Joshua Sundance Bailey
refactor+ui: drop propose_* tool family; trim legend + align Send + autoscroll
35bcf45 | """M01 β Pydantic Data Contracts. Red/green tests derived from the M01 success criteria. | |
| Success criteria under test: | |
| - All models serialize/deserialize to JSON with zero data loss (round-trip). | |
| - ``SceneAction`` for all 10 types with correct field defaults. | |
| - ``ScenePatch`` has no ``set_visible_node_ids`` field. | |
| - Trust-model invariant: ``origin`` is immutable after creation. | |
| - ``Edge.kind`` serializes under the JSON alias ``"type"``. | |
| - The model-facing ``ScenePlan`` schema inlines the action-type enum (no ``$ref``) | |
| and forbids additional properties (Q1 schema-hardening). | |
| """ | |
| from __future__ import annotations | |
| import pytest | |
| from loosecanvas.contracts import ( | |
| Callout, | |
| Claim, | |
| Edge, | |
| EdgeSpec, | |
| EvidenceRef, | |
| GraphMetadata, | |
| GraphPatch, | |
| LooseGraph, | |
| MapEvent, | |
| Node, | |
| Position, | |
| RawImport, | |
| RendererCommand, | |
| SceneAction, | |
| SceneActionType, | |
| ScenePatch, | |
| ScenePlan, | |
| SceneState, | |
| SuggestedPathway, | |
| sceneplan_validation_schema, | |
| ) | |
| from pydantic import ValidationError | |
| # ββ Round-trip helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _roundtrip(model: object) -> None: | |
| """Assert ``model`` survives a JSON dump/load with zero data loss.""" | |
| cls = type(model) | |
| dumped = model.model_dump_json() # type: ignore[attr-defined] | |
| restored = cls.model_validate_json(dumped) # type: ignore[attr-defined] | |
| assert restored == model | |
| # ββ Round-trip for every model ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_evidence_ref_roundtrip() -> None: | |
| ref = EvidenceRef( | |
| source_id="src::file1", | |
| node_id="n1", | |
| start_line=1, | |
| end_line=10, | |
| snippet_hash="abc123", | |
| ) | |
| _roundtrip(ref) | |
| def test_node_roundtrip() -> None: | |
| node = Node( | |
| id="n1", | |
| kind="concept", | |
| label="Gradient Descent", | |
| summary="An optimization method.", | |
| properties={"domain": "optimization"}, | |
| metadata={"is_method": "false"}, | |
| evidence_refs=[ | |
| EvidenceRef( | |
| source_id="s1", node_id="n1", start_line=1, end_line=2, snippet_hash="h" | |
| ) | |
| ], | |
| ) | |
| _roundtrip(node) | |
| def test_edge_roundtrip_and_type_alias() -> None: | |
| edge = Edge(id="e1", source="n1", target="n2", kind="related_to", label="relates") | |
| # Serializes with the property-graph alias "type" when by_alias=True. | |
| dumped = edge.model_dump(by_alias=True) | |
| assert dumped["type"] == "related_to" | |
| assert "kind" not in dumped | |
| # Construct from the JSON-native "type" key (fixtures and M10 prompts use it). | |
| from_alias = Edge.model_validate( | |
| { | |
| "id": "e1", | |
| "source": "n1", | |
| "target": "n2", | |
| "type": "related_to", | |
| "label": "relates", | |
| } | |
| ) | |
| assert from_alias.kind == "related_to" | |
| assert from_alias == edge | |
| _roundtrip(edge) | |
| def test_claim_roundtrip() -> None: | |
| claim = Claim( | |
| id="c1", | |
| claim_type="note", | |
| target_id="n1", | |
| source_id="", | |
| text="A note.", | |
| origin="model_inferred", | |
| support_state="unverified", | |
| review_state="pending", | |
| ) | |
| _roundtrip(claim) | |
| def test_raw_import_roundtrip_and_defaults() -> None: | |
| # The M05 -> M06 hand-off shape round-trips and carries sensible defaults. | |
| bare = RawImport() | |
| assert bare.module == "" | |
| assert bare.names == [] | |
| assert bare.level == 0 | |
| assert bare.lineno == 0 | |
| assert bare.current_file == "" | |
| populated = RawImport( | |
| module="loosecanvas.contracts", | |
| names=["Node", "Edge"], | |
| level=0, | |
| lineno=12, | |
| current_file="src/loosecanvas/extractors/ast_extractor.py", | |
| ) | |
| _roundtrip(populated) | |
| relative = RawImport( | |
| module="", names=["x"], level=1, lineno=3, current_file="a/b.py" | |
| ) | |
| assert relative.level == 1 | |
| _roundtrip(relative) | |
| def test_loosegraph_roundtrip_and_top_level_keys() -> None: | |
| graph = LooseGraph( | |
| nodes=[Node(id="n1", kind="concept", label="A")], | |
| edges=[Edge(id="e1", source="n1", target="n2", kind="related_to")], | |
| claims=[ | |
| Claim(id="c1", claim_type="label", target_id="n1", origin="deterministic") | |
| ], | |
| metadata=GraphMetadata(schema_version="0.1.0", node_count=1, edge_count=1), | |
| ) | |
| dumped = graph.model_dump(by_alias=True) | |
| assert set(dumped.keys()) == {"nodes", "edges", "claims", "metadata"} | |
| assert dumped["metadata"]["schema_version"] == "0.1.0" | |
| _roundtrip(graph) | |
| def test_scenestate_roundtrip_with_positions() -> None: | |
| scene = SceneState( | |
| scene_id="scene-1", | |
| visible_node_ids=["n1", "n2"], | |
| visible_edge_ids=["e1"], | |
| fogged_node_ids=["n3"], | |
| highlighted_ids=["n1"], | |
| selected_id="", | |
| callouts=[Callout(id="co1", target_id="n1", text="hi")], | |
| node_positions={"n1": (1.0, 2.0), "n2": (3.5, 4.5)}, | |
| ) | |
| restored = SceneState.model_validate_json(scene.model_dump_json()) | |
| assert restored.node_positions["n1"] == (1.0, 2.0) | |
| assert restored == scene | |
| def test_scenepatch_roundtrip() -> None: | |
| patch = ScenePatch( | |
| add_visible_node_ids=["n1"], | |
| remove_fogged_node_ids=["n1"], | |
| set_highlighted_ids=["n1"], | |
| set_selected_id="n1", | |
| set_camera_target_id="n1", | |
| add_callouts=[Callout(id="co1", target_id="n1", text="x")], | |
| add_elements=[ | |
| EdgeSpec( | |
| id="h1", | |
| source_id="n1", | |
| target_id="n2", | |
| label="hyp", | |
| css_class="hypothesis-edge", | |
| ) | |
| ], | |
| ) | |
| _roundtrip(patch) | |
| def test_graphpatch_roundtrip() -> None: | |
| patch = GraphPatch( | |
| upsert_nodes=[Node(id="n1", kind="concept", label="A")], | |
| upsert_edges=[Edge(id="e1", source="n1", target="n2", kind="uses")], | |
| upsert_claims=[ | |
| Claim(id="c1", claim_type="note", target_id="n1", origin="model_inferred") | |
| ], | |
| remove_node_ids=["nx"], | |
| remove_edge_ids=["ex"], | |
| ) | |
| _roundtrip(patch) | |
| def test_sceneplan_roundtrip() -> None: | |
| plan = ScenePlan( | |
| actions=[ | |
| SceneAction( | |
| type=SceneActionType.highlight, target_id="n1", reasoning="because" | |
| ) | |
| ], | |
| summary="Highlighted node n1.", | |
| ) | |
| _roundtrip(plan) | |
| def test_renderercommand_roundtrip() -> None: | |
| cmd = RendererCommand( | |
| op="add_element", | |
| selector="#n1", | |
| data={"id": "n1", "position": {"x": 1.0, "y": 2.0}}, | |
| animation={"duration": 300, "easing": "ease"}, | |
| ) | |
| _roundtrip(cmd) | |
| def test_suggested_pathway_roundtrip() -> None: | |
| pathway = SuggestedPathway( | |
| pathway_id="pathway::a", | |
| label="Cluster A", | |
| anchor_node_id="n1", | |
| community_node_ids=["n1", "n2"], | |
| summary="A cluster.", | |
| total_pagerank=0.42, | |
| ) | |
| _roundtrip(pathway) | |
| def test_mapevent_roundtrip() -> None: | |
| event = MapEvent(actor="model", action="explain_no_change", detail="informational") | |
| _roundtrip(event) | |
| def test_edgespec_position_roundtrip() -> None: | |
| spec = EdgeSpec( | |
| id="h1", | |
| source_id="n1", | |
| target_id="n2", | |
| label="hyp", | |
| css_class="hypothesis-edge", | |
| position=Position(x=1.0, y=2.0), | |
| ) | |
| _roundtrip(spec) | |
| # ββ SceneAction: all 12 types with correct field defaults βββββββββββββββββββββ | |
| def test_scene_action_type_has_ten_values() -> None: | |
| # Removed propose_edge_label/propose_note/propose_hypothesis/propose_question (16 β 12). | |
| assert len(list(SceneActionType)) == 12 | |
| def test_scene_action_constructs_with_string_defaults( | |
| action_type: SceneActionType, | |
| ) -> None: | |
| action = SceneAction(type=action_type) | |
| # Every non-type field defaults to the empty string (flat model, no Optional). | |
| assert action.target_id == "" | |
| assert action.source_id == "" | |
| assert action.edge_id == "" | |
| assert action.label == "" | |
| assert action.text == "" | |
| assert action.note == "" | |
| assert action.reasoning == "" | |
| _roundtrip(action) | |
| def test_scene_action_type_values() -> None: | |
| assert {t.value for t in SceneActionType} == { | |
| "center_on", | |
| "highlight", | |
| "reveal", | |
| "fog_all_except", | |
| "show_callout", | |
| "explain_no_change", | |
| # T2-01 curation actions: | |
| "remove_node", | |
| "remove_edge", | |
| # T2-03 user-edit actions: | |
| "update_node", | |
| "update_edge", | |
| "create_edge", | |
| # Agent graph-growth action: | |
| "create_node", | |
| } | |
| # ββ ScenePatch has no set_visible_node_ids field ββββββββββββββββββββββββββββββ | |
| def test_scenepatch_has_no_set_visible_node_ids() -> None: | |
| assert "set_visible_node_ids" not in ScenePatch.model_fields | |
| # ββ Trust-model invariant: origin is immutable ββββββββββββββββββββββββββββββββ | |
| def test_claim_origin_is_immutable() -> None: | |
| claim = Claim(id="c1", claim_type="note", target_id="n1", origin="model_inferred") | |
| with pytest.raises(ValidationError): | |
| claim.origin = "user_asserted" # type: ignore[misc] | |
| def test_claim_claim_type_is_immutable() -> None: | |
| claim = Claim(id="c1", claim_type="note", target_id="n1", origin="model_inferred") | |
| with pytest.raises(ValidationError): | |
| claim.claim_type = "label" # type: ignore[misc] | |
| def test_claim_support_and_review_state_are_mutable() -> None: | |
| claim = Claim(id="c1", claim_type="note", target_id="n1", origin="model_inferred") | |
| claim.support_state = "source_supported" | |
| claim.review_state = "accepted" | |
| assert claim.support_state == "source_supported" | |
| assert claim.review_state == "accepted" | |
| # ββ Schema hardening: inline enum + additionalProperties:false ββββββββββββββββ | |
| def test_sceneplan_schema_inlines_action_type_enum() -> None: | |
| schema = sceneplan_validation_schema() | |
| # No $defs indirection survives in the model-facing schema. | |
| assert "$defs" not in schema | |
| action_schema = schema["properties"]["actions"]["items"] | |
| type_schema = action_schema["properties"]["type"] | |
| # The action type is an inline enum, not a $ref into $defs. | |
| assert "$ref" not in type_schema | |
| assert set(type_schema["enum"]) == {t.value for t in SceneActionType} | |
| def test_sceneplan_schema_forbids_additional_properties() -> None: | |
| schema = sceneplan_validation_schema() | |
| assert schema["additionalProperties"] is False | |
| action_schema = schema["properties"]["actions"]["items"] | |
| assert action_schema["additionalProperties"] is False | |