This commit is contained in:
2026-07-12 21:26:08 +08:00
commit 9dd41afd48
502 changed files with 129901 additions and 0 deletions
+660
View File
@@ -0,0 +1,660 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const basicHtml = process.env.GRAPH_OFFLINE_PHASE_6_BASIC_HTML || "";
const denseHtml = process.env.GRAPH_OFFLINE_PHASE_6_DENSE_HTML || "";
const multicommHtml = process.env.GRAPH_OFFLINE_PHASE_6_MULTICOMM_HTML || "";
const artifactDir = process.env.GRAPH_OFFLINE_PHASE_6_ARTIFACT_DIR || "";
const executablePath = process.env.GRAPH_OFFLINE_PHASE_6_CHROME_EXECUTABLE || "";
assert.notEqual(basicHtml, "", "GRAPH_OFFLINE_PHASE_6_BASIC_HTML must point at generated basic HTML");
assert.notEqual(denseHtml, "", "GRAPH_OFFLINE_PHASE_6_DENSE_HTML must point at generated dense HTML");
assert.notEqual(multicommHtml, "", "GRAPH_OFFLINE_PHASE_6_MULTICOMM_HTML must point at generated multicomm HTML");
const browser = await chromium.launch(executablePath ? { executablePath } : {});
try {
const basic = await runBasicOfflineChecks(browser);
const dense = await runFixtureSmoke(browser, denseHtml, "dense");
const multicomm = await runFixtureSmoke(browser, multicommHtml, "multicomm");
const evidence = { basic, dense, multicomm };
if (artifactDir) {
await fs.mkdir(artifactDir, { recursive: true });
await fs.writeFile(path.join(artifactDir, "phase-6-offline-interactions.json"), `${JSON.stringify(evidence, null, 2)}\n`);
}
console.log(JSON.stringify(evidence, null, 2));
} finally {
await browser.close();
}
async function runBasicOfflineChecks(browser) {
const page = await openOfflinePage(browser, basicHtml, { width: 1440, height: 960 });
const evidence = {
html: basicHtml,
viewport: "1440x960",
wheelTargets: {},
rootScroll: {},
hover: {},
drag: {},
reader: {},
pinPersistence: {},
theme: {}
};
evidence.wheelTargets.blank = await assertWheelZoomsFromReset(page, () => findBlankPoint(page), "blank");
evidence.wheelTargets.node = await assertWheelZoomsFromReset(page, () => visibleNodeCenter(page), "node");
evidence.wheelTargets.communityWash = await assertWheelZoomsFromReset(page, () => findCommunityWashPoint(page, "t1"), "community wash");
await resetGraphView(page);
evidence.rootScroll = await assertGraphRootScrollResets(page);
evidence.hover.beforeZoom = await hoverNodeAndMeasure(page, null, {
preferCenter: true,
roomLeft: 120,
roomRight: 360,
roomTop: 120,
roomBottom: 180
});
evidence.hover.node = evidence.hover.beforeZoom.target;
evidence.hover.zoom = await assertWheelZooms(page, await nodeCenter(page, evidence.hover.node.id), "hovered node");
evidence.hover.afterZoom = await hoverNodeAndMeasure(page, evidence.hover.node.id);
assert.equal(
evidence.hover.afterZoom.target.id,
evidence.hover.node.id,
"offline hover after zoom should stay on the same node"
);
assertHoverStaysNearAnchor(evidence.hover.beforeZoom, "offline hover before zoom should stay near its node");
assertHoverStaysNearAnchor(evidence.hover.afterZoom, "offline hover after zoom should stay near its node");
await resetGraphView(page);
const dragNode = await visibleNode(page, { roomRight: 360 });
evidence.drag = await dragNodeAndMeasure(page, dragNode.id);
evidence.pinPersistence = await assertPinPersistsAfterReload(page, dragNode.id);
evidence.reader = await assertBuiltInReader(page);
evidence.theme = await assertThemeToggle(page);
if (artifactDir) {
await page.screenshot({ path: path.join(artifactDir, "phase-6-offline-basic.png"), fullPage: true });
}
await page.close();
return roundObject(evidence);
}
async function runFixtureSmoke(browser, html, name) {
const page = await openOfflinePage(browser, html, { width: 1280, height: 860 });
const before = await layerTransform(page);
const point = await visibleNodeCenter(page);
await page.mouse.move(point.x, point.y);
await page.mouse.wheel(0, -360);
const after = await waitForLayerTransform(page, before);
assert.notEqual(after, before, `${name} offline graph should zoom over a node`);
const counts = await page.evaluate(() => ({
nodes: document.querySelectorAll(".node").length,
edges: document.querySelectorAll(".edge").length,
communities: document.querySelectorAll(".community-wash").length,
hasReactDrawer: Boolean(document.querySelector(".drawer-panel-open"))
}));
assert.ok(counts.nodes > 0, `${name} offline graph should render nodes`);
assert.equal(counts.hasReactDrawer, false, `${name} offline graph should not depend on the React host drawer`);
await page.close();
return { html, before, after, counts };
}
async function openOfflinePage(browser, html, viewport) {
const page = await browser.newPage({ viewport });
await page.addInitScript(() => {
window.__graphOfflinePhase6 = {
round(value) {
return Math.round(value * 1000) / 1000;
},
roundRect(rect) {
return {
left: this.round(rect.left),
top: this.round(rect.top),
right: this.round(rect.right),
bottom: this.round(rect.bottom),
width: this.round(rect.width),
height: this.round(rect.height)
};
},
relativeRect(rect, rootRect) {
return {
left: this.round(rect.left - rootRect.left),
top: this.round(rect.top - rootRect.top),
right: this.round(rect.right - rootRect.left),
bottom: this.round(rect.bottom - rootRect.top),
width: this.round(rect.width),
height: this.round(rect.height)
};
},
relativePoint(point, rootRect) {
return {
x: this.round(point.x - rootRect.left),
y: this.round(point.y - rootRect.top)
};
}
};
});
await page.goto(pathToFileURL(html).href);
await page.waitForSelector("[data-llm-wiki-graph-root='true']");
await page.waitForSelector("[data-viewport-layer='true']");
await page.waitForSelector(".node");
return page;
}
async function assertWheelZooms(page, point, label) {
const before = await layerTransform(page);
await page.mouse.move(point.x, point.y);
await page.mouse.wheel(0, -420);
const after = await waitForLayerTransform(page, before);
assert.notEqual(after, before, `offline wheel over ${label} should zoom`);
return { before, after, point: roundedPoint(point) };
}
async function assertWheelZoomsFromReset(page, pointFactory, label) {
await resetGraphView(page);
await waitForGraphMotionSettled(page);
const point = await pointFactory();
return assertWheelZooms(page, point, label);
}
async function hoverNodeAndMeasure(page, preferredId = null, targetOptions = {}) {
const target = await visibleNode(page, { ...targetOptions, preferredId });
const id = target.id;
await waitForNodeMotionSettled(page, id);
let lastProbe = null;
for (let attempt = 0; attempt < 5; attempt += 1) {
await movePointerAwayFromGraphObjects(page);
await waitForNodeMotionSettled(page, id);
const point = await nodeCenter(page, id);
await page.mouse.move(point.x, point.y, { steps: 4 });
await page.waitForTimeout(450);
const probe = await hoverProbe(page, id, point);
lastProbe = probe;
if (probe.preview.state === "open" && probe.preview.kind === "node" && probe.hit.closestNode === id) break;
}
assert.ok(
lastProbe?.preview.state === "open" && lastProbe.preview.kind === "node" && lastProbe.hit.closestNode === id,
`offline hover preview for ${id} should open after pointer enters node: ${JSON.stringify(lastProbe)}`
);
await page.locator(".graph-hover-preview-title").waitFor();
const measurement = await page.evaluate((id) => {
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
const node = document.querySelector(`.node[data-id="${CSS.escape(id)}"]`);
const preview = document.querySelector(".graph-hover-preview");
if (!root || !node || !preview) throw new Error("Missing hover measurement elements");
const rootRect = root.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
const previewRect = preview.getBoundingClientRect();
const nodeAnchor = {
x: nodeRect.left + nodeRect.width / 2,
y: nodeRect.top + nodeRect.height / 2
};
return {
target: {
id,
label: node.querySelector(".node-name")?.textContent || node.textContent?.trim() || id
},
nodeAnchor: window.__graphOfflinePhase6.relativePoint(nodeAnchor, rootRect),
preview: window.__graphOfflinePhase6.relativeRect(previewRect, rootRect),
offset: {
x: previewRect.left - nodeAnchor.x,
y: previewRect.bottom - nodeAnchor.y
}
};
}, id);
await assertBoxInsideViewport(page, ".graph-hover-preview", `offline hover preview for ${id}`);
await assertGraphRootNotScrolled(page, `offline hover preview for ${id}`);
return roundObject({ ...measurement, target });
}
async function hoverProbe(page, id, point) {
return page.evaluate(({ id, point }) => {
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
const node = document.querySelector(`.node[data-id="${CSS.escape(id)}"]`);
const preview = document.querySelector(".graph-hover-preview");
if (!root || !node || !preview) throw new Error("Missing hover probe elements");
const hit = document.elementFromPoint(point.x, point.y);
const nodeRect = node.getBoundingClientRect();
const rootRect = root.getBoundingClientRect();
return {
point: window.__graphOfflinePhase6.relativePoint(point, rootRect),
rootScroll: { left: root.scrollLeft, top: root.scrollTop },
nodeRect: window.__graphOfflinePhase6.relativeRect(nodeRect, rootRect),
nodeHovered: node.matches(":hover"),
hit: {
tag: hit?.tagName || "",
className: typeof hit?.className === "string" ? hit.className : hit?.className?.baseVal || "",
closestNode: hit?.closest?.(".node")?.dataset?.id || "",
closestWash: hit?.closest?.(".community-wash")?.dataset?.communityId || ""
},
preview: {
state: preview.dataset.state || "",
kind: preview.dataset.kind || "",
textLength: preview.textContent?.length || 0
}
};
}, { id, point });
}
async function movePointerAwayFromGraphObjects(page) {
const point = await findBlankPoint(page);
await page.mouse.move(point.x, point.y, { steps: 3 });
await page.waitForTimeout(80);
}
async function dragNodeAndMeasure(page, id) {
await waitForNodeMotionSettled(page, id);
const start = await nodeCenter(page, id);
const target = { x: start.x + 360, y: start.y + 28 };
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(target.x, target.y, { steps: 8 });
await page.waitForTimeout(80);
const during = await nodeCenter(page, id);
const pointerDistance = Math.hypot(during.x - target.x, during.y - target.y);
assert.ok(pointerDistance <= 48, `offline dragged node should stay under pointer, distance=${pointerDistance.toFixed(1)}`);
await page.mouse.up();
await page.waitForSelector(`.node[data-id="${cssString(id)}"][data-pinned="true"]`);
await page.waitForTimeout(140);
const after = await page.locator(`.node[data-id="${cssString(id)}"]`).evaluate((node) => {
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
if (!root) throw new Error("Missing root after drag");
const rootRect = root.getBoundingClientRect();
const rect = node.getBoundingClientRect();
return {
center: {
x: rect.left + rect.width / 2 - rootRect.left,
y: rect.top + rect.height / 2 - rootRect.top
},
pinned: node.dataset.pinned,
dragging: root.dataset.dragging || ""
};
});
assert.equal(after.pinned, "true", "offline drag should commit a pin");
assert.equal(after.dragging, "", "offline drag should not remain stuck");
return roundObject({ start, target, during, pointerDistance, after });
}
async function assertPinPersistsAfterReload(page, id) {
const key = await page.evaluate(() => window.__LLM_WIKI_GRAPH_PINS_KEY__ || "");
assert.notEqual(key, "", "offline graph pins key should be exposed");
const before = await page.evaluate((key) => JSON.parse(window.localStorage.getItem(key) || "{}"), key);
assert.ok(Object.keys(before).length >= 1, "offline drag should write at least one pin");
for (const pin of Object.values(before)) {
assert.equal(pin.coordinateSpace, "world", "offline persisted pins should be explicit world coordinates");
}
await page.reload();
await page.waitForSelector("[data-llm-wiki-graph-root='true']");
await page.waitForSelector(`.node[data-id="${cssString(id)}"][data-pinned="true"]`);
const pinnedCount = await page.locator("[data-llm-wiki-graph-root='true']").evaluate((root) => root.dataset.pinnedCount || "");
assert.equal(pinnedCount, "1", "offline pin should survive reload");
const unmarkedWorldPin = { x: 333, y: 222 };
const pinKey = await nodePinKey(page, id);
await page.evaluate(({ key, id, pin }) => {
window.localStorage.setItem(key, JSON.stringify({
[id]: pin
}));
}, { key, id: pinKey, pin: unmarkedWorldPin });
await page.reload();
await page.waitForSelector("[data-llm-wiki-graph-root='true']");
await page.waitForSelector(`.node[data-id="${cssString(id)}"][data-pinned="true"]`);
await resetGraphView(page);
await page.waitForTimeout(120);
const unmarkedWorldCenter = await page.locator(`.node[data-id="${cssString(id)}"]`).evaluate((node) => {
return {
worldPoint: {
x: Number(node.dataset.worldX || node.dataset.liveX || Number.NaN),
y: Number(node.dataset.worldY || node.dataset.liveY || Number.NaN)
},
pinned: node.dataset.pinned || ""
};
});
assert.equal(unmarkedWorldCenter.pinned, "true", "offline unmarked localStorage pin should reload as pinned");
const worldDistance = Math.hypot(
unmarkedWorldCenter.worldPoint.x - unmarkedWorldPin.x,
unmarkedWorldCenter.worldPoint.y - unmarkedWorldPin.y
);
assert.ok(
worldDistance <= 0.01,
`offline unmarked localStorage pin should stay in world coordinates, distance=${worldDistance.toFixed(2)}`
);
return { key, storedPins: before, pinnedCount, unmarkedWorldPin, pinKey, unmarkedWorldCenter, worldDistance };
}
async function assertBuiltInReader(page, id) {
await resetGraphView(page);
const target = id ? await visibleNode(page, { preferredId: id }) : await visibleNode(page);
await clickNodeByPointer(page, target.id);
await page.waitForSelector(".graph-reader[data-state='open']");
await page.locator(".graph-reader-title", { hasText: target.label }).waitFor();
await page.locator(".graph-reader-body").waitFor();
assert.equal(await page.locator(".drawer-panel-open").count(), 0, "offline graph should not open the React host drawer");
const snapshot = await page.locator(".graph-reader[data-state='open']").evaluate((reader) => ({
title: reader.querySelector(".graph-reader-title")?.textContent || "",
bodyLength: reader.querySelector(".graph-reader-body")?.textContent?.length || 0,
hasHostDrawer: Boolean(document.querySelector(".drawer-panel-open"))
}));
await page.keyboard.press("Escape");
await page.waitForSelector(".graph-reader[data-state='closed']");
return { ...snapshot, target };
}
async function assertThemeToggle(page) {
const before = await graphTheme(page);
await page.getByRole("button", { name: before === "shan-shui" ? "切换墨夜主题" : "切换山水主题" }).click();
const after = await waitForGraphThemeChange(page, before);
await page.getByRole("button", { name: after === "shan-shui" ? "切换墨夜主题" : "切换山水主题" }).click();
const restored = await waitForGraphThemeChange(page, after);
assert.equal(restored, before, "offline theme toggle should restore the original theme");
return { before, after, restored };
}
async function assertGraphRootScrollResets(page) {
const before = await graphRootScroll(page);
await page.locator("[data-llm-wiki-graph-root='true']").evaluate((root) => {
root.scrollLeft = 120;
root.scrollTop = 240;
});
await page.waitForFunction(() => {
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
return root && root.scrollLeft === 0 && root.scrollTop === 0;
});
const after = await graphRootScroll(page);
assert.deepEqual(after, { left: 0, top: 0 }, "offline graph root native scroll should reset to zero");
return { before, after };
}
async function assertGraphRootNotScrolled(page, label) {
const scroll = await graphRootScroll(page);
assert.deepEqual(scroll, { left: 0, top: 0 }, `${label}: graph root native scroll should stay at zero`);
}
async function resetGraphView(page) {
const before = await layerTransform(page);
await page.getByRole("button", { name: "回全图" }).click();
await page.waitForTimeout(220);
const after = await layerTransform(page);
if (after === before) await page.waitForTimeout(120);
}
async function layerTransform(page) {
return page.locator("[data-viewport-layer='true']").evaluate((element) => element.style.transform);
}
async function waitForLayerTransform(page, previous) {
await page.waitForFunction(
(previous) => document.querySelector("[data-viewport-layer='true']")?.style.transform !== previous,
previous,
{ timeout: 3000 }
);
return layerTransform(page);
}
async function nodeCenter(page, id) {
return page.locator(`.node[data-id="${cssString(id)}"]`).evaluate((node) => {
const rect = node.getBoundingClientRect();
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
};
});
}
async function nodePinKey(page, id) {
return page.evaluate((id) => {
const dataEl = document.getElementById("graph-data");
const graphData = dataEl?.textContent ? JSON.parse(dataEl.textContent) : null;
const node = graphData?.nodes?.find((item) => item.id === id);
if (!node) return id;
const existing = node.source_path || node.path || node.source;
if (existing) return String(existing);
const nodeId = String(node.id || id);
const baseId = nodeId.endsWith(".md") ? nodeId.slice(0, -3) : nodeId;
const type = String(node.type || "");
const directory = type === "topic"
? "topics"
: type === "source"
? "sources"
: type === "comparison"
? "comparisons"
: type === "synthesis"
? "synthesis"
: type === "query"
? "queries"
: "entities";
return `wiki/${directory}/${baseId}.md`;
}, id);
}
async function visibleNodeCenter(page, preferredId = null, options = {}) {
const targetOptions = typeof preferredId === "object" && preferredId !== null
? preferredId
: { ...options, preferredId };
const node = await visibleNode(page, targetOptions);
return node.center;
}
async function visibleNode(page, options = {}) {
const {
preferredId = null,
roomLeft = 0,
roomRight = 0,
roomTop = 0,
roomBottom = 0,
preferCenter = false
} = options;
await waitForGraphMotionSettled(page);
const candidate = await page.evaluate((options) => {
const { preferredId, roomLeft, roomRight, roomTop, roomBottom, preferCenter } = options;
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
if (!root) throw new Error("Missing graph root");
const rootRect = root.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const minX = Math.max(rootRect.left + roomLeft, 0);
const maxX = Math.min(rootRect.right - roomRight, viewportWidth);
const minY = Math.max(rootRect.top + roomTop, 0);
const maxY = Math.min(rootRect.bottom - roomBottom, viewportHeight);
const ideal = {
x: minX + Math.max(0, maxX - minX) / 2,
y: minY + Math.max(0, maxY - minY) / 2
};
const nodes = Array.from(document.querySelectorAll(".node"));
const measurements = nodes.map((node) => {
const rect = node.getBoundingClientRect();
const center = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
};
const hit = document.elementFromPoint(center.x, center.y);
const centerHitsNode = hit?.closest?.(".node") === node;
const visibleLeft = Math.max(rect.left, minX);
const visibleRight = Math.min(rect.right, maxX);
const visibleTop = Math.max(rect.top, minY);
const visibleBottom = Math.min(rect.bottom, maxY);
const visibleArea = Math.max(0, visibleRight - visibleLeft) * Math.max(0, visibleBottom - visibleTop);
const centerInViewport = center.x >= rootRect.left
&& center.x >= minX
&& center.x <= maxX
&& center.y >= minY
&& center.y <= maxY;
const centerDistance = Math.hypot(center.x - ideal.x, center.y - ideal.y);
return {
id: node.dataset.id || "",
label: node.querySelector(".node-name")?.textContent || node.textContent?.trim() || node.dataset.id || "",
center,
rect: window.__graphOfflinePhase6.relativeRect(rect, rootRect),
visibleArea,
centerDistance,
centerHitsNode,
centerInViewport
};
}).filter((item) => item.id && item.visibleArea > 0);
const preferred = preferredId
? measurements.find((item) => item.id === preferredId && item.centerInViewport && item.centerHitsNode)
: null;
if (preferred) return preferred;
const best = measurements
.filter((item) => item.centerInViewport && item.centerHitsNode)
.sort((a, b) => preferCenter
? a.centerDistance - b.centerDistance || b.visibleArea - a.visibleArea
: b.visibleArea - a.visibleArea || a.centerDistance - b.centerDistance
)[0];
if (best) return best;
throw new Error(`Could not find an interactable visible node: ${JSON.stringify(measurements)}`);
}, { preferredId, roomLeft, roomRight, roomTop, roomBottom, preferCenter });
return roundObject(candidate);
}
async function clickNodeByPointer(page, id) {
const node = await visibleNode(page, { preferredId: id });
await movePointerAwayFromGraphObjects(page);
await page.mouse.move(node.center.x, node.center.y, { steps: 4 });
await page.mouse.down();
await page.mouse.up();
return node;
}
async function waitForGraphMotionSettled(page) {
const firstId = await firstNodeId(page);
await waitForNodeMotionSettled(page, firstId);
}
async function waitForNodeMotionSettled(page, id) {
let previous = await nodeCenter(page, id);
for (let sample = 0; sample < 12; sample += 1) {
await page.waitForTimeout(140);
const next = await nodeCenter(page, id);
const distance = Math.hypot(next.x - previous.x, next.y - previous.y);
if (distance <= 1.5) return;
previous = next;
}
}
async function firstNodeId(page) {
return page.locator(".node").first().evaluate((node) => node.dataset.id || "");
}
async function findBlankPoint(page) {
return page.evaluate(() => {
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
if (!root) throw new Error("Missing graph root");
const rect = root.getBoundingClientRect();
const blocked = ".node,.community-wash,.edge,.graph-toolbar,.mini-map,.graph-search,.graph-reader,.graph-selection-panel";
const xs = [0.08, 0.16, 0.28, 0.42, 0.58, 0.74, 0.88];
const ys = [0.18, 0.32, 0.48, 0.64, 0.8, 0.9];
for (const ry of ys) {
for (const rx of xs) {
const x = rect.left + rect.width * rx;
const y = rect.top + rect.height * ry;
const hit = document.elementFromPoint(x, y);
if (!hit || !root.contains(hit)) continue;
if (hit.closest(blocked)) continue;
return { x, y };
}
}
throw new Error("Could not find blank graph point");
});
}
async function findCommunityWashPoint(page, communityId) {
return page.evaluate((communityId) => {
const wash = document.querySelector(`.community-wash[data-community-id="${CSS.escape(communityId)}"]`) || document.querySelector(".community-wash");
if (!wash) throw new Error("Missing community wash");
const rect = wash.getBoundingClientRect();
const candidates = [
[0.5, 0.18],
[0.2, 0.5],
[0.8, 0.5],
[0.5, 0.82],
[0.32, 0.32],
[0.68, 0.68],
[0.5, 0.5]
];
for (const [rx, ry] of candidates) {
const x = rect.left + rect.width * rx;
const y = rect.top + rect.height * ry;
if (document.elementFromPoint(x, y)?.closest?.(".community-wash") === wash) {
return { x, y, communityId: wash.dataset.communityId || "" };
}
}
throw new Error("Could not find exposed community wash point");
}, communityId);
}
async function assertBoxInsideViewport(page, selector, label) {
const box = await page.locator(selector).first().boundingBox();
assert.ok(box, `${label}: ${selector} should have a bounding box`);
const viewport = page.viewportSize();
assert.ok(viewport, `${label}: viewport should be available`);
assert.ok(box.x >= -1, `${label}: ${selector} should not overflow left`);
assert.ok(box.y >= -1, `${label}: ${selector} should not overflow top`);
assert.ok(box.x + box.width <= viewport.width + 1, `${label}: ${selector} should not overflow right`);
assert.ok(box.y + box.height <= viewport.height + 1, `${label}: ${selector} should not overflow bottom`);
}
async function graphRootScroll(page) {
return page.locator("[data-llm-wiki-graph-root='true']").evaluate((root) => ({
left: root.scrollLeft,
top: root.scrollTop
}));
}
async function graphTheme(page) {
return page.locator(".llm-wiki-graph-engine").evaluate((root) => root.dataset.theme || "");
}
async function waitForGraphThemeChange(page, previous) {
await page.waitForFunction((previous) => {
const next = document.querySelector(".llm-wiki-graph-engine")?.dataset.theme || "";
return next && next !== previous;
}, previous);
return graphTheme(page);
}
function assertHoverStaysNearAnchor(measurement, message) {
const preview = measurement.preview;
const anchor = measurement.nodeAnchor;
const horizontalGap = preview.left > anchor.x
? preview.left - anchor.x
: anchor.x > preview.right
? anchor.x - preview.right
: 0;
const verticalGap = preview.top > anchor.y
? preview.top - anchor.y
: anchor.y > preview.bottom
? anchor.y - preview.bottom
: 0;
assert.ok(horizontalGap <= 180, `${message}: horizontal gap ${horizontalGap.toFixed(2)}px`);
assert.ok(verticalGap <= 140, `${message}: vertical gap ${verticalGap.toFixed(2)}px`);
}
function cssString(value) {
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function roundedPoint(point) {
return {
x: round(point.x),
y: round(point.y)
};
}
function roundObject(value) {
if (Array.isArray(value)) return value.map(roundObject);
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, roundObject(item)]));
}
if (typeof value === "number") return round(value);
return value;
}
function round(value) {
return Math.round(value * 1000) / 1000;
}