loosecanvas / tests /test_renderer_adapter.py
Joshua Sundance Bailey
loosecanvas: local AI thought-mapping canvas with a trust-tagged knowledge graph
6d1438c
Raw
History Blame Contribute Delete
12.7 kB
"""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)