loosecanvas / tests /test_reducer.py
Joshua Sundance Bailey
refactor+ui: drop propose_* tool family; trim legend + align Send + autoscroll
35bcf45
Raw
History Blame Contribute Delete
9.07 kB
"""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)