first
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user