Keep investigation graphs stable during updates, defer expensive chart rendering, and prevent stale results when switching tasks. Adopt reference annotations and consolidate frontend documentation.
154 lines
10 KiB
TypeScript
154 lines
10 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import { mockAgentApi } from "./support/mock-agent-api";
|
|
|
|
test("调查图加载与尺寸变化保持阅读位置,刷新后仍可缩放和查看节点", async ({ page }, testInfo) => {
|
|
await page.route("http://127.0.0.1:8787/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
|
|
await mockAgentApi(page);
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.setContent('<!doctype html><html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="reader" style="max-width:640px;margin:auto;padding:16px;height:100vh;overflow:auto"></main></body></html>');
|
|
await page.evaluate(async () => {
|
|
const entry = await (await fetch("/src/main.tsx")).text();
|
|
const reactUrl = entry.match(/from "([^"\n]*\/react\.js[^"\n]*)"/)?.[1];
|
|
const clientUrl = entry.match(/from "([^"\n]*\/react-dom_client\.js[^"\n]*)"/)?.[1];
|
|
if (!reactUrl || !clientUrl) throw new Error("Vite React module paths unavailable");
|
|
const reactModule = await import(reactUrl);
|
|
const React = reactModule.default ?? reactModule;
|
|
const clientModule = await import(clientUrl);
|
|
const { createRoot } = clientModule.default ?? clientModule;
|
|
const { InvestigationDiagramView } = await import("/src/features/workbench/components/investigation-diagram-view.tsx");
|
|
const { investigationDiagramPlugins } = await import("/src/features/workbench/utils/investigation-diagram-style.ts");
|
|
const renderer = investigationDiagramPlugins.mermaid.getMermaid.bind(investigationDiagramPlugins.mermaid);
|
|
let release: (() => void) | undefined;
|
|
let renderPending = false;
|
|
let holdRender = true;
|
|
let completedRenders = 0;
|
|
investigationDiagramPlugins.mermaid.getMermaid = (config: unknown) => {
|
|
const instance = renderer(config);
|
|
return { ...instance, async render(id: string, source: string) {
|
|
if (holdRender) {
|
|
renderPending = true;
|
|
await new Promise<void>((resolve) => { release = resolve; });
|
|
renderPending = false;
|
|
}
|
|
const result = await instance.render(id, source);
|
|
completedRenders++;
|
|
return result;
|
|
} };
|
|
};
|
|
const root = createRoot(document.getElementById("reader"));
|
|
const documentHeights: number[] = [];
|
|
new MutationObserver(() => {
|
|
documentHeights.push(document.documentElement.scrollHeight);
|
|
}).observe(document.body, { childList: true, subtree: true, attributes: true });
|
|
let source = "flowchart TD\nA[读取工单] --> B[核对监测]\nB --> C[现场采样]\nC --> D[关联证据]\nD --> E[发布报告]";
|
|
const render = () => root.render(React.createElement(React.Fragment, null,
|
|
React.createElement(InvestigationDiagramView, {
|
|
diagram: { title: "调查刷新检查", mermaid: source,
|
|
node_refs: [{ node_id: "B", label: "核对监测", evidence_refs: ["e1"], action_refs: [] }] },
|
|
renderDetails: () => React.createElement("p", null, "监测记录的引用依据")
|
|
}),
|
|
React.createElement("section", { id: "reading-anchor", style: { height: 1200 } }, "当前正在阅读的证据")
|
|
));
|
|
Object.assign(window, { investigationFixture: {
|
|
release: () => release?.(), pending: () => renderPending,
|
|
hold: (value: boolean) => { holdRender = value; },
|
|
completed: () => completedRenders,
|
|
update: (next: string) => { source = next; render(); }
|
|
}, documentHeights });
|
|
render();
|
|
});
|
|
const fixture = (action: "release" | "pending") => page.evaluate((key) => {
|
|
const api = (window as unknown as { investigationFixture: { release: () => void; pending: () => boolean } }).investigationFixture;
|
|
return api[key]();
|
|
}, action);
|
|
const diagram = page.getByRole("region", { name: "调查脉络", exact: true });
|
|
const viewport = diagram.locator(".investigation-diagram");
|
|
await expect.poll(() => fixture("pending")).toBe(true);
|
|
const loadingHeight = (await viewport.boundingBox())!.height;
|
|
await fixture("release");
|
|
await expect(diagram.locator("svg .node")).toHaveCount(5);
|
|
const renderHeights = await page.evaluate(() => (window as unknown as { documentHeights: number[] }).documentHeights);
|
|
expect(Math.max(...renderHeights) - Math.min(...renderHeights)).toBeLessThanOrEqual(1);
|
|
const loadedHeight = (await viewport.boundingBox())!.height;
|
|
testInfo.annotations.push({ type: "geometry", description: `loading=${loadingHeight}, loaded=${loadedHeight}` });
|
|
expect(loadedHeight).toBeGreaterThan(loadingHeight);
|
|
// Read below the graph: native scroll anchoring must preserve that content,
|
|
// while the graph is free to take its real (different) height.
|
|
await page.locator("#reading-anchor").evaluate((element) => element.scrollIntoView({ block: "start" }));
|
|
const anchorTop = await page.locator("#reading-anchor").evaluate((element) => element.getBoundingClientRect().top);
|
|
await page.evaluate(() => (window as unknown as { investigationFixture: { update: (source: string) => void } }).investigationFixture
|
|
.update("flowchart LR\nA[读取工单] --> B[核对监测]"));
|
|
await expect.poll(() => fixture("pending")).toBe(true);
|
|
await expect(diagram.locator("svg .node")).toHaveCount(5);
|
|
await fixture("release");
|
|
await expect(diagram.locator("svg .node")).toHaveCount(2);
|
|
expect((await viewport.boundingBox())!.height).toBeLessThan(loadedHeight - 100);
|
|
expect(Math.abs(await page.locator("#reading-anchor").evaluate((element) => element.getBoundingClientRect().top) - anchorTop)).toBeLessThanOrEqual(1);
|
|
await page.locator("#reader").evaluate((element) => { element.scrollTop = 0; });
|
|
await diagram.locator(".node").filter({ hasText: "核对监测" }).click();
|
|
await expect(diagram.getByLabel("节点详情 核对监测")).toContainText("监测记录的引用依据");
|
|
await diagram.getByRole("button", { name: "Zoom in", exact: true }).click();
|
|
await expect(diagram.locator('[role="application"]')).toHaveCSS("transform", "matrix(1.1, 0, 0, 1.1, 0, 0)");
|
|
await diagram.getByRole("button", { name: "Reset zoom and pan", exact: true }).click();
|
|
// A pending layout must not overwrite a newer source restored from cache.
|
|
await page.evaluate(() => {
|
|
const api = (window as unknown as { investigationFixture: { update: (source: string) => void } }).investigationFixture;
|
|
api.update("flowchart LR\nA[读取工单] --> B[核对监测] --> C[迟到的排版]");
|
|
});
|
|
await expect.poll(() => fixture("pending")).toBe(true);
|
|
await page.evaluate(() => {
|
|
const api = (window as unknown as { investigationFixture: { hold: (value: boolean) => void; update: (source: string) => void } }).investigationFixture;
|
|
api.hold(false);
|
|
api.update("flowchart LR\nA[读取工单] --> B[核对监测]");
|
|
});
|
|
await expect.poll(() => page.evaluate(() => (window as unknown as { investigationFixture: { completed: () => number } }).investigationFixture.completed())).toBe(3);
|
|
await fixture("release");
|
|
await expect.poll(() => page.evaluate(() => (window as unknown as { investigationFixture: { completed: () => number } }).investigationFixture.completed())).toBe(4);
|
|
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
|
|
await expect(diagram.locator("svg .node")).toHaveCount(2);
|
|
const checkCollapse = async (keyboard: boolean) => {
|
|
const before = await page.locator("#reading-anchor").evaluate((element) => element.getBoundingClientRect().top);
|
|
await diagram.getByRole("button", { name: "放大查看", exact: true }).click();
|
|
const detail = page.getByRole("dialog", { name: "调查刷新检查", exact: true });
|
|
await expect(detail.locator("svg .node")).toHaveCount(2);
|
|
await page.evaluate(() => {
|
|
const reader = document.getElementById("reader")!;
|
|
const anchor = document.getElementById("reading-anchor")!;
|
|
const frames: { top: number; scroll: number; height: number }[] = [];
|
|
Object.assign(window, { collapseFrames: frames, collapseDone: false });
|
|
const start = performance.now();
|
|
const sample = () => {
|
|
frames.push({ top: anchor.getBoundingClientRect().top, scroll: reader.scrollTop, height: reader.scrollHeight });
|
|
if (performance.now() - start < 800) requestAnimationFrame(sample);
|
|
else Object.assign(window, { collapseDone: true });
|
|
};
|
|
sample();
|
|
});
|
|
if (keyboard) await page.keyboard.press("Escape");
|
|
else await detail.getByRole("button", { name: "关闭详情", exact: true }).click();
|
|
await expect(detail).toHaveCount(0);
|
|
await page.waitForFunction(() => (window as unknown as { collapseDone: boolean }).collapseDone);
|
|
const frames = await page.evaluate(() => (window as unknown as { collapseFrames: { top: number; scroll: number; height: number }[] }).collapseFrames);
|
|
const range = (key: "top" | "scroll" | "height") => Math.max(...frames.map((frame) => frame[key])) - Math.min(...frames.map((frame) => frame[key]));
|
|
testInfo.annotations.push({ type: "collapse-geometry", description: `keyboard=${keyboard}, anchorShift=${range("top")}, scrollShift=${range("scroll")}, heightShift=${range("height")}` });
|
|
expect(Math.abs(frames[0].top - before)).toBeLessThanOrEqual(1);
|
|
expect(range("top")).toBeLessThanOrEqual(1);
|
|
expect(range("scroll")).toBeLessThanOrEqual(1);
|
|
expect(range("height")).toBeLessThanOrEqual(1);
|
|
expect(await diagram.locator(":scope > .flow-root").evaluate((element) => (element as HTMLElement).style.height)).toBe("");
|
|
};
|
|
await checkCollapse(false);
|
|
const allRenderHeights = await page.evaluate(() => (window as unknown as { documentHeights: number[] }).documentHeights);
|
|
expect(Math.max(...allRenderHeights) - Math.min(...allRenderHeights)).toBeLessThanOrEqual(1);
|
|
testInfo.annotations.push({ type: "page-height", description: `min=${Math.min(...allRenderHeights)}, max=${Math.max(...allRenderHeights)}` });
|
|
await diagram.screenshot({ path: testInfo.outputPath("diagram-stable-desktop.png") });
|
|
await page.setViewportSize({ width: 375, height: 800 });
|
|
await expect(diagram.locator("svg .node")).toHaveCount(2);
|
|
await expect(diagram.getByRole("button", { name: "放大查看", exact: true })).toBeVisible();
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
|
|
await diagram.screenshot({ path: testInfo.outputPath("diagram-stable-mobile.png") });
|
|
await checkCollapse(true);
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
await expect(diagram.locator('[role="img"] > svg')).toHaveCSS("animation-name", "none");
|
|
});
|