Files
semantica/tests/explorer/test_explorer_deterministic_rendering_e2e.py
T
Alex Smolya d3183d0ab3 feat(explorer): add deterministic rendering E2E example and test (#1037) (#1041)
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.
2026-08-29 12:25:58 +05:00

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