mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-10 04:00:35 +00:00
feat(explorer): add deterministic rendering E2E example and test (#1037) Adds a deterministic Explorer graph baseline and coverage for the full build -> persist -> API -> frontend hydration -> canvas rendering path, so a regression anywhere along that chain shows up in CI instead manually. examples/explorer_deterministic_rendering_example.py builds the canonical 4-node, 3-edge graph (Alice -WORKS_AT-> Acme, Bob -KNOWS-> Alice, Acme -LOCATED_IN-> New York) with ContextGraph.add_node()/ add_edge(), persists it with save_to_file() and reloads it with GraphSession.from_file(), printing the setup prerequisites and the expected node/edge/label checklist for anyone running it by hand. tests/explorer/test_explorer_deterministic_rendering_e2e.py covers graph construction, the serialize/deserialize round trip, GraphSession loading, and the Explorer API's /api/graph/* responses against the exact expected nodes, edges, and labels, plus all three auth modes (unconfigured, API-key required, anonymous opt-in). fix(explorer): address Qodo review findings for deterministic rendering e2e (#1037) - configure SEMANTICA_ALLOW_ANONYMOUS=true and document SEMANTICA_API_KEY as the alternative in the reproduction instructions, so the documented commands don't 503 on a clean checkout - add clean-checkout prerequisites and a visual verification checklist to the example - add edge-label (WORKS_AT, KNOWS, LOCATED_IN), zoom-tier, and hover-interaction coverage to the frontend test - add an explicit auth-enforcement integration test for the deterministic graph endpoints fix(explorer): connect deterministic rendering E2E path The frontend test built its own node/edge objects directly with batchMergeNodes()/batchMergeEdges(), bypassing the real loading path entirely -- it never went through useLoadGraph, never mounted the canvas, and its fixture didn't even carry the same fields the backend actually returns (e.g. no color values), so a break in API hydration, the edge.type -> edgeType mapping, or canvas label rendering could still pass. Adds deterministicExplorerRendering.e2e.ts, which mounts the real Explorer app in Chromium, serves API-shaped /api/graph/nodes and /api/graph/edges responses through route interception, drives the app through its actual useLoadGraph hydration path into a real Sigma canvas, and asserts on captured canvas fillText() calls that WORKS_AT, KNOWS, and LOCATED_IN are genuinely drawn, both after load and after Zoom In. fix(explorer): preserve upstream markdown dependencies ci(explorer): isolate deterministic backend test dependencies Wires the new Python test into ci.yml as its own focused step (it previously only ran manually), installs Playwright's Chromium browser before the frontend suite, and keeps the deterministic backend test's dependency install separate from the rest of the pipeline so it doesn't pull in unrelated optional extras during collection. fix(explorer): remove redundant edge label hydration An earlier commit in this PR added an explicit `label` field to hydrated edge attributes on the theory that it was needed for edge labels to render. Review traced through GraphCanvas.tsx's label resolution (`attrs.edgeType || data.label || ""`, from the earlier #1009 fix already on main) and found that `edgeType` is set unconditionally on every edge during hydration, so it always wins the `||` before `data.label` is ever consulted -- the added field and its plumbing in useLoadGraph.ts and graphStore.ts never did anything. Removed both; reran the real Chromium E2E test against the reverted code and confirmed all three labels still render identically, closing out the question of whether anything else was actually broken.
220 lines
8.3 KiB
Python
220 lines
8.3 KiB
Python
"""
|
|
End-to-end integration test for deterministic Explorer rendering (Issue #1037).
|
|
|
|
Validates:
|
|
1. Building the canonical 4-node, 3-edge graph using ContextGraph.
|
|
2. Serialization via save_to_file().
|
|
3. Reloading via load_from_file() and GraphSession.from_file() without mutation.
|
|
4. Explorer HTTP API serving exact nodes, edges, edge types, and connectivity.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from semantica.context.context_graph import ContextGraph
|
|
from semantica.explorer.app import create_app
|
|
from semantica.explorer.session import GraphSession
|
|
|
|
try:
|
|
from starlette.testclient import TestClient
|
|
except ImportError:
|
|
pytest.skip(
|
|
"starlette TestClient required. Install semantica[explorer].",
|
|
allow_module_level=True,
|
|
)
|
|
|
|
|
|
def _build_deterministic_graph() -> ContextGraph:
|
|
"""Build the exact graph requested in Semantica #1037."""
|
|
graph = ContextGraph(advanced_analytics=False)
|
|
|
|
graph.add_node("alice", "Person", content="Alice")
|
|
graph.add_node("bob", "Person", content="Bob")
|
|
graph.add_node("acme", "Organization", content="Acme")
|
|
graph.add_node("new_york", "Location", content="New York")
|
|
|
|
graph.add_edge("alice", "acme", edge_type="WORKS_AT")
|
|
graph.add_edge("bob", "alice", edge_type="KNOWS")
|
|
graph.add_edge("acme", "new_york", edge_type="LOCATED_IN")
|
|
|
|
return graph
|
|
|
|
|
|
class TestExplorerDeterministicRenderingE2E:
|
|
"""E2E suite for deterministic graph rendering and session loading."""
|
|
|
|
def test_build_and_in_memory_structure(self):
|
|
graph = _build_deterministic_graph()
|
|
|
|
assert len(graph.nodes) == 4
|
|
assert len(graph.edges) == 3
|
|
|
|
node_map = {
|
|
nid: (node.node_type, node.content) for nid, node in graph.nodes.items()
|
|
}
|
|
assert node_map["alice"] == ("Person", "Alice")
|
|
assert node_map["bob"] == ("Person", "Bob")
|
|
assert node_map["acme"] == ("Organization", "Acme")
|
|
assert node_map["new_york"] == ("Location", "New York")
|
|
|
|
edge_tuples = {(e.source_id, e.target_id, e.edge_type) for e in graph.edges}
|
|
assert edge_tuples == {
|
|
("alice", "acme", "WORKS_AT"),
|
|
("bob", "alice", "KNOWS"),
|
|
("acme", "new_york", "LOCATED_IN"),
|
|
}
|
|
|
|
def test_serialization_and_deserialization(self, tmp_path: Path):
|
|
graph = _build_deterministic_graph()
|
|
output_file = tmp_path / "explorer_deterministic_graph.json"
|
|
|
|
graph.save_to_file(str(output_file))
|
|
assert output_file.exists()
|
|
|
|
# Validate JSON structure
|
|
with open(output_file, "r", encoding="utf-8") as f:
|
|
raw_data = json.load(f)
|
|
|
|
assert "nodes" in raw_data
|
|
assert "edges" in raw_data
|
|
assert len(raw_data["nodes"]) == 4
|
|
assert len(raw_data["edges"]) == 3
|
|
|
|
# Reload with ContextGraph.load_from_file
|
|
reloaded_graph = ContextGraph(advanced_analytics=False)
|
|
reloaded_graph.load_from_file(str(output_file))
|
|
|
|
assert len(reloaded_graph.nodes) == 4
|
|
assert len(reloaded_graph.edges) == 3
|
|
reloaded_nodes = {
|
|
nid: (node.node_type, node.content)
|
|
for nid, node in reloaded_graph.nodes.items()
|
|
}
|
|
assert reloaded_nodes["alice"] == ("Person", "Alice")
|
|
assert reloaded_nodes["bob"] == ("Person", "Bob")
|
|
assert reloaded_nodes["acme"] == ("Organization", "Acme")
|
|
assert reloaded_nodes["new_york"] == ("Location", "New York")
|
|
|
|
reloaded_edges = {
|
|
(e.source_id, e.target_id, e.edge_type) for e in reloaded_graph.edges
|
|
}
|
|
assert reloaded_edges == {
|
|
("alice", "acme", "WORKS_AT"),
|
|
("bob", "alice", "KNOWS"),
|
|
("acme", "new_york", "LOCATED_IN"),
|
|
}
|
|
|
|
def test_session_loading_and_stats(self, tmp_path: Path):
|
|
graph = _build_deterministic_graph()
|
|
output_file = tmp_path / "explorer_deterministic_graph.json"
|
|
graph.save_to_file(str(output_file))
|
|
|
|
session = GraphSession.from_file(str(output_file))
|
|
stats = session.get_stats()
|
|
|
|
assert stats["node_count"] == 4
|
|
assert stats["edge_count"] == 3
|
|
|
|
def test_explorer_api_endpoints_with_deterministic_graph(self, tmp_path: Path):
|
|
graph = _build_deterministic_graph()
|
|
output_file = tmp_path / "explorer_deterministic_graph.json"
|
|
graph.save_to_file(str(output_file))
|
|
|
|
session = GraphSession.from_file(str(output_file))
|
|
app = create_app(session=session)
|
|
|
|
with TestClient(app) as client:
|
|
# 1. Health & Info
|
|
health = client.get("/api/health")
|
|
assert health.status_code == 200
|
|
assert health.json() == {"status": "ok"}
|
|
|
|
info = client.get("/api/info")
|
|
assert info.status_code == 200
|
|
assert info.json()["status"] == "active"
|
|
|
|
# 2. Stats
|
|
stats = client.get("/api/graph/stats")
|
|
assert stats.status_code == 200
|
|
stats_data = stats.json()
|
|
assert stats_data["node_count"] == 4
|
|
assert stats_data["edge_count"] == 3
|
|
|
|
# 3. Nodes endpoint
|
|
nodes_res = client.get("/api/graph/nodes")
|
|
assert nodes_res.status_code == 200
|
|
nodes_data = nodes_res.json()
|
|
assert nodes_data["total"] == 4
|
|
assert len(nodes_data["nodes"]) == 4
|
|
|
|
returned_nodes = {
|
|
n["id"]: (n["type"], n["content"]) for n in nodes_data["nodes"]
|
|
}
|
|
assert returned_nodes["alice"] == ("Person", "Alice")
|
|
assert returned_nodes["bob"] == ("Person", "Bob")
|
|
assert returned_nodes["acme"] == ("Organization", "Acme")
|
|
assert returned_nodes["new_york"] == ("Location", "New York")
|
|
|
|
# 4. Individual node lookups
|
|
for node_id in ["alice", "bob", "acme", "new_york"]:
|
|
node_res = client.get(f"/api/graph/node/{node_id}")
|
|
assert node_res.status_code == 200
|
|
assert node_res.json()["id"] == node_id
|
|
|
|
# 5. Edges endpoint
|
|
edges_res = client.get("/api/graph/edges")
|
|
assert edges_res.status_code == 200
|
|
edges_data = edges_res.json()
|
|
assert edges_data["total"] == 3
|
|
assert len(edges_data["edges"]) == 3
|
|
|
|
returned_edges = {
|
|
(e["source"], e["target"], e["type"]) for e in edges_data["edges"]
|
|
}
|
|
assert returned_edges == {
|
|
("alice", "acme", "WORKS_AT"),
|
|
("bob", "alice", "KNOWS"),
|
|
("acme", "new_york", "LOCATED_IN"),
|
|
}
|
|
|
|
def test_deterministic_graph_auth_enforcement(self, tmp_path: Path, monkeypatch):
|
|
graph = _build_deterministic_graph()
|
|
output_file = tmp_path / "explorer_deterministic_graph.json"
|
|
graph.save_to_file(str(output_file))
|
|
|
|
session = GraphSession.from_file(str(output_file))
|
|
app = create_app(session=session)
|
|
|
|
with TestClient(app) as client:
|
|
# 1. Unconfigured auth (no SEMANTICA_ALLOW_ANONYMOUS, no SEMANTICA_API_KEY) -> 503
|
|
monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False)
|
|
monkeypatch.delenv("SEMANTICA_API_KEY", raising=False)
|
|
unconfigured_res = client.get("/api/graph/stats")
|
|
assert unconfigured_res.status_code == 503
|
|
|
|
# 2. Configured SEMANTICA_API_KEY without header -> 401
|
|
test_key = "secret-test-key-1037"
|
|
monkeypatch.setenv("SEMANTICA_API_KEY", test_key)
|
|
unauthorized_res = client.get("/api/graph/nodes")
|
|
assert unauthorized_res.status_code == 401
|
|
|
|
# 3. Configured SEMANTICA_API_KEY with valid X-API-Key header -> 200
|
|
authorized_res = client.get(
|
|
"/api/graph/nodes",
|
|
headers={"X-API-Key": test_key},
|
|
)
|
|
assert authorized_res.status_code == 200
|
|
assert authorized_res.json()["total"] == 4
|
|
|
|
# 4. Explicit local development opt-in: SEMANTICA_ALLOW_ANONYMOUS=true -> 200 without header
|
|
monkeypatch.setenv("SEMANTICA_ALLOW_ANONYMOUS", "true")
|
|
monkeypatch.delenv("SEMANTICA_API_KEY", raising=False)
|
|
anon_res = client.get("/api/graph/edges")
|
|
assert anon_res.status_code == 200
|
|
assert anon_res.json()["total"] == 3
|
|
|