131 lines
7.8 KiB
TypeScript
131 lines
7.8 KiB
TypeScript
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<void>; finishAction: () => number };
|
|
|
|
async function startSampling(page: Page) {
|
|
await page.evaluate((labels) => {
|
|
const toolbar = [...document.querySelectorAll<HTMLElement>('[role="toolbar"][aria-label="绘制工具"]')]
|
|
.find((node) => node.getBoundingClientRect().width > 0)!;
|
|
const find = (label: string) => [...toolbar.querySelectorAll<HTMLButtonElement>("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<void>((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<HTMLElement>(":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<unknown>, 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<keyof ButtonFrame["rect"]>).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);
|
|
});
|
|
}
|