loosecanvas / tests /test_fixture_adapter.py
Joshua Sundance Bailey
feat: open-world trust-loop UX (chat-first review, edge review, layout reshape)
a973dff
Raw
History Blame Contribute Delete
6.08 kB
"""M04a — Graph JSON / Fixture Adapter. Red/green tests from the M04a success criteria.
Success criteria under test:
- ``load_fixture(fixtures/small_graph.json)`` returns ``(LooseGraph, SceneState)`` with
8 nodes, 12 edges, 3 claims and the explicit scene preserved verbatim (visible/fogged
ids and ``node_positions``) — with NO M12 / NetworkX auto-curation call.
- Round-trips through the ``LooseGraph`` / ``SceneState`` schemas without errors.
- ``load_fixture_from_dict`` works on an inline dict.
- A path outside ``ALLOWED_BASE_DIR`` is rejected (path-traversal guard).
"""
from __future__ import annotations
import logging
from pathlib import Path
import pytest
from loosecanvas.contracts import LooseGraph, SceneState
from loosecanvas.extractors.fixture_adapter import load_fixture, load_fixture_from_dict
# Repo root is two parents up from this test file (tests/ -> repo root).
_REPO_ROOT = Path(__file__).resolve().parents[1]
_SMALL_GRAPH = _REPO_ROOT / "fixtures" / "small_graph.json"
_ANNOTATED_GRAPH = _REPO_ROOT / "fixtures" / "annotated_graph.json"
def test_load_small_graph_counts_and_types() -> None:
graph, scene = load_fixture(_SMALL_GRAPH)
assert isinstance(graph, LooseGraph)
assert isinstance(scene, SceneState)
assert len(graph.nodes) == 8
assert len(graph.edges) == 12
assert len(graph.claims) == 3
def test_small_graph_edges_use_type_alias() -> None:
"""The fixture JSON uses ``"type"`` for edges; it must map onto ``Edge.kind``."""
graph, _ = load_fixture(_SMALL_GRAPH)
kinds = {edge.kind for edge in graph.edges}
assert kinds <= {"explains", "prerequisite_of", "contradicts", "related_to"}
assert all(edge.kind for edge in graph.edges)
def test_explicit_scene_preserved_verbatim() -> None:
"""The canonical fixture ships an explicit scene + positions — read verbatim, no M12."""
_, scene = load_fixture(_SMALL_GRAPH)
assert scene.visible_node_ids, "expected an explicit visible set"
assert scene.fogged_node_ids, "expected an explicit fogged set"
# Every visible node has an explicit position (Magic Slice 0 needs no curator).
for node_id in scene.visible_node_ids:
assert node_id in scene.node_positions
pos = scene.node_positions[node_id]
assert isinstance(pos, tuple)
assert len(pos) == 2
def test_round_trips_through_schemas() -> None:
graph, scene = load_fixture(_SMALL_GRAPH)
reloaded_graph = LooseGraph.model_validate(graph.model_dump(by_alias=True))
reloaded_scene = SceneState.model_validate(scene.model_dump())
assert reloaded_graph == graph
assert reloaded_scene == scene
def test_load_fixture_from_dict_inline() -> None:
data = {
"graph": {
"nodes": [{"id": "n1", "kind": "concept", "label": "Concept One"}],
"edges": [
{"id": "e1", "source": "n1", "target": "n1", "type": "related_to"}
],
"claims": [],
"metadata": {"schema_version": "0.1.0", "node_count": 1, "edge_count": 1},
},
"scene": {
"scene_id": "s0",
"visible_node_ids": ["n1"],
"visible_edge_ids": ["e1"],
"fogged_node_ids": [],
"node_positions": {"n1": [10.0, 20.0]},
},
}
graph, scene = load_fixture_from_dict(data)
assert graph.edges[0].kind == "related_to"
assert scene.node_positions["n1"] == (10.0, 20.0)
def test_path_traversal_rejected(
caplog: pytest.LogCaptureFixture,
) -> None:
from loosecanvas.config import ALLOWED_BASE_DIR
# A path that is absolute and guaranteed OUTSIDE ALLOWED_BASE_DIR on any OS: the
# filesystem anchor ("/" on POSIX, "C:\\" on Windows) is never inside the (deeper)
# allowed base. A hardcoded Windows path was relative on the Linux CI runner, so it
# resolved *under* the base and the guard let it through (then FileNotFoundError).
outside = Path(ALLOWED_BASE_DIR.anchor) / "loosecanvas_traversal_probe.json"
resolved = outside.resolve()
with (
caplog.at_level(
logging.WARNING, logger="loosecanvas.extractors.fixture_adapter"
),
pytest.raises(ValueError, match="path not allowed") as excinfo,
):
load_fixture(outside)
# The client-visible message must be generic — no resolved path leaked.
assert "ALLOWED_BASE_DIR" not in str(excinfo.value)
assert str(resolved) not in str(excinfo.value)
# The resolved path must appear in the server-side warning log, not in the exception.
assert any(str(resolved) in rec.getMessage() for rec in caplog.records)
def test_annotated_graph_loads() -> None:
graph, scene = load_fixture(_ANNOTATED_GRAPH)
assert isinstance(graph, LooseGraph)
assert isinstance(scene, SceneState)
# The annotated variant adds notes / hypotheses / open questions on top of the base.
assert len(graph.claims) > 3
def test_load_fixture_from_dict_missing_graph_raises_value_error() -> None:
"""A partial/corrupt fixture (no ``graph`` key) must raise ``ValueError`` — NOT a
bare ``KeyError`` that bubbles up as an opaque 500 on /api/load_graph."""
data = {
"scene": {
"scene_id": "s0",
"visible_node_ids": [],
"visible_edge_ids": [],
"fogged_node_ids": [],
"node_positions": {},
},
}
with pytest.raises(ValueError, match="fixture missing graph/scene"):
load_fixture_from_dict(data)
def test_load_fixture_from_dict_missing_scene_raises_value_error() -> None:
"""A partial fixture (no ``scene`` key) must raise ``ValueError`` (mapped to 400),
not ``KeyError`` (opaque 500)."""
data = {
"graph": {
"nodes": [{"id": "n1", "kind": "concept", "label": "Concept One"}],
"edges": [],
"claims": [],
"metadata": {"schema_version": "0.1.0", "node_count": 1, "edge_count": 0},
},
}
with pytest.raises(ValueError, match="fixture missing graph/scene"):
load_fixture_from_dict(data)