"""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 @pytest.mark.parametrize("action_type", list(SceneActionType)) 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