(Init): Added shit
This commit is contained in:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""NiriMod test suite."""
|
||||
311
tests/test_features.py
Normal file
311
tests/test_features.py
Normal 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
192
tests/test_ipc.py
Normal 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
202
tests/test_kdl_parser.py
Normal 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
162
tests/test_state.py
Normal 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
116
tests/test_updater.py
Normal 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()
|
||||
384
tests/test_window_effects.py
Normal file
384
tests/test_window_effects.py
Normal 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
173
tests/test_window_rules.py
Normal 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()
|
||||
Reference in New Issue
Block a user