Files
semantica/explorer/tests/graphColorLegend.e2e.ts
T
Wei Tao 008b9e0b64 fix(explorer): align visible legend with semantic node colors (#1483)
Replace the static biomedical category key in Explorer with a live node color legend derived from the active display graph, sharing the canvas's baseColor -> color -> theme fallback resolver.

- Preserve original semantic colors on focused display clones via `semanticBaseColor` so interaction styling (selection, path, neighbor highlights) does not bleed into the legend.
- Suppress the semantic legend while distance visualization modes are active.
- Refactor plugin fallback legend panels to use the shared legend builder with composite (group, color) keys.
- Consolidate duplicate `"test:graph-workspace"` scripts in `explorer/package.json`, recovering 26 previously shadowed tests across markdown and explorer capability suites.
- Add `tests/graphColorLegend.test.ts` to workspace test runs and wire `test:graph-legend-e2e` into `.github/workflows/ci.yml`.
- Closes #1479
2026-09-07 03:56:12 +05:00

113 lines
5.9 KiB
TypeScript

import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { setTimeout as delay } from "node:timers/promises";
import test from "node:test";
import { chromium, type Page } from "playwright";
const BASE_URL = "http://127.0.0.1:4175";
const initialNodes = [
{ id: "alice", type: "Person", content: "Alice", properties: {} },
{ id: "bob", type: "Person", content: "Bob", properties: {} },
{ id: "acme", type: "Organization", content: "Acme", properties: {} },
{ id: "london", type: "Location", content: "London", properties: {} },
{ id: "research", type: "Project", content: "Research", properties: {} },
{ id: "report", type: "Document", content: "Report", properties: {} },
];
const edges = [
["alice", "acme", "WORKS_AT"], ["bob", "acme", "WORKS_AT"],
["acme", "london", "LOCATED_IN"], ["alice", "research", "LEADS"],
["bob", "report", "AUTHORED"], ["report", "research", "DESCRIBES"],
].map(([source, target, type], i) => ({
id: `edge_${i}`, familyId: `edge_${i}`, source, target, type, weight: 1, properties: {},
}));
async function assertLegendMatchesGraph(page: Page, nodeIds?: string[]) {
const result = await page.evaluate(async (includedNodeIds) => {
const storePath = "/src/store/graphStore.ts";
const { graph } = await import(storePath);
const colors: Record<string, string> = {};
graph.forEachNode((id: string, attrs: { semanticGroup: string; baseColor: string }) => {
if (includedNodeIds && !includedNodeIds.includes(id)) return;
const hex = attrs.baseColor.replace("#", "");
colors[attrs.semanticGroup] = `rgb(${[0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(", ")})`;
});
const items = [...document.querySelectorAll(".explore-color-legend-item")].map((item) => ({
group: item.querySelector(".explore-color-legend-name")?.textContent,
color: getComputedStyle(item.querySelector(".explore-color-legend-mark")!).backgroundColor,
}));
return { colors, items };
}, nodeIds);
assert.equal(result.items.length, Object.keys(result.colors).length);
for (const item of result.items) {
assert.equal(item.color, result.colors[item.group!], `Swatch for ${item.group} must match the loaded canvas color`);
}
}
test("visible legend follows loaded data, reloads, focused views, and distance mode", async (t) => {
const server = spawn("npm", ["run", "dev", "--", "--host", "127.0.0.1", "--port", "4175", "--strictPort"], { stdio: "ignore" });
t.after(() => { server.kill(); });
let ready = false;
for (let i = 0; i < 100; i += 1) {
try { if ((await fetch(BASE_URL)).ok) { ready = true; break; } } catch { /* Starting Vite. */ }
await delay(100);
}
assert.ok(ready, "Vite must start");
const browser = await chromium.launch({
headless: true,
executablePath:
process.env.CHROMIUM_PATH || (existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : undefined),
});
t.after(() => browser.close());
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(10_000);
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); });
let nodes = initialNodes;
await page.routeWebSocket("**/ws/graph-updates", () => {});
await page.route("**/api/**", async (route) => {
const path = new URL(route.request().url()).pathname;
let json: unknown = {};
if (path === "/api/info") json = { capabilities: { agent_memory: false } };
if (path === "/api/graph/stats") json = { node_count: nodes.length, edge_count: edges.length };
if (path === "/api/graph/nodes") json = { nodes, total: nodes.length, next_cursor: null };
if (path === "/api/graph/edges") json = { edges, total: edges.length, next_cursor: null };
if (path === "/api/temporal/bounds") json = { min: null, max: null };
if (path === "/api/temporal/snapshot") json = { active_node_ids: nodes.map((n) => n.id), active_node_count: nodes.length };
if (path === "/api/graph/search") json = { results: [{ node: nodes[0], score: 1 }] };
await route.fulfill({ json });
});
await page.goto(BASE_URL);
await page.getByRole("button", { name: "Open Semantica Explorer" }).click();
const legend = page.getByRole("group", { name: "Node colors" });
await legend.waitFor();
await page.locator("canvas").first().waitFor({ state: "visible" });
await assertLegendMatchesGraph(page);
assert.equal(await legend.getByText("Person", { exact: true }).count(), 1);
assert.equal(await legend.getByText("Biomolecule", { exact: true }).count(), 0);
nodes = initialNodes.map((node) => ({ ...node, type: node.type === "Person" ? "Researcher" : node.type }));
await page.getByRole("button", { name: "Reload graph data" }).click();
await legend.getByText("Researcher", { exact: true }).waitFor();
assert.equal(await legend.getByText("Person", { exact: true }).count(), 0);
await assertLegendMatchesGraph(page);
await page.getByPlaceholder("Search command, node, or concept").fill("Alice");
await page.getByRole("option").filter({ hasText: "Alice" }).click();
const heatmap = page.getByRole("button", { name: "Heatmap", exact: true });
await heatmap.click();
await legend.waitFor({ state: "hidden" });
await heatmap.click();
await legend.waitFor();
await assertLegendMatchesGraph(page);
await page.getByRole("button", { name: "Focused", exact: true }).click();
await legend.getByText("Document", { exact: true }).waitFor({ state: "hidden" });
await assertLegendMatchesGraph(page, ["alice", "acme", "research"]);
assert.equal(await legend.getByText("Researcher", { exact: true }).count(), 1);
await page.getByRole("button", { name: "Full Graph", exact: true }).click();
await legend.getByText("Document", { exact: true }).waitFor();
await assertLegendMatchesGraph(page);
assert.deepEqual(errors, []);
});