(Init): Added shit

This commit is contained in:
2026-05-29 00:41:12 +00:00
commit 72005fd71d
52 changed files with 12875 additions and 0 deletions

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""NiriMod test suite."""

311
tests/test_features.py Normal file
View File

@@ -0,0 +1,311 @@
#!/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)

192
tests/test_ipc.py Normal file
View File

@@ -0,0 +1,192 @@
"""Unit tests for the niri IPC wrappers.
Tests the synchronous helpers and validates that the non-blocking async
dispatch functions are wired correctly, using mocks to avoid requiring
a live niri compositor.
"""
from __future__ import annotations
import unittest
from unittest.mock import patch
class TestRunSync(unittest.TestCase):
"""Tests for the internal _run_sync helper."""
def test_command_not_found(self):
from nirimod.niri_ipc import _run_sync
stdout, stderr, rc = _run_sync(["__nonexistent_binary_xyz__"])
self.assertEqual(rc, 1)
self.assertIn("not found", stderr)
def test_successful_command(self):
from nirimod.niri_ipc import _run_sync
stdout, stderr, rc = _run_sync(["echo", "hello"])
self.assertEqual(rc, 0)
self.assertIn("hello", stdout)
def test_timeout(self):
from nirimod.niri_ipc import _run_sync
stdout, stderr, rc = _run_sync(["sleep", "10"], timeout=0.01)
self.assertEqual(rc, 1)
self.assertIn("timed out", stderr)
class TestIsNiriRunning(unittest.TestCase):
def test_returns_false_when_niri_absent(self):
from nirimod import niri_ipc
with patch.object(niri_ipc, "_run_sync", return_value=("", "not found", 1)):
self.assertFalse(niri_ipc.is_niri_running())
def test_returns_true_when_niri_present(self):
from nirimod import niri_ipc
with patch.object(niri_ipc, "_run_sync", return_value=("niri 1.0\n", "", 0)):
self.assertTrue(niri_ipc.is_niri_running())
class TestValidateConfig(unittest.TestCase):
def test_valid_config(self):
from nirimod import niri_ipc
with patch.object(niri_ipc, "_run_sync", return_value=("Config is valid.\n", "", 0)):
ok, msg = niri_ipc.validate_config()
self.assertTrue(ok)
self.assertIn("valid", msg.lower())
def test_invalid_config(self):
from nirimod import niri_ipc
with patch.object(niri_ipc, "_run_sync", return_value=("", "parse error line 3", 1)):
ok, msg = niri_ipc.validate_config()
self.assertFalse(ok)
self.assertIn("parse error", msg)
def test_with_config_path(self):
from nirimod import niri_ipc
captured = {}
def fake_run(args, timeout=5.0):
captured["args"] = args
return ("ok", "", 0)
with patch.object(niri_ipc, "_run_sync", side_effect=fake_run):
niri_ipc.validate_config("/tmp/test.kdl")
self.assertIn("--config", captured["args"])
self.assertIn("/tmp/test.kdl", captured["args"])
class TestLoadConfigFile(unittest.TestCase):
def test_load_config_file_calls_niri_action(self):
from nirimod import niri_ipc
captured = {}
def fake_run(args, timeout=5.0):
captured["args"] = args
captured["timeout"] = timeout
return ("", "", 0)
with patch.object(niri_ipc, "_run_sync", side_effect=fake_run):
ok, msg = niri_ipc.load_config_file()
self.assertTrue(ok)
self.assertEqual(captured["args"], ["niri", "msg", "action", "load-config-file"])
self.assertEqual(captured["timeout"], 10.0)
self.assertIn("applied", msg)
def test_load_config_file_reports_failure(self):
from nirimod import niri_ipc
with patch.object(niri_ipc, "_run_sync", return_value=("", "reload failed", 1)):
ok, msg = niri_ipc.load_config_file()
self.assertFalse(ok)
self.assertIn("reload failed", msg)
class TestHasTouchpad(unittest.TestCase):
def test_caching(self):
import nirimod.niri_ipc as ipc_mod
# Clear any existing cache
ipc_mod._touchpad_cache = None
call_count = [0]
original_listdir = __import__("os").listdir
def fake_listdir(path):
if path == "/sys/class/input":
call_count[0] += 1
return []
return original_listdir(path)
with patch("os.listdir", side_effect=fake_listdir):
ipc_mod.has_touchpad()
ipc_mod.has_touchpad()
# Second call should use cache, so listdir only called once
self.assertEqual(call_count[0], 1)
# Clean up for other tests
ipc_mod._touchpad_cache = None
class TestGetVersion(unittest.TestCase):
def test_returns_version_string(self):
from nirimod import niri_ipc
with patch.object(niri_ipc, "_run_sync", return_value=("niri 1.2.3\n", "", 0)):
v = niri_ipc.get_version()
self.assertEqual(v, "niri 1.2.3")
def test_returns_unknown_on_failure(self):
from nirimod import niri_ipc
with patch.object(niri_ipc, "_run_sync", return_value=("", "error", 1)):
v = niri_ipc.get_version()
self.assertEqual(v, "unknown")
class TestRunInThread(unittest.TestCase):
"""Compatibility shim run_in_thread should invoke callback."""
def test_shim_calls_callback(self):
from nirimod import niri_ipc
try:
import gi
gi.require_version("GLib", "2.0")
from gi.repository import GLib
except (ModuleNotFoundError, Exception):
self.skipTest("gi (PyGObject) not available in this test environment")
results = []
original_idle_add = GLib.idle_add
def sync_idle_add(fn, *args):
fn(*args)
return 0
GLib.idle_add = sync_idle_add
try:
t = niri_ipc.run_in_thread(lambda: 42, lambda r: results.append(r))
t.join(timeout=2.0)
finally:
GLib.idle_add = original_idle_add
self.assertEqual(results, [42])
if __name__ == "__main__":
unittest.main()

202
tests/test_kdl_parser.py Normal file
View File

@@ -0,0 +1,202 @@
"""Unit tests for the KDL parser and writer.
Tests the core parse → mutate → write round-trip logic that underpins
all config changes in NiriMod.
"""
from __future__ import annotations
import unittest
from nirimod.kdl_parser import (
KdlNode,
KdlRawString,
find_or_create,
parse_kdl,
remove_child,
set_child_arg,
set_node_flag,
write_kdl,
)
class TestKdlRoundTrip(unittest.TestCase):
"""parse_kdl → write_kdl should produce semantically equivalent output."""
def _roundtrip(self, text: str) -> list[KdlNode]:
nodes = parse_kdl(text)
out = write_kdl(nodes)
return parse_kdl(out)
def test_simple_node(self):
nodes = parse_kdl("prefer-no-csd\n")
self.assertEqual(len(nodes), 1)
self.assertEqual(nodes[0].name, "prefer-no-csd")
def test_node_with_string_arg(self):
nodes = parse_kdl('output "eDP-1" {\n scale 2.0\n}\n')
self.assertEqual(nodes[0].name, "output")
self.assertEqual(nodes[0].args[0], "eDP-1")
scale = nodes[0].get_child("scale")
self.assertIsNotNone(scale)
self.assertAlmostEqual(scale.args[0], 2.0)
def test_boolean_values(self):
nodes = parse_kdl(
"input {\n keyboard {\n repeat-rate 30\n xkb-numlock true\n }\n}\n"
)
kb = nodes[0].get_child("keyboard")
self.assertIsNotNone(kb)
numlock = kb.get_child("xkb-numlock")
self.assertIsNotNone(numlock)
self.assertIs(numlock.args[0], True)
def test_raw_string(self):
text = "spawn-at-startup r#\"bash -c 'echo hi'\"#\n"
nodes = parse_kdl(text)
self.assertIsInstance(nodes[0].args[0], KdlRawString)
def test_raw_string_property_preserves_backslash(self):
src = 'match app-id="steam" title=r#"^notificationtoasts_\\d+_desktop$"#\n'
nodes = parse_kdl(src)
title = nodes[0].props["title"]
self.assertIsInstance(title, KdlRawString)
self.assertEqual(title, r"^notificationtoasts_\d+_desktop$")
self.assertIn(r'title=r"^notificationtoasts_\d+_desktop$"', write_kdl(nodes))
def test_write_preserves_children(self):
src = "layout {\n gaps 16\n border {\n width 2\n }\n}\n"
nodes = self._roundtrip(src)
layout = nodes[0]
self.assertEqual(layout.name, "layout")
border = layout.get_child("border")
self.assertIsNotNone(border)
self.assertEqual(border.get_child("width").args[0], 2)
def test_null_value(self):
nodes = parse_kdl("cursor-warps null\n")
self.assertIsNone(nodes[0].args[0])
def test_props(self):
nodes = parse_kdl("position x=0 y=1080\n")
self.assertEqual(nodes[0].props["x"], 0)
self.assertEqual(nodes[0].props["y"], 1080)
def test_empty_input(self):
nodes = parse_kdl("")
self.assertEqual(nodes, [])
self.assertIn("NiriMod", write_kdl(nodes))
def test_comments_are_preserved_as_trivia(self):
src = "// top-level comment\nprefer-no-csd\n"
out = write_kdl(parse_kdl(src))
self.assertIn("prefer-no-csd", out)
class TestMutationHelpers(unittest.TestCase):
"""Tests for find_or_create, set_child_arg, remove_child, set_node_flag."""
def setUp(self):
self.nodes = parse_kdl("layout {\n gaps 8\n}\n")
def test_find_existing(self):
node = find_or_create(self.nodes, "layout")
self.assertEqual(node.name, "layout")
def test_create_missing(self):
node = find_or_create(self.nodes, "input")
self.assertEqual(node.name, "input")
self.assertIn(node, self.nodes)
def test_find_or_create_nested(self):
node = find_or_create(self.nodes, "layout", "struts")
self.assertEqual(node.name, "struts")
def test_set_child_arg_creates(self):
parent = self.nodes[0]
set_child_arg(parent, "border-rule", 4)
child = parent.get_child("border-rule")
self.assertIsNotNone(child)
self.assertEqual(child.args[0], 4)
def test_set_child_arg_updates(self):
parent = self.nodes[0]
set_child_arg(parent, "gaps", 16)
self.assertEqual(parent.get_child("gaps").args[0], 16)
def test_remove_child(self):
parent = self.nodes[0]
remove_child(parent, "gaps")
self.assertIsNone(parent.get_child("gaps"))
def test_remove_nonexistent_is_noop(self):
parent = self.nodes[0]
remove_child(parent, "nonexistent")
self.assertEqual(len(parent.children), 1)
def test_set_node_flag_add(self):
parent = KdlNode("input")
set_node_flag(parent, "warp-mouse-to-focus", True)
self.assertIsNotNone(parent.get_child("warp-mouse-to-focus"))
self.assertEqual(parent.get_child("warp-mouse-to-focus").args, [])
def test_set_node_flag_serializes_bare_flag(self):
parent = KdlNode("blur")
set_node_flag(parent, "off", True)
self.assertIn("off", write_kdl([parent]))
self.assertNotIn("off true", write_kdl([parent]))
def test_set_node_flag_remove(self):
parent = KdlNode("input")
parent.children.append(KdlNode("warp-mouse-to-focus"))
set_node_flag(parent, "warp-mouse-to-focus", False)
self.assertIsNone(parent.get_child("warp-mouse-to-focus"))
def test_set_node_flag_restores_bare_flag(self):
parent = KdlNode("blur")
parent.children.append(KdlNode("off"))
set_node_flag(parent, "off", False)
set_node_flag(parent, "off", True)
self.assertIn("off", write_kdl([parent]))
self.assertNotIn("off true", write_kdl([parent]))
def test_set_node_flag_idempotent_add(self):
parent = KdlNode("input")
set_node_flag(parent, "warp-mouse-to-focus", True)
set_node_flag(parent, "warp-mouse-to-focus", True)
count = sum(1 for c in parent.children if c.name == "warp-mouse-to-focus")
self.assertEqual(count, 1)
class TestWriteKdl(unittest.TestCase):
"""Tests for the KDL serializer."""
def test_write_empty(self):
out = write_kdl([])
self.assertIn("NiriMod", out)
def test_write_simple(self):
nodes = [KdlNode("prefer-no-csd")]
out = write_kdl(nodes)
self.assertIn("prefer-no-csd", out)
def test_write_raw_string(self):
# A value containing a double-quote triggers the hash-delimited r#"..."# form
node = KdlNode("env", args=[KdlRawString('value with "double" quotes')])
out = write_kdl([node])
self.assertIn('r#"', out)
def test_write_nested(self):
parent = KdlNode("layout")
parent.children.append(KdlNode("gaps", args=[16]))
out = write_kdl([parent])
self.assertIn("gaps", out)
self.assertIn("16", out)
if __name__ == "__main__":
unittest.main()

162
tests/test_state.py Normal file
View File

@@ -0,0 +1,162 @@
"""Unit tests for the AppState manager.
Tests state initialization, dirty tracking, undo/redo integration,
commit_save, discard, and node serialization helpers — without requiring
a live GTK session or filesystem access.
"""
from __future__ import annotations
import unittest
from unittest.mock import patch
from nirimod.kdl_parser import KdlNode, KdlRawString, parse_kdl, write_kdl
from nirimod.state import AppState
class TestAppStateInit(unittest.TestCase):
"""AppState starts in a clean, non-dirty state."""
def test_initial_state(self):
state = AppState()
self.assertEqual(state.nodes, [])
self.assertEqual(state.saved_kdl, "")
self.assertFalse(state.is_dirty)
self.assertFalse(state.niri_running)
self.assertFalse(state.has_touchpad)
def test_initial_undo_empty(self):
state = AppState()
self.assertFalse(state.undo.can_undo())
self.assertFalse(state.undo.can_redo())
class TestDirtyTracking(unittest.TestCase):
def test_mark_dirty(self):
state = AppState()
self.assertFalse(state.is_dirty)
state.mark_dirty()
self.assertTrue(state.is_dirty)
def test_mark_clean(self):
state = AppState()
state.mark_dirty()
state.mark_clean()
self.assertFalse(state.is_dirty)
class TestUndoRedo(unittest.TestCase):
def _make_state_with_nodes(self, kdl: str) -> AppState:
state = AppState()
state.nodes = parse_kdl(kdl)
state._saved_kdl = kdl
return state
def test_push_and_apply_undo(self):
state = self._make_state_with_nodes("gaps 8\n")
before = "gaps 8\n"
after = "gaps 16\n"
state.push_undo("change gaps", before, after)
self.assertTrue(state.undo.can_undo())
entry = state.apply_undo()
self.assertIsNotNone(entry)
self.assertEqual(state.nodes[0].get_child("gaps") if state.nodes and state.nodes[0].children else None, None)
# After undo, nodes should be from the 'before' snapshot
kdl_out = write_kdl(state.nodes)
self.assertIn("8", kdl_out)
def test_apply_undo_empty_returns_none(self):
state = AppState()
result = state.apply_undo()
self.assertIsNone(result)
def test_apply_redo_empty_returns_none(self):
state = AppState()
result = state.apply_redo()
self.assertIsNone(result)
def test_undo_sets_dirty(self):
state = self._make_state_with_nodes("gaps 8\n")
state.push_undo("x", "gaps 16\n", "gaps 24\n")
state.apply_undo()
self.assertTrue(state.is_dirty)
def test_redo_after_undo(self):
state = self._make_state_with_nodes("gaps 8\n")
state.push_undo("x", "gaps 8\n", "gaps 16\n")
state.apply_undo()
self.assertTrue(state.undo.can_redo())
entry = state.apply_redo()
self.assertIsNotNone(entry)
kdl_out = write_kdl(state.nodes)
self.assertIn("16", kdl_out)
class TestCommitSave(unittest.TestCase):
def test_commit_save_clears_undo_and_dirty(self):
state = AppState()
state.push_undo("x", "a", "b")
state.mark_dirty()
state.commit_save("new kdl\n")
self.assertEqual(state.saved_kdl, "new kdl\n")
self.assertFalse(state.is_dirty)
self.assertFalse(state.undo.can_undo())
class TestDiscard(unittest.TestCase):
def test_discard_restores_saved_kdl(self):
state = AppState()
state._saved_kdl = "gaps 8\n"
state.nodes = parse_kdl("gaps 16\n")
state.mark_dirty()
state.push_undo("x", "gaps 8\n", "gaps 16\n")
state.discard()
self.assertFalse(state.is_dirty)
self.assertFalse(state.undo.can_undo())
kdl_out = write_kdl(state.nodes)
self.assertIn("8", kdl_out)
def test_discard_empty_saved_kdl(self):
state = AppState()
state._saved_kdl = ""
state.discard()
self.assertEqual(state.nodes, [])
class TestWriteCurrentKdl(unittest.TestCase):
def test_write_current_kdl(self):
state = AppState()
state.nodes = [KdlNode("prefer-no-csd")]
out = state.write_current_kdl()
self.assertIn("prefer-no-csd", out)
def test_write_raw_string(self):
# A string containing a double-quote forces the hash-delimited raw form
node = KdlNode("env", args=[KdlRawString('has "double" quotes')])
out = write_kdl([node])
self.assertIn('r#"', out)
class TestLoad(unittest.TestCase):
def test_load_detects_runtime(self):
state = AppState()
# state.py does: from nirimod import niri_ipc
# We patch at the canonical module location so all references see the mock.
with (
patch("nirimod.niri_ipc.is_niri_running", return_value=True),
patch("nirimod.niri_ipc.has_touchpad", return_value=True),
patch("nirimod.kdl_parser.NIRI_CONFIG") as mock_cfg,
):
mock_cfg.exists.return_value = False
state.load()
self.assertTrue(state.niri_running)
self.assertTrue(state.has_touchpad)
self.assertFalse(state.is_dirty)
if __name__ == "__main__":
unittest.main()

116
tests/test_updater.py Normal file
View File

@@ -0,0 +1,116 @@
"""Unit tests for updater terminal selection."""
from __future__ import annotations
import os
import subprocess
import tempfile
import pytest
pytest.importorskip("gi")
import unittest
from unittest.mock import patch
from nirimod import updater
class TestTerminalCandidates(unittest.TestCase):
def test_terminal_env_is_preferred(self):
with patch.dict(os.environ, {"TERMINAL": "ghostty"}, clear=False):
candidates = list(updater._terminal_candidates())
self.assertEqual(candidates[0], "ghostty")
self.assertIn("xdg-terminal-exec", candidates)
def test_ghostty_is_a_fallback_terminal(self):
self.assertIn("ghostty", updater.FALLBACK_TERMINALS)
class TestBuildTerminalCommand(unittest.TestCase):
def test_xdg_terminal_exec_gets_script_directly(self):
command = updater._build_terminal_command("xdg-terminal-exec", "/tmp/update.sh")
self.assertEqual(command, ["xdg-terminal-exec", "/tmp/update.sh"])
def test_regular_terminal_uses_execute_flag(self):
command = updater._build_terminal_command("ghostty", "/tmp/update.sh")
self.assertEqual(command, ["ghostty", "-e", "/tmp/update.sh"])
def test_terminal_command_with_existing_execute_flag(self):
command = updater._build_terminal_command(
"ghostty --gtk-single-instance=false -e", "/tmp/update.sh"
)
self.assertEqual(
command, ["ghostty", "--gtk-single-instance=false", "-e", "/tmp/update.sh"]
)
def test_invalid_terminal_command_is_ignored(self):
command = updater._build_terminal_command("ghostty '", "/tmp/update.sh")
self.assertIsNone(command)
class TestUpdateAvailability(unittest.TestCase):
def _run_git(self, repo: str, *args: str):
return subprocess.run(
["git", *args],
cwd=repo,
check=True,
capture_output=True,
text=True,
)
def _commit(self, repo: str, message: str) -> str:
self._run_git(repo, "commit", "--allow-empty", "-m", message)
return subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=repo, text=True
).strip()
def _make_repo(self) -> str:
temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(temp_dir.cleanup)
repo = temp_dir.name
self._run_git(repo, "init")
self._run_git(repo, "config", "user.email", "test@example.com")
self._run_git(repo, "config", "user.name", "Test User")
return repo
def test_branch_ahead_of_remote_main_is_not_update(self):
repo = self._make_repo()
remote_hash = self._commit(repo, "remote main")
local_hash = self._commit(repo, "local branch")
self.assertFalse(updater._update_available(local_hash, remote_hash, repo))
def test_dirty_worktree_still_gets_remote_update(self):
repo = self._make_repo()
local_hash = self._commit(repo, "installed version")
remote_hash = self._commit(repo, "remote main")
self._run_git(repo, "checkout", "--detach", local_hash)
with open(os.path.join(repo, "local-change.txt"), "w") as fh:
fh.write("local edit\n")
self.assertTrue(updater._update_available(local_hash, remote_hash, repo))
class TestLaunchUpdaterInTerminal(unittest.TestCase):
def test_launch_uses_terminal_env(self):
with (
tempfile.TemporaryDirectory() as temp_dir,
patch.dict(os.environ, {"TERMINAL": "ghostty"}, clear=False),
patch.object(updater.tempfile, "gettempdir", return_value=temp_dir),
patch.object(updater.shutil, "which", return_value="/usr/bin/ghostty"),
patch.object(updater.subprocess, "Popen") as popen,
):
updater.launch_updater_in_terminal()
popen.assert_called_once_with(
["ghostty", "-e", os.path.join(temp_dir, "nirimod_update.sh")]
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,384 @@
"""Tests for global window effect rule helpers."""
from __future__ import annotations
import unittest
from nirimod.kdl_parser import KdlNode, parse_kdl, write_kdl
from nirimod.window_effects import (
blur_effects_enabled,
focused_window_blur_enabled,
get_global_draw_border_with_background,
get_global_corner_radius,
get_global_window_opacity,
global_window_blur_enabled,
global_window_xray_enabled,
set_focused_window_blur,
set_global_draw_border_with_background,
set_global_corner_radius,
set_global_window_blur,
set_global_window_opacity,
set_global_window_xray,
set_blur_effects_enabled,
)
class TestGlobalWindowEffects(unittest.TestCase):
def test_blur_effects_are_enabled_without_top_level_off(self):
nodes = parse_kdl(
"""
blur {
passes 3
offset 3
}
"""
)
self.assertTrue(blur_effects_enabled(nodes))
def test_disabling_blur_effects_writes_top_level_off(self):
nodes: list[KdlNode] = []
set_blur_effects_enabled(nodes, False)
out = write_kdl(nodes)
self.assertIn("blur", out)
self.assertIn("off", out)
self.assertNotIn("off true", out)
self.assertFalse(blur_effects_enabled(nodes))
def test_disabling_blur_effects_preserves_quality_settings(self):
nodes = parse_kdl(
"""
blur {
passes 3
offset 3
noise 0.02
saturation 1.5
}
"""
)
set_blur_effects_enabled(nodes, False)
out = write_kdl(nodes)
self.assertIn("off", out)
self.assertIn("passes 3", out)
self.assertIn("offset 3", out)
self.assertIn("noise 0.02", out)
self.assertIn("saturation 1.5", out)
self.assertNotIn("off true", out)
def test_enabling_blur_effects_removes_only_off(self):
nodes = parse_kdl(
"""
blur {
off
passes 3
offset 3
}
"""
)
set_blur_effects_enabled(nodes, True)
out = write_kdl(nodes)
blur = parse_kdl(out)[0]
self.assertIsNone(blur.get_child("off"))
self.assertIn("passes 3", out)
self.assertIn("offset 3", out)
self.assertTrue(blur_effects_enabled(nodes))
def test_enabling_blur_effects_sets_visible_default_opacity_when_unset(self):
nodes = parse_kdl(
"""
blur {
off
passes 3
}
"""
)
set_blur_effects_enabled(nodes, True)
out = write_kdl(nodes)
self.assertEqual(get_global_window_opacity(nodes), 0.9)
self.assertIn("opacity 0.9", out)
def test_enabling_blur_effects_preserves_existing_opacity(self):
nodes = parse_kdl(
"""
blur {
off
passes 3
}
window-rule {
opacity 0.75
}
"""
)
set_blur_effects_enabled(nodes, True)
self.assertEqual(get_global_window_opacity(nodes), 0.75)
self.assertIn("opacity 0.75", write_kdl(nodes))
def test_enabling_blur_creates_matchless_window_rule(self):
nodes: list[KdlNode] = []
set_global_window_blur(nodes, True)
out = write_kdl(nodes)
self.assertIn("window-rule", out)
self.assertIn("background-effect", out)
self.assertIn("blur true", out)
self.assertNotIn("draw-border-with-background", out)
self.assertTrue(global_window_blur_enabled(nodes))
def test_enabling_blur_sets_visible_default_opacity_when_unset(self):
nodes: list[KdlNode] = []
set_global_window_blur(nodes, True)
self.assertEqual(get_global_window_opacity(nodes), 0.9)
self.assertIn("opacity 0.9", write_kdl(nodes))
def test_enabling_blur_preserves_existing_opacity(self):
nodes = parse_kdl(
"""
window-rule {
opacity 0.75
}
"""
)
set_global_window_blur(nodes, True)
self.assertEqual(get_global_window_opacity(nodes), 0.75)
self.assertIn("opacity 0.75", write_kdl(nodes))
def test_disabling_blur_preserves_other_window_effect_settings(self):
nodes = parse_kdl(
"""
window-rule {
geometry-corner-radius 16
draw-border-with-background false
background-effect {
blur true
xray false
}
}
"""
)
set_global_window_blur(nodes, False)
rule = nodes[0]
self.assertEqual(rule.child_arg("geometry-corner-radius"), 16)
self.assertIsNotNone(rule.get_child("draw-border-with-background"))
self.assertIsNotNone(rule.get_child("background-effect"))
self.assertIsNone(rule.get_child("background-effect").get_child("blur"))
self.assertIsNotNone(rule.get_child("background-effect").get_child("xray"))
self.assertFalse(global_window_blur_enabled(nodes))
def test_disabling_blur_resets_window_opacity(self):
nodes = parse_kdl(
"""
window-rule {
opacity 0.95
background-effect {
blur true
xray false
}
}
"""
)
set_global_window_blur(nodes, False)
rule = nodes[0]
self.assertEqual(get_global_window_opacity(nodes), 1.0)
self.assertIsNone(rule.get_child("opacity"))
self.assertIsNone(rule.get_child("background-effect").get_child("blur"))
self.assertIsNotNone(rule.get_child("background-effect").get_child("xray"))
def test_disabling_blur_effects_clears_forced_blur_and_opacity(self):
nodes = parse_kdl(
"""
blur {
passes 3
}
window-rule {
opacity 0.9
background-effect {
blur true
xray false
}
}
window-rule {
match is-focused=true
background-effect {
blur true
}
}
"""
)
set_blur_effects_enabled(nodes, False)
out = write_kdl(nodes)
self.assertIn("off", out)
self.assertFalse(blur_effects_enabled(nodes))
self.assertFalse(global_window_blur_enabled(nodes))
self.assertFalse(focused_window_blur_enabled(nodes))
self.assertEqual(get_global_window_opacity(nodes), 1.0)
self.assertNotIn("blur true", out)
self.assertNotIn("opacity 0.9", out)
def test_corner_radius_writes_clip_and_can_be_removed(self):
nodes: list[KdlNode] = []
set_global_corner_radius(nodes, 16)
self.assertEqual(get_global_corner_radius(nodes), 16)
out = write_kdl(nodes)
self.assertIn("geometry-corner-radius 16", out)
self.assertIn("clip-to-geometry true", out)
set_global_corner_radius(nodes, 0)
self.assertEqual(nodes, [])
def test_matched_rules_are_not_reused_as_global_effect_rules(self):
nodes = parse_kdl(
"""
window-rule {
match app-id="Alacritty"
background-effect {
blur true
}
}
"""
)
set_global_corner_radius(nodes, 12)
self.assertEqual(len([n for n in nodes if n.name == "window-rule"]), 2)
self.assertIsNone(nodes[0].get_child("geometry-corner-radius"))
self.assertEqual(get_global_corner_radius(nodes), 12)
def test_global_opacity_is_removed_when_opaque(self):
nodes: list[KdlNode] = []
set_global_window_opacity(nodes, 0.9)
self.assertEqual(get_global_window_opacity(nodes), 0.9)
self.assertIn("opacity 0.9", write_kdl(nodes))
set_global_window_opacity(nodes, 1.0)
self.assertEqual(nodes, [])
def test_draw_border_with_background_can_be_toggled(self):
nodes: list[KdlNode] = []
self.assertTrue(get_global_draw_border_with_background(nodes))
set_global_draw_border_with_background(nodes, False)
self.assertFalse(get_global_draw_border_with_background(nodes))
self.assertIn("draw-border-with-background false", write_kdl(nodes))
set_global_draw_border_with_background(nodes, True)
self.assertTrue(get_global_draw_border_with_background(nodes))
self.assertEqual(nodes, [])
def test_xray_false_is_written_with_blur(self):
nodes: list[KdlNode] = []
set_global_window_blur(nodes, True)
set_global_window_xray(nodes, False)
out = write_kdl(nodes)
self.assertIn("blur true", out)
self.assertIn("xray false", out)
self.assertFalse(global_window_xray_enabled(nodes))
def test_xray_toggle_does_not_enable_blur(self):
nodes: list[KdlNode] = []
set_global_window_xray(nodes, True)
out = write_kdl(nodes)
self.assertIn("xray true", out)
self.assertNotIn("blur true", out)
self.assertFalse(global_window_blur_enabled(nodes))
def test_generated_global_window_effect_rule_is_compact(self):
nodes: list[KdlNode] = []
set_global_corner_radius(nodes, 16)
set_global_draw_border_with_background(nodes, False)
set_global_window_blur(nodes, True)
set_global_window_xray(nodes, False)
set_global_window_opacity(nodes, 0.75)
self.assertEqual(
write_kdl(nodes).strip(),
"""window-rule {
geometry-corner-radius 16
clip-to-geometry true
draw-border-with-background false
opacity 0.75
background-effect {
blur true
xray false
}
}""",
)
def test_focused_blur_rule_does_not_duplicate_global_effect_settings(self):
nodes: list[KdlNode] = []
set_global_window_blur(nodes, True)
set_global_window_xray(nodes, False)
set_global_window_opacity(nodes, 0.75)
set_focused_window_blur(nodes, True)
out = write_kdl(nodes)
self.assertIn("match is-focused=true", out)
self.assertEqual(out.count("opacity 0.75"), 1)
self.assertEqual(out.count("blur true"), 2)
self.assertEqual(out.count("xray false"), 1)
self.assertTrue(focused_window_blur_enabled(nodes))
def test_disabling_focused_blur_preserves_other_focused_rule_settings(self):
nodes = parse_kdl(
"""
window-rule {
match is-focused=true
block-out-from "screen-capture"
draw-border-with-background false
opacity 0.75
background-effect {
blur true
xray false
}
}
"""
)
set_focused_window_blur(nodes, False)
out = write_kdl(nodes)
self.assertIn('block-out-from "screen-capture"', out)
self.assertIn("draw-border-with-background false", out)
self.assertIn("opacity 0.75", out)
self.assertNotIn("blur true", out)
self.assertIn("xray false", out)
self.assertFalse(focused_window_blur_enabled(nodes))
if __name__ == "__main__":
unittest.main()

173
tests/test_window_rules.py Normal file
View File

@@ -0,0 +1,173 @@
"""Tests for window-rule editor serialization helpers."""
from __future__ import annotations
import unittest
import pytest
pytest.importorskip("gi")
from nirimod.kdl_parser import KdlNode, write_kdl
from nirimod.pages.window_rules import (
CUSTOM_FLOATING_POSITION_INDEX,
DEFAULT_FLOATING_POSITION_RELATIVE_TO,
FLOATING_POSITION_CUSTOM_FIELD_LABELS,
FLOATING_POSITION_LOCATION_LABELS,
SCREENCAST_BLOCK_KEY,
SIZE_PERCENT_PRESETS,
_bool_action_active,
_bool_action_node,
_floating_position_location_index,
_floating_position_setting,
_make_floating_position_node,
_make_size_node,
_window_size_setting,
)
class TestWindowRuleActions(unittest.TestCase):
def test_screencast_block_action_writes_valid_niri_syntax(self):
node = _bool_action_node(SCREENCAST_BLOCK_KEY)
out = write_kdl([KdlNode("window-rule", children=[node])])
self.assertIn('block-out-from "screencast"', out)
self.assertNotIn("block-out-from-screencast", out)
def test_screencast_block_action_reads_current_syntax(self):
rule = KdlNode(
"window-rule", children=[KdlNode("block-out-from", args=["screencast"])]
)
self.assertTrue(_bool_action_active(rule, SCREENCAST_BLOCK_KEY))
def test_screencast_block_action_reads_legacy_syntax(self):
rule = KdlNode(
"window-rule", children=[KdlNode("block-out-from-screencast", args=[True])]
)
self.assertTrue(_bool_action_active(rule, SCREENCAST_BLOCK_KEY))
def test_window_rule_size_default_writes_no_override(self):
self.assertIsNone(_make_size_node("default-column-width", "default", None))
self.assertIsNone(_make_size_node("default-window-height", "default", None))
def test_window_rule_size_presets_include_full_size(self):
self.assertIn(("100%", 1.0), SIZE_PERCENT_PRESETS)
def test_window_rule_width_preset_writes_proportion_node(self):
node = _make_size_node("default-column-width", "proportion", 0.25)
out = write_kdl([KdlNode("window-rule", children=[node])])
self.assertIn("default-column-width", out)
self.assertIn("proportion 0.25", out)
self.assertNotIn("default-column-width 0.25", out)
def test_window_rule_height_preset_writes_proportion_node(self):
node = _make_size_node("default-window-height", "proportion", 1.0)
out = write_kdl([KdlNode("window-rule", children=[node])])
self.assertIn("default-window-height", out)
self.assertIn("proportion 1.0", out)
self.assertNotIn("default-window-height 1.0", out)
def test_window_rule_size_reads_nested_fixed_value(self):
rule = KdlNode(
"window-rule",
children=[
KdlNode(
"default-window-height",
children=[KdlNode("fixed", args=[270])],
)
],
)
self.assertEqual(
_window_size_setting(rule, "default-window-height"),
("fixed", 270),
)
def test_window_rule_size_reads_legacy_direct_fixed_value(self):
rule = KdlNode(
"window-rule",
children=[KdlNode("default-window-height", args=[270])],
)
self.assertEqual(
_window_size_setting(rule, "default-window-height"),
("fixed", 270),
)
def test_floating_position_default_writes_no_override(self):
self.assertIsNone(
_make_floating_position_node(
False, 0, 0, DEFAULT_FLOATING_POSITION_RELATIVE_TO
)
)
def test_floating_position_locations_are_edges_plus_custom(self):
self.assertEqual(
FLOATING_POSITION_LOCATION_LABELS,
["Top", "Bottom", "Left", "Right", "Custom"],
)
def test_floating_position_custom_fields_are_offsets_only(self):
self.assertEqual(
FLOATING_POSITION_CUSTOM_FIELD_LABELS,
["X Offset (px)", "Y Offset (px)"],
)
def test_floating_position_edge_locations_use_zero_offsets(self):
self.assertEqual(
_floating_position_location_index(0, 0, "right"),
FLOATING_POSITION_LOCATION_LABELS.index("Right"),
)
def test_floating_position_edge_offsets_are_custom(self):
self.assertEqual(
_floating_position_location_index(20, 0, "right"),
CUSTOM_FLOATING_POSITION_INDEX,
)
def test_floating_position_custom_location_is_for_non_edge_anchors(self):
self.assertEqual(
_floating_position_location_index(12, 34, "bottom-right"),
CUSTOM_FLOATING_POSITION_INDEX,
)
def test_floating_position_writes_anchor_properties(self):
node = _make_floating_position_node(True, 0, 0, "right")
out = write_kdl([KdlNode("window-rule", children=[node])])
self.assertIn(
'default-floating-position x=0 y=0 relative-to="right"',
out,
)
def test_floating_position_writes_custom_offset(self):
node = _make_floating_position_node(True, 12, 34, "right")
out = write_kdl([KdlNode("window-rule", children=[node])])
self.assertIn(
'default-floating-position x=12 y=34 relative-to="right"',
out,
)
def test_floating_position_reads_existing_anchor(self):
rule = KdlNode(
"window-rule",
children=[
KdlNode(
"default-floating-position",
props={"x": 12, "y": 34, "relative-to": "bottom-right"},
)
],
)
self.assertEqual(
_floating_position_setting(rule),
(True, 12, 34, "bottom-right"),
)
if __name__ == "__main__":
unittest.main()