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('
'); 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; investigationDiagramPlugins.mermaid.getMermaid = (config: unknown) => { const instance = renderer(config); return { ...instance, async render(id: string, source: string) { renderPending = true; await new Promise((resolve) => { release = resolve; }); renderPending = false; return instance.render(id, source); } }; }; 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, 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(); 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"); });