343 lines
17 KiB
Python
343 lines
17 KiB
Python
"""Contrat Restore Plan V1 : intentions déterministes et aucune exécution."""
|
|
|
|
import copy
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
RESTORE_SOURCE = ROOT / "quickshell/.config/quickshell/labfy-sway/session/session_restore.py"
|
|
SNAPSHOT_SOURCE = ROOT / "quickshell/.config/quickshell/labfy-sway/session/session_snapshot.py"
|
|
|
|
|
|
def load_module(name, path):
|
|
spec = importlib.util.spec_from_file_location(name, path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
restore = load_module("session_restore_test", RESTORE_SOURCE)
|
|
snapshot_backend = load_module("session_snapshot_restore_test", SNAPSHOT_SOURCE)
|
|
|
|
|
|
def window(
|
|
window_id,
|
|
app_id,
|
|
*,
|
|
desktop_entry=None,
|
|
identity_confidence="exact",
|
|
title=None,
|
|
workspace="1",
|
|
output="eDP-1",
|
|
category="user-application",
|
|
managed_by=None,
|
|
floating=False,
|
|
fullscreen=0,
|
|
focused=False,
|
|
con_id=100,
|
|
pid=1000,
|
|
parent=None,
|
|
index=0,
|
|
xclass=None,
|
|
instance=None,
|
|
executable=None,
|
|
scratchpad=None,
|
|
):
|
|
return {
|
|
"window_id": window_id,
|
|
"snapshot_identity": f"{app_id or xclass or executable or 'unknown'}#{window_id[1:]}",
|
|
"runtime": {"con_id": con_id, "pid": pid},
|
|
"workspace": workspace,
|
|
"output": output,
|
|
"app_id": app_id,
|
|
"xwayland": {"class": xclass, "instance": instance},
|
|
"executable_basename": executable or app_id,
|
|
"title_hint": title,
|
|
"restore_identity": {
|
|
"app_id": app_id,
|
|
"class": xclass,
|
|
"instance": instance,
|
|
"desktop_entry": desktop_entry,
|
|
"confidence": identity_confidence,
|
|
"matched_by": "desktop-id" if desktop_entry else None,
|
|
},
|
|
"restore_adapter": "generic-desktop" if desktop_entry else "unknown",
|
|
"classification": {"category": category, "managed_by": managed_by},
|
|
"focused": focused,
|
|
"urgent": False,
|
|
"floating": floating,
|
|
"fullscreen_mode": fullscreen,
|
|
"rect": {"x": 0, "y": 0, "width": 800, "height": 600},
|
|
"percent": 1.0,
|
|
"marks": [],
|
|
"scratchpad": scratchpad or {"member": False, "visibility": "not-applicable", "state": "none"},
|
|
"tree_position": {"parent_container_id": parent, "branch": "tiling", "index": index},
|
|
}
|
|
|
|
|
|
def session(
|
|
windows,
|
|
*,
|
|
persistent=False,
|
|
name="test",
|
|
outputs=("eDP-1",),
|
|
workspace_outputs=None,
|
|
containers=None,
|
|
focused=None,
|
|
captured_at="2026-10-05T00:00:00.000Z",
|
|
):
|
|
workspace_outputs = workspace_outputs or {}
|
|
workspace_names = []
|
|
for item in windows:
|
|
workspace = item.get("workspace")
|
|
if workspace is not None and workspace not in workspace_names:
|
|
workspace_names.append(workspace)
|
|
metadata = {
|
|
"captured_at": captured_at,
|
|
"desktop": "sway:wlroots:swayfx",
|
|
"session_type": "wayland",
|
|
}
|
|
if persistent:
|
|
metadata.update({
|
|
"session_name": name,
|
|
"created_at": captured_at,
|
|
"updated_at": captured_at,
|
|
})
|
|
focused_id = focused
|
|
if focused_id is None:
|
|
focused_id = next((item["window_id"] for item in windows if item.get("focused")), None)
|
|
return {
|
|
"schema": "labfy.sway.session-snapshot",
|
|
"version": 1,
|
|
"metadata": metadata,
|
|
"compositor": {
|
|
"variant": "sway",
|
|
"version": "0.6",
|
|
"sway_version": "1.12.0",
|
|
"layout_engine": "sway-native",
|
|
},
|
|
"outputs": [
|
|
{"name": item, "active": True, "focused": index == 0}
|
|
for index, item in enumerate(outputs)
|
|
],
|
|
"workspaces": [
|
|
{
|
|
"name": item,
|
|
"output": workspace_outputs.get(item, outputs[0] if outputs else None),
|
|
"layout": "splith",
|
|
"orientation": "horizontal",
|
|
}
|
|
for item in workspace_names
|
|
],
|
|
"containers": containers or [],
|
|
"windows": copy.deepcopy(windows),
|
|
"focus": {
|
|
"output": outputs[0] if outputs else None,
|
|
"workspace": next((item.get("workspace") for item in windows if item["window_id"] == focused_id), None),
|
|
"container_id": None,
|
|
"window_id": focused_id,
|
|
},
|
|
}
|
|
|
|
|
|
def action_types(plan):
|
|
return [item["action"] for item in plan["actions"]]
|
|
|
|
|
|
class RestorePlannerTest(unittest.TestCase):
|
|
def test_exact_reuse_identical_session_has_no_mutation(self):
|
|
saved = window("w1", "firefox", desktop_entry="firefox.desktop", focused=True)
|
|
live = copy.deepcopy(saved)
|
|
live["runtime"] = {"con_id": 999, "pid": 8888}
|
|
plan = restore.build_restore_plan(session([saved], persistent=True), session([live]))
|
|
self.assertEqual(action_types(plan), ["reuse-window"])
|
|
self.assertEqual(plan["matches"][0]["confidence"], "exact")
|
|
self.assertEqual(plan["diagnostics"]["mutating_commands_executed"], 0)
|
|
|
|
def test_missing_exact_user_application_uses_future_uwsm_backend(self):
|
|
saved = window("w1", "firefox", desktop_entry="firefox.desktop", focused=True)
|
|
plan = restore.build_restore_plan(session([saved], persistent=True), session([]))
|
|
launch = next(item for item in plan["actions"] if item["action"] == "launch-application")
|
|
self.assertEqual(launch["launch"]["argv"], ["uwsm", "app", "--", "firefox.desktop"])
|
|
self.assertFalse(launch["launch"]["execute_in_step19a"])
|
|
|
|
def test_synthetic_firefox_kitty_limusic_with_only_limusic_live(self):
|
|
saved = [
|
|
window("w1", "firefox", desktop_entry="firefox.desktop"),
|
|
window("w2", "kitty", desktop_entry="kitty.desktop", executable="kitty"),
|
|
window("w3", "limusic-app", desktop_entry="limusic.desktop", workspace="10", category="autostart-managed-application", managed_by="sway-autostart"),
|
|
]
|
|
live = [copy.deepcopy(saved[2])]
|
|
plan = restore.build_restore_plan(session(saved, persistent=True), session(live))
|
|
launches = [item["desktop_entry"] for item in plan["actions"] if item["action"] == "launch-application"]
|
|
self.assertEqual(launches, ["firefox.desktop", "kitty.desktop"])
|
|
self.assertEqual(sum(item["action"] == "reuse-window" for item in plan["actions"]), 1)
|
|
|
|
def test_existing_application_on_wrong_workspace_is_reused_then_moved(self):
|
|
saved = window("w1", "firefox", desktop_entry="firefox.desktop", workspace="1", focused=True)
|
|
live = window("w1", "firefox", desktop_entry="firefox.desktop", workspace="2", focused=True)
|
|
plan = restore.build_restore_plan(session([saved], persistent=True), session([live]))
|
|
self.assertIn("reuse-window", action_types(plan))
|
|
self.assertIn("move-to-workspace", action_types(plan))
|
|
self.assertNotIn("launch-application", action_types(plan))
|
|
move = next(item for item in plan["actions"] if item["action"] == "move-to-workspace")
|
|
self.assertEqual(move["desired_workspace"], "1")
|
|
|
|
def test_multi_window_matches_only_best_title_and_never_reuses_twice(self):
|
|
saved_a = window("w1", "firefox", desktop_entry="firefox.desktop", title="Firefox — A")
|
|
saved_b = window("w2", "firefox", desktop_entry="firefox.desktop", title="Firefox — B")
|
|
live_b = window("w1", "firefox", desktop_entry="firefox.desktop", title="Firefox — B", pid=5000)
|
|
plan = restore.build_restore_plan(session([saved_a, saved_b], persistent=True), session([live_b]))
|
|
self.assertEqual(len(plan["matches"]), 1)
|
|
self.assertEqual(plan["matches"][0]["source_window"], "w2")
|
|
launch = next(item for item in plan["actions"] if item["action"] == "launch-application")
|
|
self.assertEqual(launch["source_window"], "w1")
|
|
|
|
def test_title_hint_is_not_required_identity(self):
|
|
saved = window("w1", "firefox", desktop_entry="firefox.desktop", title="Ancien titre")
|
|
live = window("w9", "firefox", desktop_entry="firefox.desktop", title="Nouveau titre")
|
|
plan = restore.build_restore_plan(session([saved], persistent=True), session([live]))
|
|
self.assertEqual(plan["matches"][0]["source_window"], "w1")
|
|
|
|
def test_app_id_without_desktop_entry_is_high_confidence(self):
|
|
saved = window("w1", "custom-app", identity_confidence="unresolved")
|
|
live = window("w7", "custom-app", identity_confidence="unresolved")
|
|
plan = restore.build_restore_plan(session([saved], persistent=True), session([live]))
|
|
self.assertEqual(plan["matches"][0]["confidence"], "high")
|
|
self.assertIn("app-id", plan["matches"][0]["matched_by"])
|
|
|
|
def test_xwayland_class_without_app_id_is_high_confidence(self):
|
|
saved = window("w1", None, xclass="LegacyApp", executable=None, identity_confidence="unresolved")
|
|
live = window("w8", None, xclass="LegacyApp", executable=None, identity_confidence="unresolved")
|
|
saved["executable_basename"] = None
|
|
live["executable_basename"] = None
|
|
plan = restore.build_restore_plan(session([saved], persistent=True), session([live]))
|
|
self.assertEqual(plan["matches"][0]["confidence"], "high")
|
|
self.assertEqual(plan["matches"][0]["matched_by"], ["class"])
|
|
|
|
def test_indistinguishable_live_candidates_are_ambiguous_but_one_to_one(self):
|
|
saved = window("w1", "firefox", desktop_entry="firefox.desktop")
|
|
first = window("w1", "firefox", desktop_entry="firefox.desktop", pid=2000)
|
|
second = window("w2", "firefox", desktop_entry="firefox.desktop", pid=3000)
|
|
plan = restore.build_restore_plan(session([saved], persistent=True), session([first, second]))
|
|
self.assertEqual(len(plan["matches"]), 1)
|
|
self.assertEqual(plan["matches"][0]["confidence"], "ambiguous")
|
|
|
|
def test_absent_autostart_application_is_not_launched(self):
|
|
saved = window("w1", "limusic-app", desktop_entry="limusic.desktop", category="autostart-managed-application", managed_by="sway-autostart")
|
|
plan = restore.build_restore_plan(session([saved], persistent=True), session([]))
|
|
self.assertEqual(action_types(plan), ["skip-autostart-managed"])
|
|
self.assertNotIn("launch-application", action_types(plan))
|
|
|
|
def test_unresolved_window_requires_manual_action(self):
|
|
saved = window("w1", None, identity_confidence="unresolved", executable=None)
|
|
saved["executable_basename"] = None
|
|
plan = restore.build_restore_plan(session([saved], persistent=True), session([]))
|
|
self.assertEqual(action_types(plan), ["manual-required"])
|
|
self.assertEqual(plan["actions"][0]["reason"], "no-exact-launch-identity")
|
|
|
|
def test_missing_output_is_explicit_and_has_no_fallback_launch(self):
|
|
saved = window("w1", "firefox", desktop_entry="firefox.desktop", output="HDMI-A-1")
|
|
plan = restore.build_restore_plan(session([saved], persistent=True, outputs=("HDMI-A-1",)), session([], outputs=("eDP-1",)))
|
|
self.assertEqual(action_types(plan), ["manual-required"])
|
|
self.assertEqual(plan["actions"][0]["policy"], "deferred-no-fallback")
|
|
|
|
def test_floating_difference_produces_intention_only(self):
|
|
saved = window("w1", "kitty", desktop_entry="kitty.desktop", floating=True)
|
|
live = window("w1", "kitty", desktop_entry="kitty.desktop", floating=False)
|
|
plan = restore.build_restore_plan(session([saved], persistent=True), session([live]))
|
|
action = next(item for item in plan["actions"] if item["action"] == "restore-floating")
|
|
self.assertTrue(action["desired_floating"])
|
|
|
|
def test_fullscreen_difference_produces_intention_only(self):
|
|
saved = window("w1", "firefox", desktop_entry="firefox.desktop", fullscreen=1)
|
|
live = window("w1", "firefox", desktop_entry="firefox.desktop", fullscreen=0)
|
|
plan = restore.build_restore_plan(session([saved], persistent=True), session([live]))
|
|
action = next(item for item in plan["actions"] if item["action"] == "restore-fullscreen")
|
|
self.assertEqual(action["desired_fullscreen_mode"], 1)
|
|
|
|
def test_layout_difference_is_partially_supported(self):
|
|
saved = window("w1", "firefox", desktop_entry="firefox.desktop", parent="c1")
|
|
live = window("w1", "firefox", desktop_entry="firefox.desktop")
|
|
container = {
|
|
"container_id": "c1", "parent_container_id": None,
|
|
"tree_position": {"branch": "tiling", "index": 0},
|
|
"layout": "splitv", "orientation": "vertical", "percent": 1.0,
|
|
}
|
|
plan = restore.build_restore_plan(session([saved], persistent=True, containers=[container]), session([live]))
|
|
action = next(item for item in plan["actions"] if item["action"] == "restore-tree-position")
|
|
self.assertEqual(action["capability"], "partially-supported")
|
|
|
|
def test_scratchpad_hidden_intention(self):
|
|
scratch = {"member": True, "visibility": "hidden", "state": "fresh"}
|
|
saved = window("w1", "kitty", desktop_entry="kitty.desktop", workspace=None, output=None, scratchpad=scratch)
|
|
live = window("w1", "kitty", desktop_entry="kitty.desktop")
|
|
plan = restore.build_restore_plan(session([saved], persistent=True), session([live]))
|
|
self.assertIn("restore-scratchpad-hidden", action_types(plan))
|
|
|
|
def test_focus_is_last_after_mutating_intentions(self):
|
|
saved = window("w1", "firefox", desktop_entry="firefox.desktop", workspace="1", focused=True)
|
|
live = window("w1", "firefox", desktop_entry="firefox.desktop", workspace="2", focused=True)
|
|
plan = restore.build_restore_plan(session([saved], persistent=True, focused="w1"), session([live], focused="w1"))
|
|
self.assertEqual(plan["actions"][-1]["action"], "restore-focus")
|
|
self.assertTrue(plan["actions"][-1]["depends_on"])
|
|
|
|
def test_infrastructure_is_never_launched(self):
|
|
saved = window("w1", "quickshell", desktop_entry="org.quickshell.desktop", category="session-infrastructure")
|
|
plan = restore.build_restore_plan(session([saved], persistent=True), session([]))
|
|
self.assertEqual(action_types(plan), ["manual-required"])
|
|
self.assertNotIn("launch-application", action_types(plan))
|
|
|
|
def test_runtime_ids_are_observations_not_matching_identity(self):
|
|
saved = window("w1", "firefox", desktop_entry="firefox.desktop", con_id=7, pid=10)
|
|
live = window("w9", "firefox", desktop_entry="firefox.desktop", con_id=900, pid=9999)
|
|
plan = restore.build_restore_plan(session([saved], persistent=True), session([live]))
|
|
self.assertEqual(plan["matches"][0]["source_window"], "w1")
|
|
self.assertEqual(plan["policy"]["runtime_identity_fields"], [])
|
|
self.assertEqual(plan["live"]["windows"][0]["runtime"], {"con_id": 900, "pid": 9999})
|
|
|
|
def test_same_inputs_produce_byte_equivalent_plan(self):
|
|
saved = [
|
|
window("w1", "firefox", desktop_entry="firefox.desktop", title="A"),
|
|
window("w2", "firefox", desktop_entry="firefox.desktop", title="B"),
|
|
]
|
|
live = [window("w7", "firefox", desktop_entry="firefox.desktop", title="B")]
|
|
source = session(saved, persistent=True)
|
|
current = session(live)
|
|
first = restore.build_restore_plan(source, current)
|
|
second = restore.build_restore_plan(copy.deepcopy(source), copy.deepcopy(current))
|
|
self.assertEqual(json.dumps(first, sort_keys=True), json.dumps(second, sort_keys=True))
|
|
|
|
def test_cli_plan_uses_only_loader_collector_and_pure_planner(self):
|
|
saved = window("w1", "firefox", desktop_entry="firefox.desktop")
|
|
source = session([saved], persistent=True, name="cli")
|
|
current = session([copy.deepcopy(saved)])
|
|
before = copy.deepcopy(source)
|
|
output = io.StringIO()
|
|
with mock.patch.object(snapshot_backend, "show_session", return_value=source) as loader, mock.patch.object(
|
|
snapshot_backend, "collect", return_value=current
|
|
) as collector, mock.patch.object(snapshot_backend, "validate_snapshot") as validator, mock.patch("sys.stdout", output):
|
|
self.assertEqual(snapshot_backend.main(("plan", "cli")), 0)
|
|
result = json.loads(output.getvalue())
|
|
self.assertEqual(result["schema"], "labfy.sway.restore-plan")
|
|
loader.assert_called_once_with("cli")
|
|
collector.assert_called_once()
|
|
validator.assert_called_once_with(current)
|
|
self.assertEqual(source, before)
|
|
self.assertEqual(result["diagnostics"]["applications_launched"], 0)
|
|
|
|
def test_planner_module_has_no_process_or_filesystem_execution_api(self):
|
|
source_text = RESTORE_SOURCE.read_text(encoding="utf-8")
|
|
self.assertNotIn("import subprocess", source_text)
|
|
self.assertNotIn("subprocess.", source_text)
|
|
self.assertNotIn("swaymsg", source_text)
|
|
self.assertNotIn("os.system", source_text)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|