707 lines
28 KiB
TypeScript
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;
|
|
}
|