Files
next-tjwater-frontend/tests/browser/map-notice-stability.e2e.ts
T
jiang b948551c1f
Generic Container CI/CD / test-build-publish (push) Successful in 1m13s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m13s
fix(map): stabilize notifications during map navigation
Ignore obsolete tile cancellations and update source notices without dismissing their existing toast. Keep notice input above the task ticker and disable swipe removal.

The remaining vertical jitter came from Sonner exit offsets being applied on top of flow positioning. Let flow own geometry and retain opacity transitions; add frame-sampled pan and recovery regression coverage.
2026-11-08 03:23:00 +08:00

194 lines
11 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
import { mockInvestigationPresentation } from "./support/mock-investigation-presentation";
test.beforeEach(async ({ page }) => {
test.setTimeout(60_000);
await mockInvestigationPresentation(page, []);
});
async function openMap(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
}
async function clearInitialNotices(page: Page) {
await page.evaluate(async () => {
const path = "/src/features/map/core/components/notice-actions.tsx";
(await import(path)).dismissMapNotice();
});
await expect(page.locator("[data-sonner-toast]")).toHaveCount(0);
}
test("tile cancellation events during map navigation do not announce source outages", async ({ page }) => {
await openMap(page);
await clearInitialNotices(page);
await page.evaluate(() => {
const map = window.__waterNetworkMap!;
Object.assign(window, { noticeTileAborts: 0, noticeFalseOutages: 0 });
map.on("sourcedataabort", () => {
const state = window as unknown as { noticeTileAborts: number };
state.noticeTileAborts++;
});
const observer = new MutationObserver((records) => {
for (const record of records) for (const node of record.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
for (const notice of [node, ...node.querySelectorAll("[data-sonner-toast]")]) {
if (notice.matches("[data-sonner-toast]") && notice.textContent?.includes("GeoServer MVT 降级运行")) {
(window as unknown as { noticeFalseOutages: number }).noticeFalseOutages++;
}
}
}
});
observer.observe(document.body, { subtree: true, childList: true });
map.jumpTo({ zoom: 14 });
});
for (let i = 0; i < 3; i++) {
await page.mouse.move(220, 420);
await page.mouse.down();
await page.mouse.move(i % 2 ? 450 : 750, 450, { steps: 5 });
await page.mouse.up();
// Deliver the same cancellation event MapLibre emits for obsolete tiles,
// independently of cache timing or how quickly the fixture responds.
await page.evaluate(() => window.__waterNetworkMap!.fire("sourcedataabort", { sourceId: "pipes" }));
await page.waitForTimeout(150);
}
await expect.poll(() => page.evaluate(() => (window as unknown as { noticeTileAborts: number }).noticeTileAborts)).toBeGreaterThan(0);
expect(await page.evaluate(() => (window as unknown as { noticeFalseOutages: number }).noticeFalseOutages)).toBe(0);
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 降级运行" })).toHaveCount(0);
});
test("a failed map source recovers in the same visible notice without entering removal", async ({ page }) => {
await openMap(page);
await page.evaluate(() => window.__waterNetworkMap!.fire("sourcedata", { sourceId: "pipes", isSourceLoaded: true }));
await page.waitForTimeout(350);
await clearInitialNotices(page);
await page.evaluate(() => window.__waterNetworkMap!.fire("error", { sourceId: "pipes", error: new Error("notice-regression-test") }));
const notice = page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 不可用" });
await expect(notice).toBeVisible();
await expect(notice).toHaveCSS("opacity", "1");
const node = await notice.elementHandle();
await page.evaluate(() => window.__waterNetworkMap!.fire("sourcedata", { sourceId: "pipes", isSourceLoaded: true }));
await page.waitForTimeout(350);
expect(await node!.evaluate((element) => ({
connected: element.isConnected,
removed: element.getAttribute("data-removed"),
text: element.textContent
}))).toMatchObject({ connected: true, removed: "false", text: expect.stringContaining("GeoServer MVT 已连接") });
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 已连接" })).toBeVisible();
});
test("notice surfaces capture input above the task ticker while the outside map remains draggable", async ({ page }, testInfo) => {
await openMap(page);
await clearInitialNotices(page);
await page.evaluate(async () => {
const path = "/src/features/map/core/components/notice-actions.tsx";
(await import(path)).showMapNotice({ id: "notice-input-regression", tone: "warning", title: "通知交互回归",
message: "通知覆盖任务浮动条,滚轮与拖动由通知接收。", actionLabel: "查看任务", onAction: () => {} });
});
const notice = page.locator("[data-sonner-toast]").filter({ hasText: "通知交互回归" });
await expect(notice).toHaveCSS("opacity", "1");
const ticker = page.locator(".agent-task-ticker");
await expect(ticker).toBeVisible();
const point = await page.evaluate(() => {
const ticker = document.querySelector(".agent-task-ticker")!;
const r = ticker.getBoundingClientRect();
Object.assign(window, { noticeTickerWheelEvents: 0 });
ticker.addEventListener("wheel", () => {
(window as unknown as { noticeTickerWheelEvents: number }).noticeTickerWheelEvents++;
});
return { x: r.x + 170, y: r.y + 25 };
});
expect(await notice.evaluate((element, point) => element.contains(document.elementFromPoint(point.x, point.y)), point)).toBe(true);
const before = await notice.boundingBox();
await page.mouse.move(point.x, point.y);
await page.mouse.wheel(0, 120);
await page.mouse.down();
await page.mouse.move(point.x + 35, point.y + 15, { steps: 4 });
await page.mouse.up();
await expect(notice).toHaveCount(1);
expect(await notice.boundingBox()).toEqual(before);
expect(await page.evaluate(() => (window as unknown as { noticeTickerWheelEvents: number }).noticeTickerWheelEvents)).toBe(0);
const center = await page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray());
await page.mouse.move(220, 420);
await page.mouse.down();
await page.mouse.move(330, 450, { steps: 6 });
await page.mouse.up();
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray())).not.toEqual(center);
await testInfo.attach("notice-above-ticker", { body: await page.screenshot({ path: testInfo.outputPath("notice-priority.png") }), contentType: "image/png" });
await notice.getByRole("button", { name: "关闭通知" }).click();
await expect(notice).toHaveCount(0);
});
test("multiple notices stay anchored while panning and source recovery exits without a downward jump", async ({ page }, testInfo) => {
await openMap(page);
await clearInitialNotices(page);
await page.evaluate(async () => {
const path = "/src/features/map/core/components/notice-actions.tsx";
const notices = await import(path);
notices.showMapNotice({ id: "scada-position-regression", tone: "error", title: "SCADA 位置回归",
message: "监测设备暂不可用。", duration: Infinity });
notices.showMapNotice({ id: "geoserver-position-regression", tone: "error", title: "GeoServer MVT 不可用",
message: "地图数据源请求失败。", duration: Infinity });
});
const recovery = page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 不可用" });
await expect(recovery).toBeVisible();
await page.evaluate(async () => {
const path = "/src/features/map/core/components/notice-actions.tsx";
(await import(path)).showMapNotice({ id: "report-position-regression", title: "报告位置回归",
message: "报告入口持续保留。", actionLabel: "查看报告", onAction: () => {} });
});
const scada = page.locator("[data-sonner-toast]").filter({ hasText: "SCADA 位置回归" });
const report = page.locator("[data-sonner-toast]").filter({ hasText: "报告位置回归" });
await expect(report).toHaveCSS("opacity", "1");
await expect.poll(() => page.locator("[data-sonner-toaster]").evaluate((element) =>
element.getAnimations({ subtree: true }).filter((animation) => animation.playState === "running").length
)).toBe(0);
const boxes = await Promise.all([report.boundingBox(), recovery.boundingBox(), scada.boundingBox()]);
const center = await page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray());
const track = await recovery.evaluateHandle((element) => {
const cards = ["报告位置回归", "GeoServer MVT 不可用", "SCADA 位置回归"].map((title) =>
[...document.querySelectorAll("[data-sonner-toast]")].find((card) => card.textContent?.includes(title))!);
const state = { stopped: false, panning: true, panY: [] as number[][], liveY: [] as number[], exitY: [] as number[] };
const sample = () => {
if (state.stopped || !element.isConnected) return;
if (state.panning) state.panY.push(cards.map((card) => card.getBoundingClientRect().y));
const y = element.getBoundingClientRect().y;
(element.getAttribute("data-removed") === "true" ? state.exitY : state.liveY).push(y);
requestAnimationFrame(sample);
};
sample();
return state;
});
for (let i = 0; i < 3; i++) {
await page.mouse.move(220, 420);
await page.mouse.down();
await page.mouse.move(i % 2 ? 450 : 750, 450, { steps: 8 });
await page.mouse.up();
}
expect(await page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray())).not.toEqual(center);
expect(await Promise.all([report.boundingBox(), recovery.boundingBox(), scada.boundingBox()])).toEqual(boxes);
await track.evaluate((state) => { state.panning = false; });
await page.evaluate(async () => {
const path = "/src/features/map/core/components/notice-actions.tsx";
(await import(path)).showMapNotice({ id: "geoserver-position-regression", tone: "success", title: "GeoServer MVT 已连接",
message: "地图数据源已恢复正常。", duration: 700 });
});
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 已连接" })).toBeVisible();
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 已连接" })).toHaveCount(0, { timeout: 7000 });
const samples = await track.evaluate((state) => { state.stopped = true; return state; });
expect(samples.panY.length).toBeGreaterThan(0);
for (const positions of samples.panY) {
positions.forEach((y, index) => expect(Math.abs(y - boxes[index]!.y)).toBeLessThan(1));
}
expect(samples.liveY.length).toBeGreaterThan(0);
expect(samples.exitY.length).toBeGreaterThan(0);
for (const y of [...samples.liveY, ...samples.exitY]) expect(y).toBeLessThanOrEqual(boxes[1]!.y + 1);
expect((await report.boundingBox())!.y).toBe(boxes[0]!.y);
const reportBox = (await report.boundingBox())!;
const scadaBox = (await scada.boundingBox())!;
expect(scadaBox.y).toBeCloseTo(reportBox.y + reportBox.height + 8, 0);
await testInfo.attach("notice-positions-after-pan-and-recovery", {
body: await page.screenshot({ path: testInfo.outputPath("notice-positions.png") }), contentType: "image/png"
});
});