312 lines
9.2 KiB
Python
312 lines
9.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Headless feature tests for NiriMod — exercises every page's logic."""
|
|
|
|
import sys
|
|
import traceback
|
|
import os
|
|
os.environ.setdefault("DISPLAY", ":0")
|
|
os.environ.setdefault("WAYLAND_DISPLAY", "wayland-1")
|
|
|
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
|
PASS = "[PASS]"
|
|
WARN = "[WARN]"
|
|
FAIL = "[FAIL]"
|
|
|
|
results = []
|
|
|
|
def test(name, fn):
|
|
try:
|
|
msg = fn()
|
|
results.append((PASS, name, msg or ""))
|
|
except Exception as e:
|
|
results.append((FAIL, name, f"{type(e).__name__}: {e}"))
|
|
traceback.print_exc()
|
|
|
|
|
|
test.__test__ = False
|
|
|
|
# KDL Parser
|
|
from nirimod.kdl_parser import parse_kdl, write_kdl, KdlNode
|
|
|
|
def t_kdl_roundtrip():
|
|
src = 'output "eDP-1" { scale 2.0; }\nbinds { XF86AudioRaise { action "volume-up"; } }'
|
|
nodes = parse_kdl(src)
|
|
assert nodes, "no nodes parsed"
|
|
return f"{len(nodes)} nodes"
|
|
|
|
def t_kdl_include():
|
|
src = 'include "~/.config/niri/dms/monitor.kdl"\nspawn-at-startup "waybar"'
|
|
nodes = parse_kdl(src)
|
|
names = [n.name for n in nodes]
|
|
assert "include" in names
|
|
assert "spawn-at-startup" in names
|
|
return "include + spawn parsed"
|
|
|
|
def t_kdl_nested():
|
|
src = 'window-rule { match app-id="firefox"; open-maximized true; }'
|
|
nodes = parse_kdl(src)
|
|
assert nodes[0].name == "window-rule"
|
|
assert nodes[0].children
|
|
return "nested nodes OK"
|
|
|
|
def t_kdl_write():
|
|
node = KdlNode("spawn-at-startup", args=["waybar", "--config", "/etc/waybar.json"])
|
|
out = write_kdl([node])
|
|
assert "waybar" in out
|
|
return out.strip()
|
|
|
|
test("KDL: parse + write roundtrip", t_kdl_roundtrip)
|
|
test("KDL: include directive parsing", t_kdl_include)
|
|
test("KDL: nested children parsing", t_kdl_nested)
|
|
test("KDL: write KdlNode with args", t_kdl_write)
|
|
|
|
# AppState
|
|
from nirimod.state import AppState
|
|
|
|
def t_state_load():
|
|
s = AppState()
|
|
s.load()
|
|
assert isinstance(s.nodes, list)
|
|
return f"{len(s.nodes)} top-level nodes loaded"
|
|
|
|
def t_state_dirty():
|
|
s = AppState()
|
|
s.load()
|
|
assert not s.is_dirty
|
|
s.mark_dirty()
|
|
assert s.is_dirty
|
|
s.mark_clean()
|
|
assert not s.is_dirty
|
|
return "dirty/clean flags OK"
|
|
|
|
def t_state_discard():
|
|
s = AppState()
|
|
s.load()
|
|
original_len = len(s.nodes)
|
|
s.nodes.append(KdlNode("test-node"))
|
|
s.mark_dirty()
|
|
s.discard()
|
|
assert len(s.nodes) == original_len
|
|
return f"discarded back to {original_len} nodes"
|
|
|
|
def t_state_undo():
|
|
s = AppState()
|
|
s.load()
|
|
before = write_kdl(s.nodes)
|
|
s.nodes.append(KdlNode("test-undo-node"))
|
|
after = write_kdl(s.nodes)
|
|
s.push_undo("add test node", before, after)
|
|
assert s.undo.can_undo()
|
|
entry = s.apply_undo()
|
|
assert entry is not None
|
|
assert "test-undo-node" not in write_kdl(s.nodes)
|
|
return "undo restored previous state"
|
|
|
|
test("AppState: load from disk", t_state_load)
|
|
test("AppState: dirty / clean flags", t_state_dirty)
|
|
test("AppState: discard reverts nodes", t_state_discard)
|
|
test("AppState: undo stack push+pop", t_state_undo)
|
|
|
|
# Undo Manager
|
|
from nirimod.undo import UndoManager, UndoEntry
|
|
|
|
def t_undo_redo():
|
|
m = UndoManager()
|
|
m.push(UndoEntry("step1", "before1", "after1"))
|
|
m.push(UndoEntry("step2", "before2", "after2"))
|
|
assert m.can_undo()
|
|
e = m.pop_undo()
|
|
assert e.description == "step2"
|
|
assert m.can_redo()
|
|
e2 = m.pop_redo()
|
|
assert e2.description == "step2"
|
|
return "undo→redo cycle OK"
|
|
|
|
test("UndoManager: push/pop/redo", t_undo_redo)
|
|
|
|
# Profiles
|
|
from nirimod import profiles as prof_mod
|
|
|
|
def t_profiles_list():
|
|
names = prof_mod.list_profiles()
|
|
assert isinstance(names, list)
|
|
return f"{len(names)} profiles found"
|
|
|
|
def t_profiles_save_delete():
|
|
s = AppState()
|
|
s.load()
|
|
# save_profile takes name + optional set[Path] of source files
|
|
prof_mod.save_profile("__test_profile__", s.source_files)
|
|
names = prof_mod.list_profiles()
|
|
assert "__test_profile__" in names, f"profile not found in {names}"
|
|
prof_mod.delete_profile("__test_profile__")
|
|
assert "__test_profile__" not in prof_mod.list_profiles()
|
|
return "save + delete profile OK"
|
|
|
|
test("Profiles: list", t_profiles_list)
|
|
test("Profiles: save and delete", t_profiles_save_delete)
|
|
|
|
# Pages (import + build check)
|
|
# We test imports and logic only — no GTK widget creation without display
|
|
page_modules = [
|
|
("appearance", "nirimod.pages.appearance"),
|
|
("animations", "nirimod.pages.animations"),
|
|
("layout", "nirimod.pages.layout"),
|
|
("startup", "nirimod.pages.startup"),
|
|
("environment","nirimod.pages.environment"),
|
|
("workspaces", "nirimod.pages.workspaces"),
|
|
("window_rules","nirimod.pages.window_rules"),
|
|
("bindings", "nirimod.pages.bindings"),
|
|
("outputs", "nirimod.pages.outputs"),
|
|
("input_page", "nirimod.pages.input_page"),
|
|
("gestures", "nirimod.pages.gestures"),
|
|
("raw_config", "nirimod.pages.raw_config"),
|
|
]
|
|
|
|
import importlib
|
|
for name, module_path in page_modules:
|
|
def _test(mp=module_path, n=name):
|
|
importlib.import_module(mp)
|
|
return "module imported OK"
|
|
test(f"Page import: {name}", _test)
|
|
|
|
# Startup page logic
|
|
import shlex
|
|
|
|
def t_startup_spawn_sh():
|
|
cmd = "waybar --config /etc/waybar.json"
|
|
node = KdlNode("spawn-sh-at-startup", args=[cmd]) # single string for sh
|
|
assert node.args[0] == cmd
|
|
return f"spawn-sh-at-startup args = {node.args}"
|
|
|
|
def t_startup_spawn_direct():
|
|
cmd = "dunst"
|
|
args = shlex.split(cmd)
|
|
node = KdlNode("spawn-at-startup", args=args)
|
|
assert node.args == ["dunst"]
|
|
return "spawn-at-startup args OK"
|
|
|
|
test("Startup: spawn-sh-at-startup node", t_startup_spawn_sh)
|
|
test("Startup: spawn-at-startup node", t_startup_spawn_direct)
|
|
|
|
# Animations curve serialization
|
|
def t_anim_curve_format():
|
|
# The correct niri format is: curve "cubic-bezier" 0.25 0.1 0.25 1.0
|
|
kdl = 'animations { workspace-switch { spring damping-ratio=1.0; } }'
|
|
nodes = parse_kdl(kdl)
|
|
out = write_kdl(nodes)
|
|
assert "workspace-switch" in out
|
|
return "animation node roundtrip OK"
|
|
|
|
test("Animations: curve node roundtrip", t_anim_curve_format)
|
|
|
|
# Environment page logic
|
|
def t_env_node():
|
|
node = KdlNode("environment", children=[
|
|
KdlNode("WAYLAND_DISPLAY", args=["wayland-1"])
|
|
])
|
|
out = write_kdl([node])
|
|
assert "WAYLAND_DISPLAY" in out
|
|
return out.strip()
|
|
|
|
test("Environment: env var node write", t_env_node)
|
|
|
|
# Window rules logic
|
|
def t_window_rule_node():
|
|
kdl = 'window-rule { match app-id="org.gnome.Calculator"; open-floating true; }'
|
|
nodes = parse_kdl(kdl)
|
|
assert nodes[0].name == "window-rule"
|
|
children_names = [c.name for c in nodes[0].children]
|
|
assert "match" in children_names
|
|
assert "open-floating" in children_names
|
|
return f"children: {children_names}"
|
|
|
|
test("Window Rules: parse match+action", t_window_rule_node)
|
|
|
|
# Output node
|
|
def t_output_node():
|
|
kdl = 'output "eDP-1" { scale 1.5; transform "90"; mode "1920x1080@60"; }'
|
|
nodes = parse_kdl(kdl)
|
|
assert nodes[0].name == "output"
|
|
assert nodes[0].args == ["eDP-1"]
|
|
children = {c.name: c for c in nodes[0].children}
|
|
assert "scale" in children
|
|
assert float(children["scale"].args[0]) == 1.5
|
|
return "output node parsed OK"
|
|
|
|
test("Outputs: parse output node", t_output_node)
|
|
|
|
# Bindings logic
|
|
def t_binds_node():
|
|
kdl = 'binds { Mod+T { action spawn "alacritty"; } Mod+Q { action close-window; } }'
|
|
nodes = parse_kdl(kdl)
|
|
assert nodes[0].name == "binds"
|
|
assert len(nodes[0].children) == 2
|
|
return f"{len(nodes[0].children)} binds found"
|
|
|
|
test("Bindings: parse binds block", t_binds_node)
|
|
|
|
# Workspaces logic
|
|
def t_workspaces_node():
|
|
kdl = 'workspaces { workspace "Browser"; workspace "Terminal"; }'
|
|
nodes = parse_kdl(kdl)
|
|
assert nodes[0].name == "workspaces"
|
|
return f"{len(nodes[0].children)} workspaces"
|
|
|
|
test("Workspaces: parse workspace names", t_workspaces_node)
|
|
|
|
# NiriIPC
|
|
from nirimod import niri_ipc
|
|
|
|
def t_ipc_is_running():
|
|
result = niri_ipc.is_niri_running()
|
|
assert isinstance(result, bool)
|
|
return f"niri running = {result}"
|
|
|
|
def t_ipc_has_touchpad():
|
|
result = niri_ipc.has_touchpad()
|
|
assert isinstance(result, bool)
|
|
return f"has touchpad = {result}"
|
|
|
|
test("NiriIPC: is_niri_running()", t_ipc_is_running)
|
|
test("NiriIPC: has_touchpad()", t_ipc_has_touchpad)
|
|
|
|
# AppSettings
|
|
from nirimod import app_settings
|
|
|
|
def t_app_settings():
|
|
original = app_settings.get("auto_update", True)
|
|
app_settings.set("auto_update", False)
|
|
assert not app_settings.get("auto_update")
|
|
app_settings.set("auto_update", original)
|
|
return "get/set OK"
|
|
|
|
test("AppSettings: get/set", t_app_settings)
|
|
|
|
def _print_results() -> int:
|
|
print("\n" + "="*50)
|
|
print(" NIRIMOD FEATURE TEST REPORT")
|
|
print("="*50)
|
|
|
|
passed = sum(1 for r in results if r[0] == PASS)
|
|
failed = sum(1 for r in results if r[0] == FAIL)
|
|
warned = sum(1 for r in results if r[0] == WARN)
|
|
|
|
for icon, name, detail in results:
|
|
status = f"{icon} {name}"
|
|
if detail:
|
|
print(f"{status}\n → {detail}")
|
|
else:
|
|
print(status)
|
|
|
|
print("="*50)
|
|
print(
|
|
f" {passed} passed | {warned} warnings | {failed} failed | {len(results)} total"
|
|
)
|
|
print("="*50)
|
|
return failed
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(1 if _print_results() else 0)
|