"""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)