661 lines
27 KiB
JavaScript
661 lines
27 KiB
JavaScript
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;
|
|
}
|