"""M15 — tests for the pure turn reducer (reduce_turn).""" from __future__ import annotations import copy from loosecanvas.contracts import ( Edge, GraphPatch, MapEvent, Node, SceneAction, SceneActionType, ScenePatch, SceneState, ) from loosecanvas.graph_repository import LooseGraphRepository from loosecanvas.reducer import reduce_turn def _action(action_type: SceneActionType, **kwargs: str) -> SceneAction: return SceneAction(type=action_type, **kwargs) # type: ignore[arg-type] def _empty_graph() -> LooseGraphRepository: return LooseGraphRepository() # ── One assertion per SceneAction type ──────────────────────────────────────── def test_highlight_sets_highlighted_ids() -> None: actions = [_action(SceneActionType.highlight, target_id="n1")] _graph_patch, scene_patch, _events = reduce_turn( actions, _empty_graph(), SceneState() ) assert scene_patch.set_highlighted_ids == ["n1"] def test_center_on_sets_camera_target() -> None: actions = [_action(SceneActionType.center_on, target_id="n2")] _graph_patch, scene_patch, _events = reduce_turn( actions, _empty_graph(), SceneState() ) assert scene_patch.set_camera_target_id == "n2" def test_reveal_adds_visible_and_removes_fogged() -> None: actions = [_action(SceneActionType.reveal, target_id="n3")] scene = SceneState(fogged_node_ids=["n3"]) _graph_patch, scene_patch, _events = reduce_turn(actions, _empty_graph(), scene) assert scene_patch.add_visible_node_ids == ["n3"] assert scene_patch.remove_fogged_node_ids == ["n3"] def test_reveal_makes_connecting_edges_visible() -> None: # A reveal must also surface edges whose BOTH endpoints become visible, # otherwise the revealed node floats disconnected on the canvas. repo = LooseGraphRepository() repo.upsert_nodes( [ Node(id="a", kind="concept", label="a"), Node(id="b", kind="concept", label="b"), Node(id="c", kind="concept", label="c"), ] ) repo.upsert_edges( [ Edge(id="e_ab", source="a", target="b", kind="relates_to"), Edge(id="e_bc", source="b", target="c", kind="relates_to"), # c fogged ] ) scene = SceneState( visible_node_ids=["a"], visible_edge_ids=[], fogged_node_ids=["b", "c"], ) actions = [_action(SceneActionType.reveal, target_id="b")] _graph_patch, scene_patch, _events = reduce_turn(actions, repo, scene) assert scene_patch.add_visible_node_ids == ["b"] # e_ab connects two now-visible nodes (a + b) → visible. assert "e_ab" in scene_patch.add_visible_edge_ids # e_bc has a fogged endpoint (c) → must NOT be revealed. assert "e_bc" not in scene_patch.add_visible_edge_ids def test_fog_all_except_emits_diff_not_set_visible() -> None: actions = [_action(SceneActionType.fog_all_except, target_id="keep")] scene = SceneState(visible_node_ids=["keep", "a", "b"]) _graph_patch, scene_patch, _events = reduce_turn(actions, _empty_graph(), scene) assert scene_patch.remove_visible_node_ids == ["a", "b"] assert scene_patch.add_fogged_node_ids == ["a", "b"] # ScenePatch has deliberately NO set_visible_node_ids field. assert not hasattr(scene_patch, "set_visible_node_ids") def test_show_callout_adds_callout() -> None: actions = [_action(SceneActionType.show_callout, target_id="n6", text="overlay")] _graph_patch, scene_patch, _events = reduce_turn( actions, _empty_graph(), SceneState() ) assert len(scene_patch.add_callouts) == 1 callout = scene_patch.add_callouts[0] assert callout.target_id == "n6" assert callout.text == "overlay" def test_explain_no_change_emits_event_only() -> None: actions = [_action(SceneActionType.explain_no_change, text="nothing to do")] graph_patch, scene_patch, events = reduce_turn( actions, _empty_graph(), SceneState() ) assert graph_patch == GraphPatch() assert scene_patch == ScenePatch() assert any( e.action == "explain_no_change" and e.detail == "nothing to do" for e in events ) no_change_event = next(e for e in events if e.action == "explain_no_change") assert no_change_event.actor == "model" # ── Trust defaults on created claims ────────────────────────────────────────── def test_created_claims_use_default_trust_states() -> None: # create_node mints a model_inferred/unverified/pending claim; create_edge likewise. actions = [ _action(SceneActionType.create_node, target_id="n_new", label="New Concept"), _action( SceneActionType.create_edge, source_id="n_new", target_id="n_new", # self-loop rejected by validator, but reducer is pure label="relates to", ), ] # Use create_node only (create_edge self-loop is a validator concern, not reducer). actions = [ _action(SceneActionType.create_node, target_id="concept_a", label="Concept A"), _action(SceneActionType.create_node, target_id="concept_b", label="Concept B"), ] graph_patch, _scene_patch, _events = reduce_turn( actions, _empty_graph(), SceneState() ) assert graph_patch.upsert_claims for claim in graph_patch.upsert_claims: assert claim.origin == "model_inferred" assert claim.support_state == "unverified" assert claim.review_state == "pending" # ── Purity ──────────────────────────────────────────────────────────────────── def test_reduce_turn_does_not_mutate_inputs() -> None: actions = [ _action(SceneActionType.reveal, target_id="x"), _action(SceneActionType.fog_all_except, target_id="keep"), _action(SceneActionType.create_node, target_id="n_new", label="New"), ] scene = SceneState(visible_node_ids=["keep", "old"], fogged_node_ids=["x"]) graph = _empty_graph() actions_before = copy.deepcopy(actions) scene_before = scene.model_copy(deep=True) nodes_before = copy.deepcopy(graph.nodes) edges_before = copy.deepcopy(graph.edges) claims_before = copy.deepcopy(graph.claims) reduce_turn(actions, graph, scene) assert actions == actions_before assert scene == scene_before assert graph.nodes == nodes_before assert graph.edges == edges_before assert graph.claims == claims_before # ── Idempotency ─────────────────────────────────────────────────────────────── def test_reduce_turn_is_idempotent() -> None: actions = [ _action(SceneActionType.highlight, target_id="n1"), _action(SceneActionType.show_callout, target_id="n1", text="callout text"), _action(SceneActionType.create_node, target_id="concept_new", label="New"), ] scene = SceneState(visible_node_ids=["k"]) graph = _empty_graph() graph_patch1, scene_patch1, _events1 = reduce_turn(actions, graph, scene) graph_patch2, scene_patch2, _events2 = reduce_turn(actions, graph, scene) assert graph_patch1 == graph_patch2 assert scene_patch1 == scene_patch2 # ── Scene-size guardrail ────────────────────────────────────────────────────── def test_reveal_cap_drops_excess_and_emits_event() -> None: scene = SceneState(visible_node_ids=["v0", "v1"]) actions = [_action(SceneActionType.reveal, target_id=f"new{i}") for i in range(5)] _graph_patch, scene_patch, events = reduce_turn( actions, _empty_graph(), scene, visible_cap=4 ) # Cap is 4, 2 already visible → only 2 reveals fit (earliest first). assert scene_patch.add_visible_node_ids == ["new0", "new1"] assert any(e.action == "scene_visible_cap_reached" for e in events) def test_reveal_under_cap_keeps_all() -> None: actions = [_action(SceneActionType.reveal, target_id=f"n{i}") for i in range(3)] _graph_patch, scene_patch, events = reduce_turn( actions, _empty_graph(), SceneState(), visible_cap=15 ) assert scene_patch.add_visible_node_ids == ["n0", "n1", "n2"] assert not any(e.action == "scene_visible_cap_reached" for e in events) def test_cap_event_is_map_event() -> None: actions = [_action(SceneActionType.reveal, target_id=f"n{i}") for i in range(3)] _graph_patch, _scene_patch, events = reduce_turn( actions, _empty_graph(), SceneState(), visible_cap=1 ) cap_events = [e for e in events if e.action == "scene_visible_cap_reached"] assert cap_events assert isinstance(cap_events[0], MapEvent)