import { expect, test } from "@playwright/test"; import type { OperationalTask } from "../../src/features/workbench/api/operational-task-client"; import { mockAgentApi } from "./support/mock-agent-api"; import { mockWorkbenchContext } from "./support/mock-workbench-context"; const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e"; const taskId = "92000000-0000-4000-8000-000000000001"; const runId = "93000000-0000-4000-8000-000000000001"; const requestId = "94000000-0000-4000-8000-000000000001"; const now = "2026-10-10T08:00:00Z"; test("调查进度替换整图,跟随只展示新指令并在关闭时清理覆盖层", async ({ page }, testInfo) => { test.setTimeout(90_000); await mockWorkbenchContext(page); await mockAgentApi(page); await page.route("https://tjwater-api.invalid/api/v1/scada-devices**", (route) => route.fulfill({ json: { items: ["P1", "P2"].map((device_id, index) => ({ device_id, device_type: "pressure", node_id: null, link_id: null, api_query_id: null, measurement_unit: "MPa", transmission_mode: "test", transmission_frequency: "1min", reliability: 1, x: null, y: null, longitude: 121.48 + index * 0.001, latitude: 31.1 + index * 0.001 })), total: 2, limit: 1000, offset: 0 } })); await page.route("**/runtime-config.js", (route) => route.fulfill({ contentType: "application/javascript", body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({ TJWATER_AUTH_MODE: "disabled", TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid", TJWATER_AGENT_URL: "http://127.0.0.1:8787", TJWATER_ENABLE_DEV_PANEL: "true", TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver", TJWATER_GEOSERVER_WORKSPACE: "tjwater_next", TJWATER_MAPBOX_ACCESS_TOKEN: "", TJWATER_TIANDITU_TOKEN: "", TJWATER_ENABLE_MSW: "false" })};` })); // Keep the stream genuinely open: a fulfilled static SSE body is a disconnect. await page.addInitScript(() => { const animate = Element.prototype.animate; const reportAnimations: string[] = []; Object.assign(window, { reportAnimations }); Element.prototype.animate = function (...args) { const label = this.getAttribute("aria-label"); if (label === "当前调查进展" || label === "任务报告") reportAnimations.push(label); return animate.apply(this, args); }; const original = window.fetch.bind(window); const streams = new Set>(); Object.assign(window, { emitInvestigationEvent: (event: { event_seq: number }) => { const bytes = new TextEncoder().encode(`id: ${event.event_seq}\nevent: workbench_event\ndata: ${JSON.stringify(event)}\n\n`); for (const stream of streams) stream.enqueue(bytes); } }); window.fetch = async (input, init) => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; if (!url.includes("/api/v1/workbench/events")) return original(input, init); let controller: ReadableStreamDefaultController; const body = new ReadableStream({ start(value) { controller = value; streams.add(value); }, cancel() { streams.delete(controller); } }); init?.signal?.addEventListener("abort", () => { if (streams.delete(controller)) controller.close(); }, { once: true }); return new Response(body, { headers: { "Content-Type": "text/event-stream" } }); }; }); let task: OperationalTask = { project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "water_quality_complaint", lifecycle_status: "open", status: "running", priority: "normal", revision: 1, execution_generation: 1, execution_request_id: requestId, title: "合成投诉联动调查", summary: "浏览器验证用合成投诉,不是真实工单", source: { kind: "operator", external_id: null, external_revision: null, event_kind: "water_quality_complaint" }, trigger: { kind: "manual", occurred_at: now, detail: "合成数据" }, scheduled_for: null, agent_session_id: "investigation-test", agent_lease_expires_at: now, current_plan_revision: null, current_report_revision: null, created_at: now, updated_at: now, active_run: { run_id: runId, task_id: taskId, generation: 1, execution_request_id: requestId, status: "running", revision: 1, agent_session_id: "investigation-test", lease_expires_at: now, heartbeat_at: now, started_at: now, finished_at: null, failure_code: null, failure_detail: null, dispatch_attempts: 1, next_dispatch_at: now, created_at: now, updated_at: now }, latest_plan: null, latest_report: null, latest_progress: { revision: 1, run_id: runId, execution_generation: 1, published_at: now, source_session_id: "investigation-test", progress: { summary: "正在核对投诉点位", diagram: { title: "实时调查脉络", mermaid: 'flowchart TD\nA["投诉点位核对"] --> B["人工采样"]', node_refs: [ { node_id: "A", label: "投诉点位核对", status: "running", evidence_refs: [], action_refs: [] }, { node_id: "B", label: "人工采样", status: "waiting_human", evidence_refs: [], action_refs: [] } ] } } }, evidence: [], actions: [], access_requests: [], work_order_snapshot: null, permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false, can_decide_work_orders: true, can_execute_work_orders: true, can_decide_commands: false, can_execute_commands: false, can_decide_access: false } }; let eventCursor = 10; let cursorReads = 0; await page.route("https://tjwater-api.invalid/api/v1/workbench/sync-snapshot", (route) => { const { latest_plan, latest_report, latest_progress, evidence, actions, access_requests, work_order_snapshot, permissions, ...summary } = task; void latest_plan; void latest_report; void latest_progress; void evidence; void actions; void access_requests; void work_order_snapshot; void permissions; return route.fulfill({ json: { schema: "workbench-sync-snapshot@1", event_cursor: eventCursor, generated_at: now, 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: "not_configured", generated_at: now, conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] }, tasks: [summary], notifications: [] } }); }); await page.route(`https://tjwater-api.invalid/api/v1/operational-tasks/${taskId}**`, (route) => { if (route.request().url().endsWith("/presentation-cursor")) { cursorReads += 1; return route.fulfill({ json: { task_id: taskId, execution_generation: 1, active_run: { run_id: runId, generation: 1, status: "running" }, event_cursor: eventCursor } }); } if (route.request().url().includes("/reports")) return route.fulfill({ json: { items: [], next_offset: null } }); return route.fulfill({ json: task }); }); const emit = async (command: Record, seq = ++eventCursor) => { await page.evaluate((event) => { (window as unknown as { emitInvestigationEvent: (value: unknown) => void }).emitInvestigationEvent(event); }, { schema: "workbench-event@1", event_seq: seq, event_id: `95000000-0000-4000-8000-${String(seq).padStart(12, "0")}`, project_id: projectId, topic: "operational.presentation.requested", aggregate_type: "operational_task", aggregate_id: taskId, aggregate_revision: 1, occurred_at: now, metadata: { task_id: taskId, run_id: runId, execution_generation: 1, command } }); }; await page.goto("/"); await page.getByRole("button", { name: "工况任务", exact: true }).click(); const panel = page.getByRole("region", { name: "工况任务", exact: true }); const expand = panel.getByRole("button", { name: "展开工况任务" }); if (await expand.isVisible()) await expand.click(); await panel.getByRole("button", { name: /调查任务/ }).click(); await panel.getByText("合成投诉联动调查", { exact: true }).first().click(); await expect(panel.getByText("正在核对投诉点位", { exact: true }).first()).toBeVisible(); const diagram = panel.getByLabel("调查脉络", { exact: true }); await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(1); const animations = () => page.evaluate(() => (window as unknown as { reportAnimations: string[] }).reportAnimations); expect(await animations()).toEqual([]); await diagram.evaluate((element) => { element.setAttribute("data-retained-reader", "true"); }); task = { ...task, revision: 2, latest_progress: { ...task.latest_progress!, revision: 2, progress: { summary: "新增支路复核,等待现场反馈", diagram: { title: "重规划后的调查", mermaid: 'flowchart TD\nC["支路复核"] --> D["现场反馈"]', node_refs: [ { node_id: "C", label: "支路复核", status: "completed", evidence_refs: [], action_refs: [] }, { node_id: "D", label: "现场反馈", status: "waiting_human", evidence_refs: [], action_refs: [] } ] } } } }; await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click(); await expect(diagram).toContainText("支路复核"); await expect(diagram).not.toContainText("投诉点位核对"); await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(0); await expect(diagram).toHaveAttribute("data-retained-reader", "true"); await expect.poll(animations).toEqual(["当前调查进展"]); task = { ...task, revision: 3, latest_report: { revision: 1, execution_generation: 1, source_session_id: "investigation-test", published_at: now, published_by: "agent", report: { title: "合成调查报告", current_conclusion: "合成结论:等待现场核查", conclusion_boundary: "浏览器动画测试", evidence_refs: [], recommendations: ["核对现场记录"], pending_items: [], stage: "interim", outcome: "concluded", stop_reason: null, data_gaps: [], diagram: null } } }; await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click(); const report = panel.getByRole("region", { name: "任务报告", exact: true }); await expect(report).toContainText("合成结论:等待现场核查"); await expect.poll(animations).toEqual(["当前调查进展", "任务报告"]); await report.getByText("判断说明与建议", { exact: true }).click(); await report.locator("details").evaluate((element) => element.setAttribute("data-retained-reader", "true")); task = { ...task, revision: 4, latest_report: { ...task.latest_report!, revision: 2, report: { ...task.latest_report!.report, current_conclusion: "合成结论:现场核查已更新" } } }; await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click(); await expect(report).toContainText("合成结论:现场核查已更新"); await expect(report.locator("details")).toHaveAttribute("open", ""); await expect(report.locator("details")).toHaveAttribute("data-retained-reader", "true"); await expect.poll(animations).toEqual(["当前调查进展", "任务报告", "任务报告"]); await testInfo.attach("report-update", { body: await report.screenshot(), contentType: "image/png" }); await page.emulateMedia({ reducedMotion: "reduce" }); task = { ...task, revision: 5, latest_report: { ...task.latest_report!, revision: 3, report: { ...task.latest_report!.report, current_conclusion: "合成结论:减少动态效果下更新" } } }; await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click(); await expect(report).toContainText("合成结论:减少动态效果下更新"); expect(await animations()).toEqual(["当前调查进展", "任务报告", "任务报告"]); await page.emulateMedia({ reducedMotion: "no-preference" }); await page.getByRole("button", { name: "切换地图 Dev Panel" }).click(); const follow = page.getByRole("checkbox", { name: "跟随所选调查" }); await expect(follow).toBeChecked(); const checkboxAlignment = await follow.evaluate((input) => { const box = input.getBoundingClientRect(); const mark = input.parentElement!.querySelector("svg")!.getBoundingClientRect(); return { width: box.width, height: box.height, offsetX: mark.x + mark.width / 2 - box.x - box.width / 2, offsetY: mark.y + mark.height / 2 - box.y - box.height / 2 }; }); expect(checkboxAlignment).toEqual({ width: 20, height: 20, offsetX: 0, offsetY: 0 }); await expect.poll(() => cursorReads).toBeGreaterThan(0); const dev = page.getByTestId("map-dev-panel"); const sections = dev.locator("details"); await expect(sections).toHaveCount(5); await expect(sections.filter({ hasText: "调查联动" })).toHaveAttribute("open", ""); await expect(sections.filter({ hasText: "要素定位" })).toHaveAttribute("open", ""); await expect(sections.filter({ hasText: "水流调试" })).not.toHaveAttribute("open", ""); await testInfo.attach("dev-panel-default", { body: await page.screenshot({ path: testInfo.outputPath("dev-panel-default.png") }), contentType: "image/png" }); const flowSection = sections.filter({ hasText: "水流调试" }); const flowToggle = flowSection.locator("summary"); await flowToggle.click(); await dev.getByLabel("管道 ID", { exact: true }).fill("P-9"); await flowToggle.focus(); await page.keyboard.press("Enter"); await expect(flowSection).not.toHaveAttribute("open", ""); await page.keyboard.press("Enter"); await expect(dev.getByLabel("管道 ID", { exact: true })).toHaveValue("P-9"); await flowToggle.click(); await sections.filter({ hasText: "要素定位" }).locator("summary").click(); await sections.filter({ hasText: "图层样式" }).locator("summary").click(); await sections.filter({ hasText: "数值标注" }).locator("summary").click(); await page.setViewportSize({ width: 1024, height: 640 }); await dev.getByRole("button", { name: "应用标注", exact: true }).scrollIntoViewIfNeeded(); for (const name of ["数值", "设施 ID"]) { const control = dev.getByRole("checkbox", { name, exact: true }); await expect(control).toHaveCSS("width", "20px"); expect(await control.evaluate((input) => { const box = input.getBoundingClientRect(); const mark = input.parentElement!.querySelector("svg")!.getBoundingClientRect(); return [mark.x + mark.width / 2 - box.x - box.width / 2, mark.y + mark.height / 2 - box.y - box.height / 2]; })).toEqual([0, 0]); } expect(await dev.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1); await expect(dev.getByRole("button", { name: "重置地图调试效果" })).toBeVisible(); await testInfo.attach("dev-panel-narrow", { body: await page.screenshot({ path: testInfo.outputPath("dev-panel-narrow.png") }), contentType: "image/png" }); await page.setViewportSize({ width: 1440, height: 900 }); await page.getByRole("button", { name: "切换地图 Dev Panel" }).click(); await expect(page.getByLabel("调查实时展示")).toBeVisible(); await emit({ action: "highlight_features", layer: "scada", feature_ids: ["P1", "P2"], effect: "pulse" }); const highlightedCount = () => page.evaluate(async () => { const map = (window as unknown as { __waterNetworkMap?: { getSource: (id: string) => { getData: () => Promise<{ features: unknown[] }> } | undefined } }).__waterNetworkMap; return (await map?.getSource("investigation-presentation-highlights")?.getData())?.features.length ?? 0; }); await expect.poll(highlightedCount).toBe(2); await emit({ action: "clear_highlights" }); await expect.poll(highlightedCount).toBe(0); const chart = { chart_id: "pressure", chart: { title: "合成压力趋势", chart_type: "line", x_data: ["08:00", "08:05"], series: [{ name: "压力", data: [0.3, 0.28] }] }, evidence_refs: ["pressure-1"] }; await emit({ action: "open_chart", ...chart }, 9); await expect(page.locator('[data-investigation-chart="pressure"]')).toHaveCount(0); await emit({ action: "open_chart", ...chart }); await expect(page.locator('[data-investigation-chart="pressure"]')).toBeVisible(); await emit({ action: "close_chart", chart_id: "pressure" }); await expect(page.locator('[data-investigation-chart="pressure"]')).toHaveCount(0); const data = { type: "FeatureCollection", features: [ { type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [121.48, 31.10] } }, { type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [121.481, 31.101] } } ] }; await emit({ action: "set_overlay", overlay_id: "complaints", title: "合成投诉点热力", mode: "heatmap", evidence_id: "spatial-1", data }); const layerType = () => page.evaluate(() => { const map = (window as unknown as { __waterNetworkMap?: { getStyle: () => { layers?: { id: string; type: string }[] } } }).__waterNetworkMap; return map?.getStyle().layers?.find((layer) => layer.id === "investigation-presentation-overlay-heatmap")?.type; }); await expect.poll(layerType).toBe("heatmap"); await expect(page.getByLabel("调查实时展示")).toContainText("合成投诉点热力"); await page.mouse.move(300, 180); await page.mouse.wheel(0, 120); await expect(page.getByLabel("调查实时展示")).toContainText("镜头跟随已暂停"); await page.getByRole("button", { name: "恢复跟随", exact: true }).click(); await expect(page.getByLabel("调查实时展示")).not.toContainText("镜头跟随已暂停"); await testInfo.attach("live-investigation-map", { body: await page.screenshot({ path: testInfo.outputPath("live-investigation-map.png") }), contentType: "image/png" }); await page.setViewportSize({ width: 375, height: 720 }); await expect(page.getByLabel("调查实时展示")).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375); await testInfo.attach("live-investigation-mobile", { body: await page.screenshot({ path: testInfo.outputPath("live-investigation-mobile.png") }), contentType: "image/png" }); await page.setViewportSize({ width: 1440, height: 900 }); await page.getByRole("button", { name: "切换地图 Dev Panel" }).click(); await follow.uncheck(); await expect(follow).not.toBeChecked(); await follow.focus(); await expect(follow).toBeFocused(); await testInfo.attach("dev-panel-checkbox-off", { body: await dev.screenshot({ path: testInfo.outputPath("dev-panel-checkbox-off.png") }), contentType: "image/png" }); await expect.poll(() => page.evaluate(() => { const map = (window as unknown as { __waterNetworkMap?: { getLayoutProperty: (id: string, key: string) => unknown } }).__waterNetworkMap; return map?.getLayoutProperty("investigation-presentation-overlay-heatmap", "visibility"); })).toBe("none"); await expect(page.locator('[data-investigation-chart]')).toHaveCount(0); });