278 lines
12 KiB
Python
278 lines
12 KiB
Python
"""Contrat Session Snapshot V1 sur arbres Sway synthétiques."""
|
|
|
|
import copy
|
|
import importlib.util
|
|
from pathlib import Path
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
|
|
SOURCE = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "quickshell/.config/quickshell/labfy-sway/session/session_snapshot.py"
|
|
)
|
|
SPEC = importlib.util.spec_from_file_location("session_snapshot", SOURCE)
|
|
snapshot_module = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(snapshot_module)
|
|
|
|
|
|
def leaf(
|
|
con_id,
|
|
app_id,
|
|
*,
|
|
focused=False,
|
|
floating="auto_off",
|
|
fullscreen=0,
|
|
properties=None,
|
|
scratchpad_state="none",
|
|
):
|
|
return {
|
|
"id": con_id,
|
|
"type": "con",
|
|
"name": f"titre {con_id}",
|
|
"app_id": app_id,
|
|
"window": con_id if properties else None,
|
|
"window_properties": properties,
|
|
"pid": 90000 + con_id,
|
|
"nodes": [],
|
|
"floating_nodes": [],
|
|
"layout": "none",
|
|
"orientation": "none",
|
|
"percent": 0.5,
|
|
"rect": {"x": con_id, "y": 2, "width": 300, "height": 200},
|
|
"focused": focused,
|
|
"urgent": False,
|
|
"marks": [f"mark-{con_id}"],
|
|
"floating": floating,
|
|
"fullscreen_mode": fullscreen,
|
|
"scratchpad_state": scratchpad_state,
|
|
}
|
|
|
|
|
|
def container(con_id, layout, children, floating_nodes=None):
|
|
return {
|
|
"id": con_id,
|
|
"type": "con",
|
|
"name": None,
|
|
"nodes": children,
|
|
"floating_nodes": floating_nodes or [],
|
|
"layout": layout,
|
|
"orientation": "horizontal" if layout in {"splith", "tabbed"} else "vertical",
|
|
"percent": 1.0,
|
|
"rect": {"x": 0, "y": 0, "width": 1200, "height": 800},
|
|
"focused": False,
|
|
"urgent": False,
|
|
"marks": [],
|
|
}
|
|
|
|
|
|
def fixture():
|
|
firefox_one = leaf(11, "firefox")
|
|
firefox_two = leaf(12, "firefox", fullscreen=1)
|
|
kitty = leaf(13, "kitty", focused=True)
|
|
xwayland = leaf(14, None, properties={"class": "LegacyApp", "instance": "legacy"})
|
|
unknown = leaf(15, "not-catalogued")
|
|
floating = leaf(16, "float-app", floating="user_on")
|
|
tabbed = container(21, "tabbed", [firefox_one, firefox_two])
|
|
stacked = container(22, "stacked", [kitty, xwayland])
|
|
splitv = container(23, "splitv", [tabbed, stacked])
|
|
splith = container(24, "splith", [splitv, unknown], [floating])
|
|
workspace = {
|
|
"id": 4,
|
|
"type": "workspace",
|
|
"name": "dev",
|
|
"nodes": [splith],
|
|
"floating_nodes": [],
|
|
"layout": "splith",
|
|
"orientation": "horizontal",
|
|
}
|
|
scratch = leaf(30, "scratch-app", floating="user_on", scratchpad_state="changed")
|
|
tree = {
|
|
"id": 1,
|
|
"type": "root",
|
|
"nodes": [
|
|
{
|
|
"id": 2,
|
|
"type": "output",
|
|
"name": "__i3",
|
|
"nodes": [{"id": 3, "type": "workspace", "name": "__i3_scratch", "nodes": [], "floating_nodes": [scratch]}],
|
|
"floating_nodes": [],
|
|
},
|
|
{"id": 5, "type": "output", "name": "DP-1", "nodes": [workspace], "floating_nodes": []},
|
|
],
|
|
}
|
|
outputs = [{
|
|
"name": "DP-1",
|
|
"active": True,
|
|
"focused": True,
|
|
"rect": {"x": 0, "y": 0, "width": 1200, "height": 800},
|
|
"scale": 1.25,
|
|
"transform": "normal",
|
|
"current_mode": {"width": 1920, "height": 1080, "refresh": 60000},
|
|
}]
|
|
workspaces = [{
|
|
"name": "dev",
|
|
"num": -1,
|
|
"output": "DP-1",
|
|
"focused": True,
|
|
"visible": True,
|
|
"urgent": False,
|
|
"rect": {"x": 0, "y": 0, "width": 1200, "height": 800},
|
|
"layout": "splith",
|
|
"orientation": "horizontal",
|
|
}]
|
|
version = {"variant": "sway", "human_readable": "0.6", "sway_original_version": "1.12.0"}
|
|
entries = [
|
|
{"desktop_entry": "firefox.desktop", "stem": "firefox", "startup_wm_class": "firefox", "name": "Firefox"},
|
|
{"desktop_entry": "kitty.desktop", "stem": "kitty", "startup_wm_class": "kitty", "name": "Kitty"},
|
|
{"desktop_entry": "legacy.desktop", "stem": "legacy-desktop", "startup_wm_class": "LegacyApp", "name": "Legacy"},
|
|
]
|
|
return version, outputs, workspaces, tree, entries
|
|
|
|
|
|
class SessionSnapshotTest(unittest.TestCase):
|
|
def build(self):
|
|
version, outputs, workspaces, tree, entries = fixture()
|
|
with mock.patch.object(snapshot_module, "_basename_from_pid", return_value=None):
|
|
return snapshot_module.build_snapshot(
|
|
version,
|
|
outputs,
|
|
workspaces,
|
|
tree,
|
|
captured_at="2026-01-01T00:00:00.000Z",
|
|
environment={"XDG_SESSION_TYPE": "wayland", "XDG_CURRENT_DESKTOP": "sway"},
|
|
desktop_entries=entries,
|
|
managed_autostart={"float-app"},
|
|
)
|
|
|
|
def test_layout_tree_order_and_simple_workspace(self):
|
|
snapshot = self.build()
|
|
self.assertEqual(snapshot["workspaces"][0]["name"], "dev")
|
|
self.assertEqual(snapshot["workspaces"][0]["roots"]["tiling"], [{"kind": "container", "id": "c1"}])
|
|
layouts = {item["layout"] for item in snapshot["containers"]}
|
|
self.assertTrue({"splith", "splitv", "tabbed", "stacked"}.issubset(layouts))
|
|
top = snapshot["containers"][0]
|
|
self.assertEqual([ref["kind"] for ref in top["children"]], ["container", "window"])
|
|
|
|
def test_simple_direct_window_and_empty_workspace(self):
|
|
version, outputs, workspaces, tree, entries = fixture()
|
|
direct = leaf(40, "firefox")
|
|
real_output = tree["nodes"][1]
|
|
real_output["nodes"][0]["nodes"] = [direct]
|
|
empty = {
|
|
"id": 41, "type": "workspace", "name": "2", "nodes": [],
|
|
"floating_nodes": [], "layout": "splith", "orientation": "horizontal",
|
|
}
|
|
real_output["nodes"].append(empty)
|
|
workspaces.append({
|
|
"name": "2", "num": 2, "output": "DP-1", "focused": False,
|
|
"visible": False, "urgent": False,
|
|
"rect": {"x": 0, "y": 0, "width": 1200, "height": 800},
|
|
"layout": "splith", "orientation": "horizontal",
|
|
})
|
|
with mock.patch.object(snapshot_module, "_basename_from_pid", return_value=None):
|
|
snapshot = snapshot_module.build_snapshot(version, outputs, workspaces, tree, desktop_entries=entries)
|
|
self.assertEqual(snapshot["workspaces"][0]["roots"]["tiling"][0]["kind"], "window")
|
|
self.assertEqual(snapshot["workspaces"][1]["roots"], {"tiling": [], "floating": []})
|
|
|
|
def test_floating_fullscreen_focus_and_marks(self):
|
|
snapshot = self.build()
|
|
floating = next(window for window in snapshot["windows"] if window["app_id"] == "float-app")
|
|
fullscreen = next(window for window in snapshot["windows"] if window["runtime"]["con_id"] == 12)
|
|
kitty = next(window for window in snapshot["windows"] if window["app_id"] == "kitty")
|
|
self.assertTrue(floating["floating"])
|
|
self.assertEqual(floating["classification"]["managed_by"], "sway-autostart")
|
|
self.assertEqual(fullscreen["fullscreen_mode"], 1)
|
|
self.assertEqual(snapshot["focus"]["window_id"], kitty["window_id"])
|
|
self.assertEqual(kitty["marks"], ["mark-13"])
|
|
|
|
def test_duplicate_app_ids_keep_distinct_snapshot_ordinals(self):
|
|
windows = [window for window in self.build()["windows"] if window["app_id"] == "firefox"]
|
|
self.assertEqual([window["snapshot_identity"] for window in windows], ["firefox#1", "firefox#2"])
|
|
self.assertNotEqual(windows[0]["window_id"], windows[1]["window_id"])
|
|
|
|
def test_desktop_identity_exact_xwayland_and_unresolved(self):
|
|
snapshot = self.build()
|
|
firefox = next(window for window in snapshot["windows"] if window["app_id"] == "firefox")
|
|
legacy = next(window for window in snapshot["windows"] if window["xwayland"]["class"] == "LegacyApp")
|
|
unknown = next(window for window in snapshot["windows"] if window["app_id"] == "not-catalogued")
|
|
self.assertEqual(firefox["restore_identity"]["desktop_entry"], "firefox.desktop")
|
|
self.assertEqual(legacy["restore_identity"]["matched_by"], "StartupWMClass")
|
|
self.assertEqual(legacy["restore_identity"]["confidence"], "exact")
|
|
self.assertEqual(unknown["restore_identity"]["confidence"], "unresolved")
|
|
|
|
def test_hidden_scratchpad_is_not_a_user_workspace(self):
|
|
snapshot = self.build()
|
|
scratch = next(window for window in snapshot["windows"] if window["app_id"] == "scratch-app")
|
|
self.assertIsNone(scratch["workspace"])
|
|
self.assertTrue(scratch["scratchpad"]["member"])
|
|
self.assertEqual(scratch["scratchpad"]["visibility"], "hidden")
|
|
self.assertEqual(snapshot["restore_hints"]["scratchpad_roots"], [{"kind": "window", "id": scratch["window_id"]}])
|
|
|
|
def test_visible_scratchpad_member_stays_on_real_workspace(self):
|
|
version, outputs, workspaces, tree, entries = fixture()
|
|
visible = leaf(42, "scratch-visible", scratchpad_state="fresh")
|
|
tree["nodes"][1]["nodes"][0]["floating_nodes"] = [visible]
|
|
with mock.patch.object(snapshot_module, "_basename_from_pid", return_value=None):
|
|
snapshot = snapshot_module.build_snapshot(version, outputs, workspaces, tree, desktop_entries=entries)
|
|
window = next(item for item in snapshot["windows"] if item["app_id"] == "scratch-visible")
|
|
self.assertEqual(window["workspace"], "dev")
|
|
self.assertEqual(window["scratchpad"]["visibility"], "visible")
|
|
|
|
def test_unique_executable_basename_is_only_heuristic(self):
|
|
entries = [{"desktop_entry": "odd-name.desktop", "stem": "tool", "startup_wm_class": None, "name": "Tool"}]
|
|
identity = snapshot_module.resolve_desktop_entry("unrelated", {}, "tool", entries)
|
|
self.assertEqual(identity, {
|
|
"desktop_entry": "odd-name.desktop",
|
|
"confidence": "heuristic",
|
|
"matched_by": "executable-basename",
|
|
})
|
|
|
|
def test_title_and_timestamp_do_not_affect_structural_projection(self):
|
|
first = self.build()
|
|
second = copy.deepcopy(first)
|
|
second["metadata"]["captured_at"] = "2027-02-02T00:00:00Z"
|
|
second["windows"][0]["title_hint"] = "titre dynamique différent"
|
|
self.assertEqual(
|
|
snapshot_module.structural_projection(first),
|
|
snapshot_module.structural_projection(second),
|
|
)
|
|
|
|
def test_validator_rejects_broken_tree_relation(self):
|
|
snapshot = self.build()
|
|
snapshot["workspaces"][0]["roots"]["tiling"][0]["id"] = "absent"
|
|
with self.assertRaises(snapshot_module.SnapshotError):
|
|
snapshot_module.validate_snapshot(snapshot)
|
|
|
|
def test_validator_rejects_duplicate_reference(self):
|
|
snapshot = self.build()
|
|
root = snapshot["workspaces"][0]["roots"]["tiling"][0]
|
|
snapshot["workspaces"][0]["roots"]["tiling"].append(copy.deepcopy(root))
|
|
with self.assertRaises(snapshot_module.SnapshotError):
|
|
snapshot_module.validate_snapshot(snapshot)
|
|
|
|
def test_autostart_parser_excludes_shell_wrappers_and_keeps_direct_apps(self):
|
|
with tempfile.TemporaryDirectory() as folder:
|
|
path = Path(folder) / "autostart"
|
|
path.write_text(
|
|
"exec limusic-app\n"
|
|
"exec python3 ~/.config/sway/scripts/inactive-windows-transparency.py\n"
|
|
"exec sh -c 'private command'\n",
|
|
encoding="utf-8",
|
|
)
|
|
self.assertEqual(
|
|
snapshot_module.parse_sway_autostart(path),
|
|
{"limusic-app", "inactive-windows-transparency.py"},
|
|
)
|
|
|
|
def test_no_command_line_or_environment_field_exists(self):
|
|
serialized_keys = repr(self.build()).casefold()
|
|
self.assertNotIn("cmdline", serialized_keys)
|
|
self.assertNotIn("command_line", serialized_keys)
|
|
self.assertNotIn("/proc/", serialized_keys)
|
|
self.assertNotIn("environment", serialized_keys)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|