Joshua Sundance Bailey
refactor+ui: drop propose_* tool family; trim legend + align Send + autoscroll
35bcf45 | """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) | |