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" }); });