"""M18 — tests for ScenePatch → RendererCommand adapter (diff_scene).""" from __future__ import annotations import copy import pytest from loosecanvas.contracts import Callout, SceneState from loosecanvas.renderer_adapter import diff_scene def _scene(**kwargs: object) -> SceneState: return SceneState(**kwargs) # type: ignore[arg-type] def _ops(cmds: list[object]) -> list[str]: return [c.op for c in cmds] # type: ignore[attr-defined] # ── Reveal & position ───────────────────────────────────────────────────────── def test_newly_visible_node_emits_add_element_with_position() -> None: old = _scene(scene_id="s") new = _scene( scene_id="s", visible_node_ids=["a"], node_positions={"a": (10.0, 20.0)}, ) cmds = diff_scene(old, new) adds = [c for c in cmds if c.op == "add_element"] assert len(adds) == 1 pos = adds[0].data["position"] assert pos == {"x": 10.0, "y": 20.0} assert adds[0].data["id"] == "a" def test_newly_visible_node_without_position_raises() -> None: old = _scene(scene_id="s") new = _scene(scene_id="s", visible_node_ids=["a"]) # no node_positions["a"] with pytest.raises(ValueError): diff_scene(old, new) def test_add_element_precedes_add_class_for_same_id() -> None: # Node "a" is both newly visible AND newly fogged in the same diff. old = _scene(scene_id="s") new = _scene( scene_id="s", visible_node_ids=["a"], fogged_node_ids=["a"], node_positions={"a": (0.0, 0.0)}, ) cmds = diff_scene(old, new) add_idx = next( i for i, c in enumerate(cmds) if c.op == "add_element" and c.data["id"] == "a" ) cls_idx = next( i for i, c in enumerate(cmds) if c.op == "add_class" and c.selector == "#a" and c.data.get("class") == "fogged" ) assert add_idx < cls_idx def test_visible_edge_add_element_after_nodes() -> None: old = _scene(scene_id="s") new = _scene( scene_id="s", visible_node_ids=["a", "b"], visible_edge_ids=["e1"], node_positions={"a": (0.0, 0.0), "b": (1.0, 1.0)}, ) cmds = diff_scene(old, new) node_idxs = [ i for i, c in enumerate(cmds) if c.op == "add_element" and c.data["id"] in {"a", "b"} ] edge_idx = next( i for i, c in enumerate(cmds) if c.op == "add_element" and c.data["id"] == "e1" ) assert all(ni < edge_idx for ni in node_idxs) # ── Edge kind → arrow-style data (edge-arrow-style-mapping) ──────────────────── def test_visible_edge_includes_kind_in_data() -> None: old = _scene(scene_id="s") new = _scene( scene_id="s", visible_node_ids=["a", "b"], visible_edge_ids=["e1"], node_positions={"a": (0.0, 0.0), "b": (1.0, 1.0)}, ) cmds = diff_scene( old, new, edge_map={"e1": ("a", "b")}, edge_kind_map={"e1": "causes"} ) edge_add = next(c for c in cmds if c.op == "add_element" and c.data["id"] == "e1") assert edge_add.data["kind"] == "causes" def test_visible_edge_without_kind_map_omits_kind() -> None: old = _scene(scene_id="s") new = _scene( scene_id="s", visible_node_ids=["a", "b"], visible_edge_ids=["e1"], node_positions={"a": (0.0, 0.0), "b": (1.0, 1.0)}, ) cmds = diff_scene(old, new, edge_map={"e1": ("a", "b")}) edge_add = next(c for c in cmds if c.op == "add_element" and c.data["id"] == "e1") assert "kind" not in edge_add.data def test_empty_kind_is_not_emitted() -> None: old = _scene(scene_id="s") new = _scene( scene_id="s", visible_node_ids=["a", "b"], visible_edge_ids=["e1"], node_positions={"a": (0.0, 0.0), "b": (1.0, 1.0)}, ) cmds = diff_scene(old, new, edge_map={"e1": ("a", "b")}, edge_kind_map={"e1": ""}) edge_add = next(c for c in cmds if c.op == "add_element" and c.data["id"] == "e1") assert "kind" not in edge_add.data # ── Cluster membership → cluster-N class deltas (cluster-louvain-colors) ──────── def test_newly_visible_node_gets_cluster_class() -> None: old = _scene(scene_id="s") new = _scene( scene_id="s", visible_node_ids=["a"], node_positions={"a": (0.0, 0.0)}, node_cluster_map={"a": 2}, ) add = next( c for c in diff_scene(old, new) if c.op == "add_element" and c.data["id"] == "a" ) assert "cluster-2" in add.data["classes"] def test_node_without_cluster_gets_no_cluster_class() -> None: old = _scene(scene_id="s") new = _scene(scene_id="s", visible_node_ids=["a"], node_positions={"a": (0.0, 0.0)}) add = next( c for c in diff_scene(old, new) if c.op == "add_element" and c.data["id"] == "a" ) assert not any(str(cls).startswith("cluster-") for cls in add.data["classes"]) def test_cluster_change_emits_remove_then_add_pair() -> None: old = _scene( scene_id="s", visible_node_ids=["a"], node_positions={"a": (0.0, 0.0)}, node_cluster_map={"a": 1}, ) new = _scene( scene_id="s", visible_node_ids=["a"], node_positions={"a": (0.0, 0.0)}, node_cluster_map={"a": 3}, ) cmds = diff_scene(old, new) removes = [ c for c in cmds if c.op == "remove_class" and c.data.get("class") == "cluster-1" ] adds = [ c for c in cmds if c.op == "add_class" and c.data.get("class") == "cluster-3" ] assert len(removes) == 1 assert len(adds) == 1 def test_unchanged_cluster_emits_no_class_ops() -> None: old = _scene( scene_id="s", visible_node_ids=["a"], node_positions={"a": (0.0, 0.0)}, node_cluster_map={"a": 1}, ) new = old.model_copy(deep=True) cmds = diff_scene(old, new) assert not [c for c in cmds if c.op in {"add_class", "remove_class"}] # ── Removal: both branches ───────────────────────────────────────────────────── def test_removed_node_becomes_fogged_branch() -> None: old = _scene(scene_id="s", visible_node_ids=["a"], node_positions={"a": (0.0, 0.0)}) new = _scene(scene_id="s", fogged_node_ids=["a"]) cmds = diff_scene(old, new) fog = [c for c in cmds if c.op == "add_class" and c.selector == "#a"] assert len(fog) == 1 assert fog[0].data["class"] == "fogged" assert "remove_element" not in _ops(cmds) def test_removed_node_genuine_removal_branch() -> None: old = _scene(scene_id="s", visible_node_ids=["a"], node_positions={"a": (0.0, 0.0)}) new = _scene(scene_id="s") # gone entirely (not visible, not fogged) cmds = diff_scene(old, new) removes = [c for c in cmds if c.op == "remove_element" and c.selector == "#a"] assert len(removes) == 1 def test_removed_edge_before_endpoint_removal() -> None: old = _scene( scene_id="s", visible_node_ids=["a", "b"], visible_edge_ids=["e1"], node_positions={"a": (0.0, 0.0), "b": (1.0, 1.0)}, ) new = _scene(scene_id="s") cmds = diff_scene(old, new) edge_idx = next( i for i, c in enumerate(cmds) if c.op == "remove_element" and c.selector == "#e1" ) node_idxs = [ i for i, c in enumerate(cmds) if c.op == "remove_element" and c.selector in {"#a", "#b"} ] assert all(edge_idx < ni for ni in node_idxs) # ── Fog / un-fog ────────────────────────────────────────────────────────────── def test_unfogged_node_remove_class_with_animation() -> None: old = _scene( scene_id="s", visible_node_ids=["a"], fogged_node_ids=["a"], node_positions={"a": (0.0, 0.0)}, ) new = _scene(scene_id="s", visible_node_ids=["a"], node_positions={"a": (0.0, 0.0)}) cmds = diff_scene(old, new) unfog = [c for c in cmds if c.op == "remove_class" and c.selector == "#a"] assert len(unfog) == 1 assert unfog[0].animation == {"duration": 300} # ── Highlight & selection ───────────────────────────────────────────────────── def test_highlight_change_clears_before_set() -> None: positions = {"a": (0.0, 0.0), "b": (1.0, 1.0)} old = _scene( scene_id="s", visible_node_ids=["a", "b"], highlighted_ids=["a"], node_positions=positions, ) new = _scene( scene_id="s", visible_node_ids=["a", "b"], highlighted_ids=["b"], node_positions=positions, ) cmds = diff_scene(old, new) remove_idx = next( i for i, c in enumerate(cmds) if c.op == "remove_class" and c.data.get("class") == "highlighted" ) add_idx = next( i for i, c in enumerate(cmds) if c.op == "add_class" and c.data.get("class") == "highlighted" ) assert remove_idx < add_idx def test_selection_change_emits_remove_then_add_and_camera_last() -> None: positions = {"a": (0.0, 0.0), "b": (1.0, 1.0)} old = _scene( scene_id="s", visible_node_ids=["a", "b"], selected_id="a", node_positions=positions, ) new = _scene( scene_id="s", visible_node_ids=["a", "b"], selected_id="b", node_positions=positions, ) cmds = diff_scene(old, new) assert any( c.op == "remove_class" and c.selector == "#a" and c.data.get("class") == "selected" for c in cmds ) assert any( c.op == "add_class" and c.selector == "#b" and c.data.get("class") == "selected" for c in cmds ) # animate_camera (when present) is LAST cam = [c for c in cmds if c.op == "animate_camera"] assert len(cam) == 1 assert cmds[-1].op == "animate_camera" assert cam[0].data == {"fit_selector": "#b", "padding": 60} # ── Callouts ────────────────────────────────────────────────────────────────── def test_callouts_added_and_removed() -> None: old = _scene(scene_id="s", callouts=[Callout(id="c1", target_id="a", text="old")]) new = _scene(scene_id="s", callouts=[Callout(id="c2", target_id="b", text="new")]) cmds = diff_scene(old, new) assert any(c.op == "remove_callout" and c.data == {"id": "c1"} for c in cmds) show = [c for c in cmds if c.op == "show_callout"] assert len(show) == 1 assert show[0].data == {"id": "c2", "text": "new"} # ── Purity ──────────────────────────────────────────────────────────────────── def test_diff_scene_is_pure() -> None: old = _scene( scene_id="s", visible_node_ids=["a"], fogged_node_ids=["x"], highlighted_ids=["a"], selected_id="a", node_positions={"a": (0.0, 0.0)}, ) new = _scene( scene_id="s", visible_node_ids=["a", "b"], fogged_node_ids=["c"], highlighted_ids=["b"], selected_id="b", node_positions={"a": (0.0, 0.0), "b": (1.0, 1.0)}, ) old_copy = copy.deepcopy(old) new_copy = copy.deepcopy(new) diff_scene(old, new) assert old == old_copy assert new == new_copy # ── Op limit ────────────────────────────────────────────────────────────────── def test_total_ops_within_limit() -> None: positions = {f"n{i}": (float(i), float(i)) for i in range(10)} old = _scene(scene_id="s") new = _scene( scene_id="s", visible_node_ids=list(positions), node_positions=positions ) cmds = diff_scene(old, new) assert len(cmds) <= 50 def test_exceeding_op_limit_raises() -> None: positions = {f"n{i}": (float(i), float(i)) for i in range(60)} old = _scene(scene_id="s") new = _scene( scene_id="s", visible_node_ids=list(positions), node_positions=positions ) with pytest.raises(ValueError): diff_scene(old, new)