import { expect, test, type Page, type TestInfo } from "@playwright/test"; import { mockInvestigationPresentation } from "./support/mock-investigation-presentation"; const labels = ["点标注", "线标注", "范围标注"]; type ButtonFrame = { label: string; sameNode: boolean; isConnected: boolean; hovered: boolean; pressed: string | null; disabled: boolean; background: string; color: string; opacity: string; rect: { x: number; y: number; width: number; height: number }; icon: { background: string; color: string; transitions: string[] }; svgColor: string; }; type Frame = { elapsed: number; buttons: ButtonFrame[] }; type SamplingProbe = { frames: Frame[]; done: Promise; finishAction: () => number }; async function startSampling(page: Page) { await page.evaluate((labels) => { const toolbar = [...document.querySelectorAll('[role="toolbar"][aria-label="绘制工具"]')] .find((node) => node.getBoundingClientRect().width > 0)!; const find = (label: string) => [...toolbar.querySelectorAll("button")].find((button) => button.textContent?.trim() === label)!; const initial = labels.map(find); const frames: Frame[] = []; const started = performance.now(); let actionFinishedAt: number | null = null; let finish: () => void; const done = new Promise((resolve) => { finish = resolve; }); const read = () => { const elapsed = performance.now() - started; frames.push({ elapsed, buttons: labels.map((label, index) => { const button = find(label); const icon = button.querySelector(":scope > span")!; const svg = icon.querySelector("svg")!; const style = getComputedStyle(button); const iconStyle = getComputedStyle(icon); const bounds = button.getBoundingClientRect(); return { label, sameNode: button === initial[index], isConnected: button.isConnected, hovered: button.matches(":hover"), pressed: button.getAttribute("aria-pressed"), disabled: button.disabled, background: style.backgroundColor, color: style.color, opacity: style.opacity, rect: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, icon: { background: iconStyle.backgroundColor, color: iconStyle.color, transitions: icon.getAnimations().filter((animation) => animation instanceof CSSTransition).map((animation) => (animation as CSSTransition).transitionProperty) }, svgColor: getComputedStyle(svg).color }; }) }); // Actionability waits can outlast 500ms; keep every frame through the action // and at least one full 150ms color transition after it has completed. if (elapsed < 500 || actionFinishedAt === null || elapsed < actionFinishedAt + 200) requestAnimationFrame(read); else finish(); }; Object.assign(window, { annotationHoverProbe: { frames, done, finishAction: () => { actionFinishedAt = performance.now() - started; return actionFinishedAt; } } }); read(); }, labels); } async function collectSampling(page: Page): Promise<{ frames: Frame[]; actionFinishedAt: number }> { return page.evaluate(async () => { const probe = (window as unknown as { annotationHoverProbe: SamplingProbe }).annotationHoverProbe; const actionFinishedAt = probe.finishAction(); await probe.done; return { frames: probe.frames, actionFinishedAt }; }); } async function screenshot(page: Page, testInfo: TestInfo, name: string) { const panel = page.getByRole("region", { name: "标注", exact: true }); await testInfo.attach(name, { body: await panel.screenshot({ path: testInfo.outputPath(`${name}.png`), animations: "allow" }), contentType: "image/png" }); } test.use({ reducedMotion: "no-preference", hasTouch: false }); for (const width of [1440, 375]) { test(`annotation icons track button colors through hover, selection, cancellation and rapid reselection at ${width}px`, async ({ page }, testInfo) => { test.setTimeout(60_000); await page.setViewportSize({ width, height: 900 }); await mockInvestigationPresentation(page, []); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit"))); await page.getByRole("button", { name: "标注:管理地图标注", exact: true }).click(); const panel = page.getByRole("region", { name: "标注", exact: true }); const point = panel.getByRole("button", { name: "点标注", exact: true }); const line = panel.getByRole("button", { name: "线标注", exact: true }); const polygon = panel.getByRole("button", { name: "范围标注", exact: true }); await expect(point).toBeEnabled(); await point.click({ trial: true }); await page.evaluate(() => document.fonts.ready); await page.mouse.move(1, 1); const sample = async (name: string, action: () => Promise, selected: string | null, capture = false) => { if (capture) await screenshot(page, testInfo, `${name}-before`); await startSampling(page); await action(); if (capture) await screenshot(page, testInfo, `${name}-during`); const { frames, actionFinishedAt } = await collectSampling(page); expect(frames.at(-1)!.elapsed - actionFinishedAt, `${name}: sampling must cover at least 200ms after the completed action`).toBeGreaterThanOrEqual(200); const failures = frames.flatMap((frame) => frame.buttons.flatMap((button, index) => { const initial = frames[0].buttons[index]; const shifted = (Object.keys(button.rect) as Array).some((key) => Math.abs(button.rect[key] - initial.rect[key]) > 0.5); return !button.sameNode || !button.isConnected || shifted || button.disabled !== initial.disabled || button.opacity !== "1" || button.svgColor !== button.color || button.icon.color !== button.color ? [{ elapsed: frame.elapsed, ...button }] : []; })); if (failures.length) await testInfo.attach(`${name}-failed-frames`, { body: JSON.stringify(failures.slice(0, 12), null, 2), contentType: "application/json" }); expect(failures, `${name}: icon colors, DOM identity, geometry and enabled state must remain in sync on every animation frame`).toEqual([]); for (const button of frames.at(-1)!.buttons) expect(button.pressed).toBe(String(button.label === selected)); if (name.startsWith("hover-")) for (let index = 0; index < labels.length; index++) { const changes = frames.slice(1).filter((frame, frameIndex) => frame.buttons[index].hovered !== frames[frameIndex].buttons[index].hovered).length; expect(changes, "a single pointer entry or exit must not make hover blink repeatedly").toBeLessThanOrEqual(1); } if (capture) await screenshot(page, testInfo, `${name}-after`); }; await sample("hover-point-first", () => point.hover(), null); await sample("hover-line-first", () => line.hover(), null); await sample("hover-point-repeat", () => point.hover(), null); await sample("hover-line-repeat", () => line.hover(), null); await sample("select-point", () => point.click(), "点标注"); await sample("select-line-stay", () => line.click(), "线标注", true); await sample("select-polygon-stay", () => polygon.click(), "范围标注"); await sample("cancel-polygon-stay", () => polygon.click(), null); await sample("select-point-again", () => point.click(), "点标注"); await sample("select-line-return-to-point", async () => { await line.click(); await point.hover(); }, "线标注"); await sample("cancel-line-return-to-point", async () => { await line.click(); await point.hover(); }, null); await sample("rapid-reselection", async () => { await point.click(); await line.click(); await point.click(); }, "点标注", true); }); }