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