Joshua Sundance Bailey
loosecanvas: local AI thought-mapping canvas with a trust-tagged knowledge graph
6d1438c | """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) | |