Files
llm-wiki/tests/browser/graph-community-node-map.mjs
T
2026-07-12 21:26:08 +08:00

256 lines
14 KiB
JavaScript

import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const html = process.env.GRAPH_COMMUNITY_NODE_MAP_HTML || "";
assert.notEqual(html, "", "GRAPH_COMMUNITY_NODE_MAP_HTML must point at generated HTML");
const screenshotPath = process.env.GRAPH_COMMUNITY_NODE_MAP_SCREENSHOT || "";
const executablePath = process.env.GRAPH_COMMUNITY_NODE_MAP_CHROME_EXECUTABLE || "";
const browser = await chromium.launch(executablePath ? { executablePath } : {});
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
await page.goto(pathToFileURL(html).href);
await page.waitForSelector('[data-testid="offline-graph-root"]');
await page.evaluate(() => {
const engine = window.__LLM_WIKI_GRAPH_ENGINE__;
if (!engine?.focusCommunity) throw new Error("offline graph engine is missing focusCommunity");
engine.focusCommunity("t1");
});
await page.waitForSelector('[data-community-map-state="lightweight"]');
await page.waitForSelector('.node[data-id="A"]');
const initial = await snapshot(page);
assert.equal(initial.communityMapState, "lightweight");
assert.equal(initial.relationFocus, "idle");
assert.equal(initial.fullCardCount, 0, "community map should not render full card nodes");
assert.ok(initial.visibleLabelCount > 0 && initial.visibleLabelCount <= 12, `default community map should expose only budgeted key labels, got ${initial.visibleLabelCount}`);
assert.ok(initial.visibleLabelCount < initial.nodeCount, `default community map should not label every node, got ${initial.visibleLabelCount}/${initial.nodeCount}`);
assert.ok(initial.defaultEdgeOpacity > 0 && initial.defaultEdgeOpacity < 0.35, `default community edges should stay quiet, got ${initial.defaultEdgeOpacity}`);
assert.equal(initial.rootPaperToken.toLowerCase(), "#f8f1e6", "community map should expose the approved warm paper token");
assert.match(initial.rootBackgroundSize, /42px 42px/, "community map should render the paper-grid background size");
assert.match(initial.rootBackground, /linear-gradient|radial-gradient/, "community map should keep a visible paper-style background treatment");
assert.ok(initial.sampleNodeStyles.dot, "community map nodes should render a dot-core element");
assert.ok(initial.sampleNodeStyles.label, "community map labels should still be measurable when visible");
assert.match(initial.sampleNodeStyles.label.fontFamily, /(Songti|Noto Serif|STSong|serif)/i, "community map labels should use the paper-map serif stack");
assert.equal(initial.nodeTypes.F, "comparison", "community map should preserve comparison nodes instead of collapsing them to entity");
assert.notEqual(initial.nodeDotColors.topic, initial.nodeDotColors.entity, "topic and entity dots should use different colors");
assert.notEqual(initial.nodeDotColors.source, initial.nodeDotColors.entity, "source and entity dots should use different colors");
assert.notEqual(initial.nodeDotColors.comparison, initial.nodeDotColors.entity, "comparison and entity dots should use different colors");
assert.equal(initial.edgeDashes.extracted, "none", "extracted confidence should render as a solid edge");
assert.notEqual(initial.edgeDashes.inferred, "none", "inferred confidence should render as a dashed edge");
assert.notEqual(initial.edgeDashes.ambiguous, "none", "ambiguous confidence should render as a dashed edge");
assert.notEqual(initial.edgeDashes.inferred, initial.edgeDashes.ambiguous, "inferred and ambiguous confidence should use distinct dash patterns");
await page.evaluate(() => {
window.__LLM_WIKI_GRAPH_ENGINE__?.setTypeFilters?.({ entity: false });
});
await page.locator('.node[data-id="A"]').hover();
await page.waitForFunction(() => document.querySelector('.edge[data-edge-id="eAB"]')?.getAttribute("data-filter-state") === "hidden");
const hiddenFilter = await snapshot(page);
const hiddenOpacity = Number.parseFloat(hiddenFilter.sampleNodeStyles.hiddenEdge?.opacity || "1");
assert.ok(hiddenOpacity <= 0.04, `filtered hidden edge should stay visually hidden, got ${hiddenOpacity}`);
await page.evaluate(() => {
window.__LLM_WIKI_GRAPH_ENGINE__?.setTypeFilters?.({ entity: true, source: true, topic: true });
});
await page.mouse.move(20, 20);
await page.waitForFunction(() => document.querySelector('.edge[data-edge-id="eAB"]')?.getAttribute("data-filter-state") === "visible");
const beforeHoverCenter = await nodeCenter(page, "B");
await page.locator('.node[data-id="A"]').hover();
await page.waitForFunction(() => (
document.querySelector("[data-llm-wiki-graph-root='true']")?.getAttribute("data-relation-focus") === "active" &&
document.querySelector('.node[data-id="B"]')?.getAttribute("data-relation-focus-depth") === "first"
));
const hover = await snapshot(page);
const afterHoverCenter = await nodeCenter(page, "B");
assert.equal(hover.relationFocusNode, "A");
assert.equal(hover.nodeDepths.A, "focus");
assert.equal(hover.nodeDepths.B, "first");
assert.equal(hover.nodeDepths.C, "first");
assert.equal(hover.nodeDepths.D, "second");
assert.equal(hover.nodeDepths.F, "unrelated");
assert.equal(hover.edgeDepths.eAB, "first");
assert.equal(hover.edgeDepths.eBD, "second");
assert.equal(hover.edgeDepths.eEF, "unrelated");
assert.ok(hover.firstEdgeOpacity > hover.secondEdgeOpacity, "direct edges should be clearer than second-degree edges");
assert.ok(hover.secondEdgeOpacity > hover.unrelatedEdgeOpacity, "second-degree edges should remain clearer than unrelated edges");
assert.ok(hover.nodeDotOpacity.first > hover.nodeDotOpacity.second, "first-degree nodes should be clearer than second-degree nodes");
assert.ok(hover.nodeDotOpacity.second > hover.nodeDotOpacity.unrelated, "second-degree nodes should be clearer than unrelated nodes");
assert.ok(Math.abs(beforeHoverCenter.x - afterHoverCenter.x) < 0.5, "hover should not shift node x");
assert.ok(Math.abs(beforeHoverCenter.y - afterHoverCenter.y) < 0.5, "hover should not shift node y");
assert.ok(hover.labelSideCount === hover.nodeCount, "each community map node should expose a label-side placement");
assert.ok(hover.firstDegreeCount >= 10, "fixture should be dense enough to catch first-degree label walls");
assert.ok(hover.visibleFirstLabels < hover.firstDegreeCount, "first-degree labels should stay sparse instead of labeling every direct neighbor");
assert.ok(hover.visibleFirstLabels <= hover.visibleLabelCount, "first-degree labels should stay within the visible label budget");
await page.waitForTimeout(360);
const hoverAfterPreviewDelay = await snapshot(page);
assert.notEqual(hoverAfterPreviewDelay.hoverPreviewState, "open", "focused community node hover should not open the old content preview card");
await page.locator('.node[data-id="B"]').hover();
await page.waitForFunction(() => (
document.querySelector("[data-llm-wiki-graph-root='true']")?.getAttribute("data-relation-focus-node") === "B"
));
await page.waitForTimeout(120);
const hoverB = await snapshot(page);
assert.equal(hoverB.relationFocusNode, "B", "hover should move directly from A focus to B focus");
await page.mouse.move(20, 20);
await page.waitForFunction(() => (
document.querySelector("[data-llm-wiki-graph-root='true']")?.getAttribute("data-relation-focus") === "idle"
));
const afterLeave = await snapshot(page);
assert.equal(afterLeave.relationFocusNode, "");
assert.equal(afterLeave.nodeDepths.A, "none");
await page.locator('.node[data-id="A"]').click();
await page.waitForSelector('.graph-reader[data-state="open"]');
await page.waitForFunction(() => (
document.querySelector("[data-llm-wiki-graph-root='true']")?.getAttribute("data-relation-focus-node") === "A"
));
const clicked = await snapshot(page);
assert.equal(clicked.readerOpen, true);
assert.equal(clicked.relationFocusNode, "A");
assert.equal(clicked.nodeDepths.B, "first");
await page.locator('.node[data-id="D"]').hover();
await page.waitForFunction(() => (
document.querySelector("[data-llm-wiki-graph-root='true']")?.getAttribute("data-relation-focus-node") === "D"
));
const override = await snapshot(page);
assert.equal(override.relationFocusNode, "D");
assert.equal(override.nodeDepths.A, "second");
await page.mouse.move(20, 20);
await page.waitForFunction(() => (
document.querySelector("[data-llm-wiki-graph-root='true']")?.getAttribute("data-relation-focus-node") === "A"
));
const restored = await snapshot(page);
assert.equal(restored.relationFocusNode, "A");
assert.equal(restored.nodeDepths.A, "focus");
console.log(JSON.stringify({ initial, hover, afterLeave, clicked, override, restored }, null, 2));
if (screenshotPath) {
await page.screenshot({ path: screenshotPath, fullPage: true });
}
} finally {
await browser.close();
}
async function snapshot(page) {
return page.evaluate(() => {
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
const node = (id) => document.querySelector(`.node[data-id="${CSS.escape(id)}"]`);
const edge = (id) => document.querySelector(`.edge[data-edge-id="${CSS.escape(id)}"]`);
const edgeOpacity = (id) => {
const element = edge(id);
if (!element) return -1;
return Number.parseFloat(getComputedStyle(element).opacity || "0");
};
const stylesFor = (selector) => {
const element = document.querySelector(selector);
if (!element) return null;
const style = getComputedStyle(element);
return {
display: style.display,
background: style.backgroundColor,
boxShadow: style.boxShadow,
border: style.border,
borderRadius: style.borderRadius,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
left: style.left,
right: style.right,
top: style.top,
bottom: style.bottom,
opacity: style.opacity,
strokeDasharray: style.strokeDasharray,
transform: style.transform,
width: style.width,
height: style.height
};
};
return {
communityMapState: root?.getAttribute("data-community-map-state") || "",
relationFocus: root?.getAttribute("data-relation-focus") || "",
relationFocusNode: root?.getAttribute("data-relation-focus-node") || "",
nodeCount: document.querySelectorAll(".node").length,
fullCardCount: document.querySelectorAll('.node[data-density-mode="card"]').length,
visibleLabelCount: Array.from(document.querySelectorAll(".node-name")).filter((element) => getComputedStyle(element).display !== "none").length,
defaultEdgeOpacity: edgeOpacity("eAB"),
firstEdgeOpacity: edgeOpacity("eAB"),
secondEdgeOpacity: edgeOpacity("eBD"),
unrelatedEdgeOpacity: edgeOpacity("eEF"),
readerOpen: document.querySelector(".graph-reader")?.getAttribute("data-state") === "open",
nodeDepths: {
A: node("A")?.getAttribute("data-relation-focus-depth") || "",
B: node("B")?.getAttribute("data-relation-focus-depth") || "",
C: node("C")?.getAttribute("data-relation-focus-depth") || "",
D: node("D")?.getAttribute("data-relation-focus-depth") || "",
F: node("F")?.getAttribute("data-relation-focus-depth") || ""
},
edgeDepths: {
eAB: edge("eAB")?.getAttribute("data-relation-focus-depth") || "",
eBD: edge("eBD")?.getAttribute("data-relation-focus-depth") || "",
eEF: edge("eEF")?.getAttribute("data-relation-focus-depth") || ""
},
nodeTypes: {
A: node("A")?.getAttribute("data-type") || "",
B: node("B")?.getAttribute("data-type") || "",
C: node("C")?.getAttribute("data-type") || "",
F: node("F")?.getAttribute("data-type") || ""
},
nodeDotColors: {
topic: stylesFor('.node[data-id="A"] .dot-core')?.background || "",
entity: stylesFor('.node[data-id="B"] .dot-core')?.background || "",
source: stylesFor('.node[data-id="C"] .dot-core')?.background || "",
comparison: stylesFor('.node[data-id="F"] .dot-core')?.background || ""
},
nodeDotOpacity: {
first: Number.parseFloat(stylesFor('.node[data-id="B"] .dot-core')?.opacity || "0"),
second: Number.parseFloat(stylesFor('.node[data-id="D"] .dot-core')?.opacity || "0"),
unrelated: Number.parseFloat(stylesFor('.node[data-id="F"] .dot-core')?.opacity || "0")
},
edgeDashes: {
extracted: stylesFor('.edge[data-edge-id="eAB"]')?.strokeDasharray || "",
inferred: stylesFor('.edge[data-edge-id="eAC"]')?.strokeDasharray || "",
ambiguous: stylesFor('.edge[data-edge-id="eEF"]')?.strokeDasharray || ""
},
rootBackground: root ? getComputedStyle(root).backgroundImage : "",
rootBackgroundSize: root ? getComputedStyle(root).backgroundSize : "",
rootPaperToken: root ? getComputedStyle(root).getPropertyValue("--community-map-paper").trim() : "",
sampleNodeStyles: {
node: stylesFor('.node[data-id="A"]'),
dot: stylesFor('.node[data-id="A"] .dot-core'),
label: stylesFor('.node[data-id="A"] .node-name'),
hiddenEdge: stylesFor('.edge[data-edge-id="eAB"][data-filter-state="hidden"]'),
firstEdge: stylesFor('.edge[data-edge-id="eAB"]')
},
hoverPreviewState: document.querySelector(".graph-hover-preview")?.getAttribute("data-state") || "",
hoverPreviewKind: document.querySelector(".graph-hover-preview")?.getAttribute("data-kind") || "",
labelSideCount: Array.from(document.querySelectorAll(".node[data-label-side]")).length,
firstDegreeCount: Array.from(document.querySelectorAll('.node[data-relation-focus-depth="first"]')).length,
visibleFirstLabels: Array.from(document.querySelectorAll('.node[data-relation-focus-depth="first"] .node-name'))
.filter((element) => getComputedStyle(element).display !== "none").length
};
});
}
async function nodeCenter(page, id) {
return page.locator(`.node[data-id="${cssString(id)}"]`).evaluate((element) => {
const rect = element.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
});
}
function cssString(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}