Joshua Sundance Bailey
feat: open-world trust-loop UX (chat-first review, edge review, layout reshape)
a973dff | """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) | |