import { expect, test } from "@playwright/test"; import { investigationFixture, mockInvestigationPresentation } from "./support/mock-investigation-presentation"; import type { OperationalTaskNotification } from "../../src/features/workbench/api/operational-task-client"; for (const { width, reducedMotion } of [{ width: 1280, reducedMotion: false }, { width: 375, reducedMotion: false }, { width: 320, reducedMotion: false }, { width: 320, reducedMotion: true }]) { test(`待办和告警可独立移除且刷新后保持 ${width}${reducedMotion ? " 减少动态效果" : ""}`, async ({ page }, testInfo) => { await page.setViewportSize({ width, height: 900 }); await page.emulateMedia({ reducedMotion: reducedMotion ? "reduce" : "no-preference" }); const task = investigationFixture(1, "通知移除测试调查"); const notification: OperationalTaskNotification = { notification_id: "96000000-0000-4000-8000-000000000011", task_id: task.task_id, kind: "human_input_required", title: "待补充现场流量记录", description: "请核对现场记录后补充资料。", required_permission: null, lifecycle_status: "active", subject_type: "question", subject_id: null, resolved_at: null, resolution_reason: null, status: "unread", version: 1, created_at: task.created_at, read_at: null, read_by: null }; await mockInvestigationPresentation(page, [task], [], [notification]); const alert = { id: "pressure-alert-1", source: { kind: "scada_replay", replay_id: "replay-1", rule_id: "pressure-low", batch_sequence: 1 }, condition_id: `operational-task:${task.task_id}`, title: "SCADA 压力持续偏低需要核查现场情况", description: "压力监测点连续出现异常读数,请查看关联调查。", occurred_at: task.created_at, severity: "warning" }; let cursor = 10; await page.route("https://tjwater-api.invalid/api/v1/workbench/sync-snapshot", (route) => route.fulfill({ json: { schema: "workbench-sync-snapshot@1", event_cursor: cursor, generated_at: task.created_at, runtime: { availability: "not_configured", source: "none", scenario_availability: "not_configured", artifact_availability: "not_configured", schedule_availability: "not_configured", work_order_availability: "not_configured", alert_availability: "ready", generated_at: task.created_at, conditions: [], alerts: [alert], scenarios: [], latest_analysis: null, work_orders: [] }, tasks: [Object.fromEntries(Object.entries(task).filter(([key]) => !["latest_plan", "latest_report", "latest_progress", "evidence", "actions", "access_requests", "work_order_snapshot", "permissions", "questions"].includes(key)))], notifications: [notification] } })); const writes: string[] = []; page.on("request", (request) => { if (request.url().startsWith("https://tjwater-api.invalid/") && ["POST", "PUT", "PATCH", "DELETE"].includes(request.method())) writes.push(request.url()); }); await page.goto("/", { waitUntil: "domcontentloaded" }); const trigger = page.getByRole("button", { name: /查看待办与告警/ }); // Runtime alerts and task notifications finish loading independently on a cold page. await expect(trigger).toHaveAccessibleName("查看待办与告警,当前 2 条待处理事项", { timeout: 15_000 }); await trigger.click(); const menu = page.getByRole("menu"); await expect(menu).toBeVisible(); const removeTask = page.getByRole("menuitem", { name: `移除通知:${notification.title}` }); await expect(removeTask).toBeVisible(); const openTask = page.getByRole("menuitem", { name: `打开通知:${notification.title}` }); await expect(openTask).toHaveText(""); const notificationRow = page.locator(`[data-alert-id="operational-task-notification:${notification.notification_id}"]`); const titleBounds = await notificationRow.locator("p").first().boundingBox(); const rowBounds = await notificationRow.boundingBox(); expect(rowBounds!.height).toBeLessThanOrEqual(82); const hit = await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.closest("button")?.getAttribute("aria-label"), { x: titleBounds!.x + titleBounds!.width / 2, y: titleBounds!.y + titleBounds!.height / 2 }); expect(hit).toBe(`打开通知:${notification.title}`); await openTask.hover(); await expect(notificationRow).toHaveCSS("background-color", "rgb(218, 229, 253)"); await expect(notificationRow).toHaveCSS("border-color", "rgb(66, 99, 199)"); for (const action of [removeTask]) { await action.hover(); await expect(action).toHaveCSS("background-color", "rgb(66, 99, 199)"); await expect(action).toHaveCSS("color", "rgb(255, 255, 255)"); } await testInfo.attach(`alert-hover-${width}`, { body: await page.screenshot({ path: testInfo.outputPath("alert-hover.png"), style: "[data-sonner-toaster] { visibility: hidden !important; }" }), contentType: "image/png" }); await page.mouse.move(0, 0); const removePosition = await removeTask.evaluate((element) => { const remove = element.getBoundingClientRect(); const row = element.closest("[data-alert-id]")!.getBoundingClientRect(); return { top: remove.top - row.top, right: row.right - remove.right }; }); expect(removePosition.top).toBeLessThanOrEqual(6); expect(removePosition.right).toBeLessThanOrEqual(6); const box = await menu.boundingBox(); expect(box!.x).toBeGreaterThanOrEqual(0); expect(box!.x + box!.width).toBeLessThanOrEqual(width); await testInfo.attach(`alert-menu-${width}`, { body: await page.screenshot({ path: testInfo.outputPath("alert-menu.png"), style: "[data-sonner-toaster] { visibility: hidden !important; }" }), contentType: "image/png" }); const row = page.locator(`[data-alert-id="operational-task-notification:${notification.notification_id}"]`); if (!reducedMotion) { const exitState = await removeTask.evaluate(async (element) => { const row = element.closest("[data-alert-id]")!; (element as HTMLElement).click(); const list = row.parentElement!; let opacity = 1; for (let frame = 0; frame < 30 && opacity === 1; frame++) { await new Promise(requestAnimationFrame); opacity = Number(getComputedStyle(row).opacity); } const style = getComputedStyle(row); return { retained: row.isConnected, opacity, pointerEvents: style.pointerEvents, overflowX: getComputedStyle(list).overflowX, pageOverflows: document.documentElement.scrollWidth > window.innerWidth }; }); expect(exitState.retained).toBe(true); expect(exitState.opacity).toBeGreaterThan(0); expect(exitState.opacity).toBeLessThan(1); expect(exitState.pointerEvents).toBe("none"); expect(exitState.overflowX).toBe("hidden"); expect(exitState.pageOverflows).toBe(false); } else { await removeTask.click(); } await expect(row).toHaveCount(0); await expect(menu).toBeVisible(); await expect(trigger).toHaveAccessibleName("查看待办与告警,当前 1 条待处理事项"); await expect(page.getByRole("region", { name: "工况任务", exact: true })).toHaveCount(0); // A repeated business snapshot must not restore the removed notification. await page.evaluate((event) => { (window as unknown as { emitInvestigationTestEvent: (value: unknown) => void }).emitInvestigationTestEvent(event); }, { schema: "workbench-event@1", event_seq: ++cursor, event_id: "96000000-0000-4000-8000-000000000012", project_id: task.project_id, topic: "workbench.alert.changed", aggregate_type: "task_alert", aggregate_id: alert.id, aggregate_revision: 2, occurred_at: task.created_at, metadata: {} }); await page.getByRole("menuitem", { name: `移除通知:${alert.title}` }).focus(); await page.keyboard.press("Enter"); await expect(menu).toContainText("当前无待处理事项"); await expect(trigger).toHaveAccessibleName("查看待办与告警,当前 0 条待处理事项"); await page.keyboard.press("Escape"); await expect(trigger).toBeFocused(); await page.reload({ waitUntil: "domcontentloaded" }); await expect(trigger).toHaveAccessibleName("查看待办与告警,当前 0 条待处理事项"); await trigger.click(); await expect(page.getByRole("menu")).toContainText("当前无待处理事项"); expect(writes).toEqual([]); }); }