Files
2026-07-12 21:26:08 +08:00

707 lines
28 KiB
TypeScript

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { readFile, readdir } from "node:fs/promises";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { createCommunityWashElement } from "../src/render/community-washes";
import { createCommunityLegend, createGraphToolbar, createSearchControl, createSigmaZoomControls } from "../src/render/controls";
import { createGraphEdgeElement } from "../src/render/edges";
import { createGraphMinimap } from "../src/render/minimap";
import { createGraphNodeElement } from "../src/render/nodes";
import type { RenderableCommunity, RenderableEdge, RenderableMinimap, RenderableNode } from "../src/render";
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const SRC = join(ROOT, "src");
const HOST_CALLBACK_IDENTIFIERS = [
"onOpenPage",
"onSelectionChange",
"onSelectionClear",
"onAsk",
"persistPins",
"onDragStateChange",
"GraphEngineCapabilities",
"GraphOpenPagePayload"
];
const HOST_CALLBACK_ALLOWED_FILES = new Set(["facade.ts", "types.ts"]);
const RAW_GRAPH_EVENT_ALLOWED_FILES = new Set([
"render/gestures.ts",
"render/sigma-global-drag.ts"
]);
const RAW_GRAPH_EVENT_PATTERNS = [
/\baddEventListener\s*\(\s*["'](?:wheel|pointerdown|pointermove|pointerup|pointercancel|lostpointercapture)["']/,
/\bremoveEventListener\s*\(\s*["'](?:wheel|pointerdown|pointermove|pointerup|pointercancel|lostpointercapture)["']/,
/\bsetPointerCapture\s*\(/,
/\breleasePointerCapture\s*\(/,
/\bclassifyGraph(?:EventTarget|WheelTarget|WheelTargetFromGraphTarget|PointerDownTarget|PointerDownTargetFromGraphTarget)\s*\(/
];
const SIGMA_RENDERER_FORBIDDEN_RAW_GRAPH_EVENT_PATTERNS = [
/\baddEventListener\s*\(\s*["'](?:wheel|pointerdown|pointermove|pointerup|pointercancel|lostpointercapture)["']/,
/\bremoveEventListener\s*\(\s*["'](?:wheel|pointerdown|pointermove|pointerup|pointercancel|lostpointercapture)["']/,
/\bsetPointerCapture(?:\?\.)?\s*\(/,
/\breleasePointerCapture(?:\?\.)?\s*\(/
];
const SIGMA_OVERLAY_DOM_ALLOWED_RAW_GRAPH_EVENT_PATTERNS = [
/\baddEventListener\s*\(\s*["'](?:pointerdown)["']/,
/\baddEventListener\s*\(\s*["'](?:mousedown|click|dragstart)["']/,
/\bclassName = "sigma-global-node-hit-target"/
];
const SIGMA_OVERLAY_DOM_FORBIDDEN_RAW_GRAPH_EVENT_PATTERNS = [
/\baddEventListener\s*\(\s*["'](?:wheel|pointermove|pointerup|pointercancel|lostpointercapture)["']/,
/\bremoveEventListener\s*\(\s*["'](?:wheel|pointerdown|pointermove|pointerup|pointercancel|lostpointercapture)["']/,
/\bsetPointerCapture(?:\?\.)?\s*\(/,
/\breleasePointerCapture(?:\?\.)?\s*\(/,
/\bclassifyGraph(?:EventTarget|WheelTarget|WheelTargetFromGraphTarget|PointerDownTarget|PointerDownTargetFromGraphTarget)\s*\(/
];
const DRAWING_MODULES = [
"render/nodes.ts",
"render/edges.ts",
"render/community-washes.ts",
"render/minimap.ts",
"render/overlays.ts",
"render/hover-card.ts"
] as const;
const FORBIDDEN_RENDERER_EVENT_TYPES = new Set([
"wheel",
"pointerdown",
"pointermove",
"pointerup",
"pointercancel",
"lostpointercapture"
]);
const CONTROLLER_FORBIDDEN_PATTERNS = [
/\bcreateGraphNodeElement\b/,
/\bcreateGraphEdgeElement\b/,
/\bcreateCommunityWashElement\b/,
/\bcreateGraphMinimap\b/,
/\bbuildRenderableGraph\b/,
/\bpaint\s*\(/,
/\bmount(?:SearchControl|GraphToolbar|CommunityLegend)\s*\(/
];
const RENDER_ONLY_STATE_MUTATION_PATTERNS = [
/\bsetSelection\s*\(/,
/\bsetFocus\s*\(/,
/\bsetPins\s*\(/
];
const PIPELINE_FORBIDDEN_STATE_MUTATION_PATTERNS = [
...RENDER_ONLY_STATE_MUTATION_PATTERNS,
/\bsetHover\s*\(/
];
describe("renderer and facade boundary contract", () => {
it("keeps host callback names out of layout and renderer modules", async () => {
const files = await sourceFiles(SRC);
const violations: string[] = [];
for (const file of files) {
const rel = relative(SRC, file);
if (HOST_CALLBACK_ALLOWED_FILES.has(rel)) continue;
const text = await readFile(file, "utf8");
for (const identifier of HOST_CALLBACK_IDENTIFIERS) {
if (new RegExp(`\\b${identifier}\\b`).test(text)) violations.push(`${rel}: ${identifier}`);
}
}
assert.deepEqual(violations, []);
});
it("keeps graph object hit classification calls inside GraphGestures", async () => {
const renderFiles = (await sourceFiles(join(SRC, "render")))
.filter((file) => {
const rel = relative(SRC, file);
return rel !== "render/gestures.ts" && rel !== "render/index.ts";
});
const violations: string[] = [];
const forbiddenCalls = /\bclassifyGraph(?:EventTarget|WheelTarget|WheelTargetFromGraphTarget|PointerDownTarget|PointerDownTargetFromGraphTarget)\s*\(/;
for (const file of renderFiles) {
const rel = relative(SRC, file);
const text = await readFile(file, "utf8");
if (forbiddenCalls.test(text)) violations.push(rel);
}
assert.deepEqual(violations, []);
});
it("keeps raw graph gesture ownership out of drawing modules", async () => {
const violations: string[] = [];
for (const rel of DRAWING_MODULES) {
const text = await readFile(join(SRC, rel), "utf8");
for (const pattern of RAW_GRAPH_EVENT_PATTERNS) {
if (pattern.test(text)) violations.push(`${rel}: ${pattern}`);
}
}
assert.deepEqual(violations, []);
});
it("keeps root graph wheel and pointer bindings inside GraphGestures", async () => {
const renderFiles = await sourceFiles(join(SRC, "render"));
const violations: string[] = [];
for (const file of renderFiles) {
const rel = relative(SRC, file);
if (RAW_GRAPH_EVENT_ALLOWED_FILES.has(rel)) continue;
const text = await readFile(file, "utf8");
const patterns = rel === "render/sigma-global-renderer.ts"
? SIGMA_RENDERER_FORBIDDEN_RAW_GRAPH_EVENT_PATTERNS
: rel === "render/sigma-overlay-dom.ts"
? SIGMA_OVERLAY_DOM_FORBIDDEN_RAW_GRAPH_EVENT_PATTERNS
: RAW_GRAPH_EVENT_PATTERNS;
for (const pattern of patterns) {
if (pattern.test(text)) violations.push(`${rel}: ${pattern}`);
}
}
assert.deepEqual(violations, []);
});
it("keeps Sigma raw pointer exceptions limited to node overlay drag", async () => {
const sigmaRendererText = await readFile(join(SRC, "render/sigma-global-renderer.ts"), "utf8");
const sigmaDragText = await readFile(join(SRC, "render/sigma-global-drag.ts"), "utf8");
const sigmaOverlayDomText = await readFile(join(SRC, "render/sigma-overlay-dom.ts"), "utf8");
assert.equal(/\baddEventListener\s*\(\s*["']wheel["']/.test(sigmaRendererText), false);
assert.equal(/\baddEventListener\s*\(\s*["']wheel["']/.test(sigmaDragText), false);
assert.doesNotMatch(sigmaRendererText, /className = "sigma-global-node-hit-target"/);
assert.doesNotMatch(sigmaRendererText, /\baddEventListener\s*\(\s*"pointerdown"/);
assert.equal(/\baddEventListener\s*\(\s*["'](?:pointermove|pointerup|pointercancel|lostpointercapture)["']/.test(sigmaRendererText), false);
assert.equal(/\bremoveEventListener\s*\(\s*["'](?:pointermove|pointerup|pointercancel|lostpointercapture)["']/.test(sigmaRendererText), false);
assert.equal(/\bsetPointerCapture(?:\?\.)?\s*\(/.test(sigmaRendererText), false);
assert.equal(/\breleasePointerCapture(?:\?\.)?\s*\(/.test(sigmaRendererText), false);
assert.match(sigmaOverlayDomText, /className = "sigma-global-node-hit-target"/);
assert.match(sigmaOverlayDomText, /\baddEventListener\s*\(\s*"pointerdown"/);
assert.equal(/\baddEventListener\s*\(\s*["'](?:wheel|pointermove|pointerup|pointercancel|lostpointercapture)["']/.test(sigmaOverlayDomText), false);
assert.equal(/\bremoveEventListener\s*\(\s*["'](?:wheel|pointerdown|pointermove|pointerup|pointercancel|lostpointercapture)["']/.test(sigmaOverlayDomText), false);
assert.equal(/\bsetPointerCapture(?:\?\.)?\s*\(/.test(sigmaOverlayDomText), false);
assert.equal(/\breleasePointerCapture(?:\?\.)?\s*\(/.test(sigmaOverlayDomText), false);
for (const pattern of SIGMA_OVERLAY_DOM_ALLOWED_RAW_GRAPH_EVENT_PATTERNS) {
assert.match(sigmaOverlayDomText, pattern);
}
assert.match(sigmaDragText, /\bbindSigmaGlobalOverlayPointerDrag\b/);
assert.match(sigmaDragText, /\bsetPointerCapture(?:\?\.)?\s*\(/);
assert.match(sigmaDragText, /\baddEventListener\s*\(\s*"pointermove"/);
assert.match(sigmaDragText, /\bremoveEventListener\s*\(\s*"pointermove"/);
assert.match(sigmaDragText, /\breleasePointerCapture(?:\?\.)?\s*\(/);
});
it("keeps Sigma overlay DOM out of host callbacks and graph selection ownership", async () => {
const sigmaOverlayDomText = await readFile(join(SRC, "render/sigma-overlay-dom.ts"), "utf8");
for (const identifier of HOST_CALLBACK_IDENTIFIERS) {
assert.doesNotMatch(sigmaOverlayDomText, new RegExp(`\\b${identifier}\\b`));
}
for (const pattern of RENDER_ONLY_STATE_MUTATION_PATTERNS) {
assert.doesNotMatch(sigmaOverlayDomText, pattern);
}
assert.doesNotMatch(sigmaOverlayDomText, /\boptions\.(?:onHitTarget|onPinsChanged|onDragActiveChange)\b/);
assert.doesNotMatch(sigmaOverlayDomText, /\bbuildGraphRendererAdapterData\b/);
});
it("keeps blank double-click ownership out of the graph renderer root", async () => {
const rendererText = await readFile(join(SRC, "render/graph-renderer-root.ts"), "utf8");
assert.equal(/\baddEventListener\s*\(\s*["']dblclick["']/.test(rendererText), false);
assert.equal(/\bremoveEventListener\s*\(\s*["']dblclick["']/.test(rendererText), false);
});
it("keeps browser pointer coordinate normalization inside GraphGestures", async () => {
const rendererText = await readFile(join(SRC, "render/graph-renderer-root.ts"), "utf8");
const gestureText = await readFile(join(SRC, "render/gestures.ts"), "utf8");
assert.equal(/\brootClientPointToScreenPoint\b/.test(rendererText), false);
assert.equal(/\bclient[XY]\b/.test(rendererText), false);
assert.equal(/\brootClientPointToScreenPoint\b/.test(gestureText), true);
});
it("proves drawing modules do not attach global graph gesture listeners at runtime", () => {
const ownerDocument = new FakeDocument();
createGraphNodeElement(ownerDocument as unknown as Document, sampleNode(), {
onNodeClick: () => {},
onNodeDoubleClick: () => false,
onNodePreviewEnter: () => {},
onNodePreviewLeave: () => {}
});
createGraphEdgeElement(ownerDocument as unknown as Document, sampleEdge(), {
onEdgePreviewEnter: () => {},
onEdgePreviewLeave: () => {}
});
createCommunityWashElement(ownerDocument as unknown as Document, sampleCommunity());
createGraphMinimap(ownerDocument as unknown as Document, sampleMinimap());
assert.deepEqual(ownerDocument.forbiddenListeners, []);
});
it("keeps controller out of drawing and render-model ownership", async () => {
const controllerText = await readFile(join(SRC, "render/controller.ts"), "utf8");
const violations = CONTROLLER_FORBIDDEN_PATTERNS
.filter((pattern) => pattern.test(controllerText))
.map(String);
assert.deepEqual(violations, []);
});
it("routes controller node DOM focus and class commands through the renderer surface", async () => {
const controllerText = await readFile(join(SRC, "render/controller.ts"), "utf8");
const surfaceText = await readFile(join(SRC, "render/renderer-surface.ts"), "utf8");
assert.doesNotMatch(
controllerText,
/context\.dom\.(?:nodeElements|edgeElements|aggregationContainerElements).*?\.(?:focus|classList)/
);
assert.match(controllerText, /context\.rendererSurface\.focusNode\(/);
assert.match(controllerText, /context\.rendererSurface\.setNodeDragging\(/);
assert.match(controllerText, /context\.rendererSurface\.clearNodeDragging\(/);
assert.match(surfaceText, /focusNode\(id/);
assert.match(surfaceText, /setNodeDragging\(id/);
});
it("keeps route transition CSS attached to the stable facade route marker", async () => {
const stylesText = await readFile(join(SRC, "render/render-styles.ts"), "utf8");
assert.equal(stylesText.includes("[data-llm-wiki-graph-route-transition] > .sigma-global-route"), true);
assert.equal(stylesText.includes("[data-llm-wiki-graph-route-transition] > [data-llm-wiki-graph-root=\"true\"]"), true);
assert.equal(stylesText.includes("[data-llm-wiki-graph-route-transition] > .graph-over-limit-notice-view"), true);
assert.equal(stylesText.includes(".llm-wiki-graph-engine[data-llm-wiki-graph-route-transition]"), false);
});
it("mounts the Sigma global route on the themed graph root surface", async () => {
const facadeText = await readFile(join(SRC, "facade.ts"), "utf8");
assert.match(facadeText, /shell\.className = "sigma-global-route llm-wiki-graph-engine"/);
assert.match(facadeText, /applyGraphThemeToElement\(shell, options\.theme\)/);
assert.match(facadeText, /applyGraphThemeToElement\(shell, theme\)/);
});
it("mounts and styles Sigma zoom controls outside the main graph toolbar", async () => {
const facadeText = await readFile(join(SRC, "facade.ts"), "utf8");
const stylesText = await readFile(join(SRC, "render/render-styles.ts"), "utf8");
assertSourceContainsAll(facadeText, [
"createSigmaZoomControls",
"shell.querySelector(\".graph-zoom-controls\")?.remove();",
"onZoomIn: () => renderer?.zoomIn()",
"onZoomOut: () => renderer?.zoomOut()"
]);
assertSourceContainsAll(stylesText, [
".graph-zoom-controls",
"bottom: 14px;",
".graph-zoom-button",
".graph-zoom-button:hover"
]);
});
it("lets Sigma route share theme styles without inheriting the root minimum height", async () => {
const stylesText = await readFile(join(SRC, "render/render-styles.ts"), "utf8");
assert.match(stylesText, /\.llm-wiki-graph-engine\s*\{[\s\S]*?min-height: 520px;/);
assert.match(stylesText, /\.sigma-global-route\.llm-wiki-graph-engine\s*\{\s*min-height: 0;\s*\}/);
});
it("keeps Sigma node hit targets above passive community overlays", async () => {
const stylesText = await readFile(join(SRC, "render/render-styles.ts"), "utf8");
const nodeHitTargetBlock = stylesText.match(/\.sigma-global-node-hit-target\s*\{[\s\S]*?\n\}/)?.[0] ?? "";
assert.match(nodeHitTargetBlock, /pointer-events:\s*auto;/);
assert.match(nodeHitTargetBlock, /z-index:\s*2;/);
});
it("keeps pipeline and presenter out of semantic selection/focus/pin ownership", async () => {
const files: Array<[string, RegExp[]]> = [
["render/render-pipeline.ts", PIPELINE_FORBIDDEN_STATE_MUTATION_PATTERNS],
["render/overlays-presenter.ts", RENDER_ONLY_STATE_MUTATION_PATTERNS]
];
const violations: string[] = [];
for (const [rel, patterns] of files) {
const text = await readFile(join(SRC, rel), "utf8");
for (const pattern of patterns) {
if (pattern.test(text)) violations.push(`${rel}: ${pattern}`);
}
}
assert.deepEqual(violations, []);
});
it("routes mounted control callbacks through injected graph commands", () => {
const ownerDocument = new FakeDocument();
const calls: string[] = [];
const search = createSearchControl(ownerDocument as unknown as Document, {
open: false,
query: "",
onOpen: () => calls.push("openSearch"),
onQuery: (query) => calls.push(`query:${query}`),
onNext: () => calls.push("nextSearch"),
onPrevious: () => calls.push("previousSearch"),
onActivate: () => calls.push("activateSearch"),
onClose: () => calls.push("closeSearch")
});
search.input.value = "atlas";
(search.input as unknown as FakeElement).dispatch("focus");
(search.input as unknown as FakeElement).dispatch("input");
(search.input as unknown as FakeElement).dispatch("keydown", { key: "ArrowDown" });
(search.input as unknown as FakeElement).dispatch("keydown", { key: "ArrowUp" });
(search.input as unknown as FakeElement).dispatch("keydown", { key: "Enter" });
(search.input as unknown as FakeElement).dispatch("keydown", { key: "Escape" });
const toolbar = createGraphToolbar(ownerDocument as unknown as Document, {
panelState: "closed",
typeFilters: { topic: true },
onPanelToggle: (panel) => calls.push(`panel:${panel}`),
onTypeFilterToggle: (type, enabled) => calls.push(`filter:${type}:${enabled}`),
onReset: () => calls.push("requestGlobalReset")
});
const toolbarButtons = findByClass(toolbar.element as unknown as FakeElement, "graph-toolbar-button");
toolbarButtons[0]?.dispatch("click");
toolbarButtons[2]?.dispatch("click");
const filterInput = findByTag(toolbar.element as unknown as FakeElement, "input")[0];
if (filterInput) {
filterInput.checked = false;
filterInput.dispatch("change");
}
const legend = createCommunityLegend(ownerDocument as unknown as Document, {
rows: [{ id: "community-a", label: "Community A", color: "#c66", pageCount: 2, nodeIds: ["node-a"] }],
collapsed: false,
onToggle: () => calls.push("toggleLegend"),
onHover: (id) => calls.push(`hover:${id ?? "none"}`),
onSelect: (id) => calls.push(`selectCommunity:${id}`)
});
const legendToggle = findByClass(legend.element as unknown as FakeElement, "community-legend-toggle")[0];
legendToggle?.dispatch("click");
const legendRow = legend.rows.get("community-a") as unknown as FakeElement | undefined;
legendRow?.dispatch("pointerenter");
legendRow?.dispatch("pointerleave");
legendRow?.dispatch("click");
assert.deepEqual(calls, [
"openSearch",
"query:atlas",
"nextSearch",
"previousSearch",
"activateSearch",
"closeSearch",
"panel:filters",
"requestGlobalReset",
"filter:topic:false",
"toggleLegend",
"hover:community-a",
"hover:none",
"selectCommunity:community-a"
]);
});
it("wires Sigma zoom controls as a separate bottom-left control group", () => {
const ownerDocument = new FakeDocument();
const calls: string[] = [];
const zoom = createSigmaZoomControls(ownerDocument as unknown as Document, {
onZoomIn: () => calls.push("zoomIn"),
onZoomOut: () => calls.push("zoomOut")
});
assert.equal(zoom.element.className, "graph-zoom-controls");
assert.equal(zoom.element.dataset.control, "sigma-zoom");
assert.equal(zoom.element.getAttribute("aria-label"), "图谱缩放");
assert.equal(zoom.buttons.zoomIn.textContent, "+");
assert.equal(zoom.buttons.zoomIn.getAttribute("aria-label"), "放大图谱");
assert.equal(zoom.buttons.zoomOut.textContent, "-");
assert.equal(zoom.buttons.zoomOut.getAttribute("aria-label"), "缩小图谱");
const zoomInEvent = zoom.buttons.zoomIn.dispatch("click");
const zoomOutEvent = zoom.buttons.zoomOut.dispatch("click");
assert.deepEqual(calls, ["zoomIn", "zoomOut"]);
assert.equal(zoomInEvent.propagationStopped, true);
assert.equal(zoomOutEvent.propagationStopped, true);
assert.deepEqual(ownerDocument.forbiddenListeners, []);
});
it("keeps mounted control wiring on injected graph commands", async () => {
const pipelineText = await readFile(join(SRC, "render/render-pipeline.ts"), "utf8");
assert.doesNotMatch(pipelineText, /from\s+["']\.\/controller["']/);
assertSourceContainsAll(pipelineText, [
"options.commands.openSearch()",
"options.commands.applySearchQuery(query)",
"options.commands.focusNextSearchResult()",
"options.commands.focusPreviousSearchResult()",
"options.commands.activateSearchResult()",
"options.commands.closeSearch()",
"options.commands.setCommunityHover(id)",
"options.commands.selectCommunity(id)",
"applyTypeFilters({ ...context.typeFilters, [type]: enabled })",
"options.commands.requestGlobalReset()"
]);
assert.doesNotMatch(pipelineText, /onReset:[\s\S]{0,160}resetViewState\(\)/);
});
it("routes controller-owned return-global entries through the global reset command with DOM fallback", async () => {
const rootText = await readFile(join(SRC, "render/graph-renderer-root.ts"), "utf8");
const controllerText = await readFile(join(SRC, "render/controller.ts"), "utf8");
assertSourceContainsAll(rootText, [
"requestGlobalReset:",
"context.callbacks.onGlobalResetRequested",
"controller.resetViewState()"
]);
assertSourceContainsAll(controllerText, [
"onBlankDoubleClick:",
"requestGlobalReset()",
"function requestGlobalReset(): void"
]);
});
it("keeps DOM/SVG graph drawing behind the dom-svg renderer boundary", async () => {
const pipelineText = await readFile(join(SRC, "render/render-pipeline.ts"), "utf8");
const domSvgText = await readFile(join(SRC, "render/dom-svg-renderer.ts"), "utf8");
assert.match(pipelineText, /paintDomSvgGraph\(\{/);
assert.doesNotMatch(pipelineText, /\bcreateGraphNodeElement\b/);
assert.doesNotMatch(pipelineText, /\bcreateGraphEdgeElement\b/);
assert.doesNotMatch(pipelineText, /\bcreateCommunityWashElement\b/);
assert.doesNotMatch(pipelineText, /\bcreateGraphMinimap\b/);
assert.match(domSvgText, /\bcreateGraphNodeElement\b/);
assert.match(domSvgText, /\bcreateGraphEdgeElement\b/);
assert.match(domSvgText, /\bcreateCommunityWashElement\b/);
assert.match(domSvgText, /\bcreateGraphMinimap\b/);
});
it("keeps lifecycle teardown ownership explicit in the renderer root", async () => {
const rootText = await readFile(join(SRC, "render/graph-renderer-root.ts"), "utf8");
const pipelineText = await readFile(join(SRC, "render/render-pipeline.ts"), "utf8");
const presenterText = await readFile(join(SRC, "render/overlays-presenter.ts"), "utf8");
assert.match(rootText, /if \(context\.destroyed\) return;/);
assert.match(rootText, /pipeline\.destroy\(\);/);
assert.match(rootText, /presenter\.destroy\(\);/);
assert.match(rootText, /removeEventListener\("scroll", pipeline\.resetRootScroll\)/);
assert.match(rootText, /removeEventListener\("keydown", controller\.handleDocumentKeydown\)/);
assert.match(rootText, /context\.gestureController\?\.destroy\(\);/);
assert.match(rootText, /context\.pathCache\.clear\(\);/);
assert.match(pipelineText, /context\.simulation\?\.destroy\(\);/);
assert.match(pipelineText, /context\.resizeObserver\?\.disconnect\(\);/);
assert.match(pipelineText, /clearTimeout\(context\.viewportAnimationTimer\)/);
assert.match(presenterText, /clearTimeout\(context\.previewTimer\)/);
assert.match(presenterText, /context\.previewTimer = null;/);
});
it("keeps async diff settlement guarded after destroy", async () => {
const pipelineText = await readFile(join(SRC, "render/render-pipeline.ts"), "utf8");
assert.match(pipelineText, /if \(context\.destroyed\) return;/);
assert.match(pipelineText, /const diffEpoch = \+\+context\.renderEpoch;/);
assert.match(pipelineText, /context\.renderEpoch === diffEpoch/);
});
});
async function sourceFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const files = await Promise.all(entries.map(async (entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) return sourceFiles(path);
return entry.isFile() && path.endsWith(".ts") ? [path] : [];
}));
return files.flat();
}
function assertSourceContainsAll(source: string, expectedSnippets: string[]): void {
const missing = expectedSnippets.filter((snippet) => !source.includes(snippet));
assert.deepEqual(missing, []);
}
function sampleNode(): RenderableNode {
return {
id: "node-a",
label: "Node A",
type: "topic",
kind: "TOPIC",
community: "community-a",
sourcePath: "wiki/node-a.md",
x: 50,
y: 40,
point: { x: 500, y: 272 },
displayMode: "card",
visualRole: "landmark",
priority: 80,
weight: 80,
unavailable: false,
selected: false,
startNode: false,
previewStart: false,
labelVisible: true
};
}
function sampleEdge(): RenderableEdge {
return {
id: "edge-a",
source: "node-a",
target: "node-b",
type: "extracted",
confidence: "extracted",
relationType: "实现",
relationClass: "relation-implementation",
path: "M 0 0 Q 50 50 100 100",
curveOffset: 0,
strokeWidth: 1.5,
opacity: 0.8,
simulationWeight: 1
};
}
function sampleCommunity(): RenderableCommunity {
return {
id: "community-a",
label: "Community A",
color: "#c66",
nodeCount: 3,
wash: {
cx: 500,
cy: 272,
rx: 120,
ry: 80,
opacity: 0.18
}
};
}
function sampleMinimap(): RenderableMinimap {
return {
path: "M0 0 L100 20",
nodes: [{ id: "node-a", x: 10, y: 12, r: 3, fill: "#c66", selected: false }]
};
}
class FakeDocument {
readonly forbiddenListeners: string[] = [];
createElement(tagName: string): FakeElement {
return new FakeElement(tagName, this);
}
createElementNS(_namespace: string, tagName: string): FakeElement {
return new FakeElement(tagName, this);
}
}
class FakeElement {
readonly children: FakeElement[] = [];
private readonly listeners = new Map<string, Array<(event: FakeEvent) => void>>();
private readonly attributes = new Map<string, string>();
readonly dataset: Record<string, string | undefined> = {};
readonly style: Record<string, string> & { setProperty(name: string, value: string): void } = {
setProperty(name: string, value: string): void {
(this as unknown as Record<string, string>)[name] = value;
}
};
readonly classList = {
add: (...classNames: string[]) => {
this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...classNames])].join(" ");
},
toggle: (className: string, force?: boolean) => {
const classNames = new Set(this.className.split(/\s+/).filter(Boolean));
const shouldAdd = force ?? !classNames.has(className);
if (shouldAdd) classNames.add(className);
else classNames.delete(className);
this.className = [...classNames].join(" ");
}
};
className = "";
textContent = "";
type = "";
title = "";
href = "";
innerHTML = "";
checked = false;
value = "";
constructor(readonly tagName: string, private readonly ownerDocument: FakeDocument) {}
append(...children: Array<FakeElement | string>): void {
for (const child of children) {
if (typeof child === "string") {
this.textContent += child;
} else {
this.children.push(child);
}
}
}
appendChild(child: FakeElement): FakeElement {
this.children.push(child);
return child;
}
setAttribute(name: string, value: string): void {
this.attributes.set(name, value);
if (name === "class") this.className = value;
else if (name === "href") this.href = value;
else (this as unknown as Record<string, string>)[name] = value;
}
getAttribute(name: string): string | null {
return this.attributes.get(name) ?? null;
}
addEventListener(type: string, listener: unknown): void {
if (FORBIDDEN_RENDERER_EVENT_TYPES.has(type)) {
this.ownerDocument.forbiddenListeners.push(`${this.tagName}:${type}`);
}
const listeners = this.listeners.get(type) || [];
listeners.push(listener as (event: FakeEvent) => void);
this.listeners.set(type, listeners);
}
dispatch(type: string, init: Record<string, unknown> = {}): FakeEvent {
const event = new FakeEvent(type, init);
for (const listener of this.listeners.get(type) || []) {
listener(event);
}
return event;
}
}
class FakeEvent {
readonly type: string;
readonly key?: string;
defaultPrevented = false;
propagationStopped = false;
constructor(type: string, init: Record<string, unknown>) {
this.type = type;
if (typeof init.key === "string") this.key = init.key;
}
preventDefault(): void {
this.defaultPrevented = true;
}
stopPropagation(): void {
this.propagationStopped = true;
}
}
function findByClass(root: FakeElement, className: string): FakeElement[] {
const matches: FakeElement[] = [];
const classes = new Set(root.className.split(/\s+/).filter(Boolean));
if (classes.has(className)) matches.push(root);
for (const child of root.children) {
matches.push(...findByClass(child, className));
}
return matches;
}
function findByTag(root: FakeElement, tagName: string): FakeElement[] {
const matches: FakeElement[] = [];
if (root.tagName === tagName) matches.push(root);
for (const child of root.children) {
matches.push(...findByTag(child, tagName));
}
return matches;
}