import { expect, test } from "@playwright/test"; import { mockAgentApi } from "./support/mock-agent-api"; test.beforeEach(async ({ page }) => { await page.route("http://127.0.0.1:8787/api/v1/**", (route) => route.fulfill({ json: { items: [] } })); await mockAgentApi(page); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.setContent('
'); await page.evaluate(async () => { const entry = await (await fetch("/src/main.tsx")).text(); const reactUrl = entry.match(/from "([^"\n]*\/react\.js[^"\n]*)"/)?.[1]; const clientUrl = entry.match(/from "([^"\n]*\/react-dom_client\.js[^"\n]*)"/)?.[1]; if (!reactUrl || !clientUrl) throw new Error("Vite React module paths unavailable"); const reactModule = await import(reactUrl); const React = reactModule.default ?? reactModule; const clientModule = await import(clientUrl); const { createRoot } = clientModule.default ?? clientModule; const { InvestigationTaskReport } = await import("/src/features/workbench/components/investigation-task-report.tsx"); const now = "2026-10-13T08:00:00Z"; const report = { revision: 1, execution_generation: 1, published_at: now, report: { title: "居民异味投诉调查报告", current_conclusion: "上游监测未见明显异常,仍需现场核查。", conclusion_boundary: "仅覆盖现有上游监测,不代表末梢水质。", stage: "interim", outcome: "concluded", recommendations: ["建议安排现场采样。"], pending_items: ["核实楼栋二次供水记录。"], evidence_refs: ["e1"], data_gaps: [{ gap_id: "g1", missing_data: "缺少末梢采样记录", impact: "尚不能确定异常来源", scope: "投诉时段", required_data: "末梢采样结果", evidence_refs: [], blocking: false }], diagram: { title: "现场核查路线", mermaid: "flowchart LR\nA[核对监测] --> B[现场采样]", node_refs: [] } } }; let task = { task_id: "task-cards", title: "居民异味投诉调查", summary: "上游监测已核对,等待现场资料", execution_generation: 1, lifecycle_status: "open", status: "running", updated_at: now, latest_report: report, latest_progress: null, latest_plan: null, actions: [{ action_id: "a1", kind: "create_work_order", payload: { title: "安排现场采样" }, reason: "补齐现场资料", approval_status: "pending", execution_status: "not_submitted", proposed_at: now }], evidence: [{ evidence_id: "e1", kind: "system_data", summary: "上游监测数据已核对", source_label: "SCADA 水质监测", observed_at: now, recorded_at: now }] }; const root = createRoot(document.getElementById("reader")); const render = () => root.render(React.createElement(InvestigationTaskReport, { task, onContinue: () => undefined, renderAction: () => React.createElement("p", null, "人工审核后才会创建工单"), renderEvidence: () => React.createElement("p", null, "原始监测记录与来源详情"), onLoadReports: async () => ({ items: [report], next_offset: null }) })); Object.assign(window, { updateInvestigationReport: () => { task = { ...task, latest_report: { ...report, revision: task.latest_report.revision + 1, report: { ...report.report, current_conclusion: "新增现场记录已接收,等待复核。" } } }; render(); }, showHistoricalUtcReport: () => { task = { ...task, latest_report: { ...report, revision: 2, report: { ...report.report, current_conclusion: "上报时间2026-10-08T04:00:00Z,查询04:00–05:00Z。", conclusion_boundary: "覆盖2026-12-31 15:30–17:00Z。", recommendations: ["复核2026-10-08T05:00:00Z的数据。"], data_gaps: [{ ...report.report.data_gaps[0], scope: "2026-10-08 04:00–05:00Z" }] } } }; Object.assign(window, { storedReportConclusion: task.latest_report.report.current_conclusion }); render(); }, startNextInvestigationRound: () => { task = { ...task, execution_generation: 2, status: "running", lifecycle_status: "open" }; Object.assign(task, { active_run: { run_id: "run-2", status: "running" }, latest_progress: { revision: 1, run_id: "run-2", execution_generation: 2, published_at: now, progress: { summary: "正在复核本轮现场反馈", diagram: { title: "本轮调查进展图", mermaid: "flowchart LR\nC[现场反馈复核] --> D[复测记录]", node_refs: [{ node_id: "C", label: "现场反馈复核", status: "running", evidence_refs: [], action_refs: [] }] } } } }); render(); }, finishInvestigationRound: (clearProgressDiagram: boolean) => { task = { ...task, status: "completed", lifecycle_status: "completed", latest_report: { ...report, revision: 2, execution_generation: 2, report: { ...report.report, stage: "final", current_conclusion: "本轮现场反馈已复核,形成新的调查结论。", diagram: { title: "本轮最终报告图", mermaid: "flowchart LR\nE[复核结果] --> F[恢复条件]", node_refs: [] } } } }; Object.assign(task, { active_run: null }); if (clearProgressDiagram) Object.assign(task, { latest_progress: { revision: 2, run_id: "run-2", execution_generation: 2, published_at: now, progress: { summary: "调查完成", diagram: null } } }); render(); } }); render(); }); }); for (const clearProgressDiagram of [false, true]) { test(`新一轮跟随最新进展,最终报告保留自己的图(过程图${clearProgressDiagram ? "为空" : "仍存在"})`, async ({ page }, testInfo) => { const report = page.getByRole("region", { name: "任务报告", exact: true }); await page.evaluate(() => (window as unknown as { startNextInvestigationRound: () => void }).startNextInvestigationRound()); await expect(report).toContainText("上一轮报告,本轮分析中"); await expect(page.getByRole("region", { name: "当前调查进展", exact: true })).toContainText("正在复核本轮现场反馈"); const diagram = page.getByRole("region", { name: "调查脉络", exact: true }); await expect(diagram.locator(".investigation-diagram svg:has(g.node)")).toContainText("现场反馈复核"); await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(1); await page.evaluate((clear) => (window as unknown as { finishInvestigationRound: (value: boolean) => void }).finishInvestigationRound(clear), clearProgressDiagram); await expect(report).toContainText("本轮现场反馈已复核,形成新的调查结论。"); await expect(diagram.locator(".investigation-diagram svg:has(g.node)")).toContainText("恢复条件"); await expect(diagram.locator(".investigation-diagram svg:has(g.node)")).not.toContainText("现场反馈复核"); await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(0); await page.getByRole("article", { name: "调查任务报告" }).evaluate(async (element) => { await Promise.allSettled(element.getAnimations({ subtree: true }) .filter((animation) => Number.isFinite(Number(animation.effect?.getComputedTiming().endTime))) .map((animation) => animation.finished)); }); await testInfo.attach("final-report-diagram", { body: await diagram.screenshot({ path: testInfo.outputPath("final-report-diagram.png"), animations: "disabled" }), contentType: "image/png" }); }); } test.describe("报告正文时间展示", () => { test.use({ timezoneId: "UTC" }); test("历史 UTC 正文和查询范围按 UTC+8 展示并保留原始报告", async ({ page }) => { await page.evaluate(() => (window as unknown as { showHistoricalUtcReport: () => void }).showHistoricalUtcReport()); const report = page.getByRole("region", { name: "任务报告", exact: true }); await expect(report).toContainText("上报时间2026/10/8 12:00:00(UTC+8),查询12:00–13:00(UTC+8)。"); await expect(report).toContainText("覆盖2026/12/31 23:30–2027/1/1 01:00(UTC+8)。"); await expect(report).not.toContainText("04:00:00Z"); await page.getByRole("button", { name: "展开当前结果", exact: true }).click(); const detail = page.getByRole("dialog", { name: "当前结果", exact: true }); await expect(detail).toContainText("复核2026/10/8 13:00:00(UTC+8)的数据。"); expect(await page.evaluate(() => (window as unknown as { storedReportConclusion: string }).storedReportConclusion)) .toBe("上报时间2026-10-08T04:00:00Z,查询04:00–05:00Z。"); }); }); test("报告摘要与有界详情分层,保留人工事项、阅读版本和返回位置", async ({ page }, testInfo) => { const progress = page.getByRole("region", { name: "当前调查进展", exact: true }); await expect(progress).toContainText("尚未发布本轮调查进展。"); await expect(progress.getByRole("button")).toHaveCount(0); await page.getByRole("button", { name: "展开任务时间线", exact: true }).click(); const timeline = page.getByRole("dialog", { name: "任务时间线", exact: true }); await expect(timeline.getByLabel("任务时间线记录").locator("li")).toHaveCount(3); await page.keyboard.press("Escape"); await expect(timeline).toHaveCount(0); await expect(page.getByLabel("当前需处理", { exact: true })).toContainText("安排现场采样"); await expect(page.getByLabel("数据缺口", { exact: true })).toContainText("缺少末梢采样记录"); await expect(page.getByText("建议安排现场采样。", { exact: true })).toHaveCount(0); await expect(page.getByText("原始监测记录与来源详情", { exact: true })).toHaveCount(0); await expect(page.locator(".investigation-diagram svg .node")).toHaveCount(2); await page.locator("#reader").evaluate((element) => { element.scrollTop = 0; }); await page.locator("#reader").screenshot({ path: testInfo.outputPath("report-cards-desktop.png") }); const trigger = page.getByRole("button", { name: "展开当前结果", exact: true }); await trigger.click(); const detail = page.getByRole("dialog", { name: "当前结果", exact: true }); await expect(detail.getByText("建议安排现场采样。", { exact: true })).toBeVisible(); await expect.poll(async () => Math.round((await detail.boundingBox())!.width)).toBe(704); await detail.screenshot({ path: testInfo.outputPath("report-expanded-desktop.png") }); await page.evaluate(() => (window as unknown as { updateInvestigationReport: () => void }).updateInvestigationReport()); await expect(detail).toContainText("上游监测未见明显异常,仍需现场核查。"); await expect(detail).not.toContainText("新增现场记录已接收"); await expect(detail.getByRole("status")).toContainText("已有更新"); await detail.getByRole("button", { name: "查看最新内容" }).click(); await expect(detail).toContainText("新增现场记录已接收"); await page.keyboard.press("Escape"); await expect(detail).toHaveCount(0); await expect(trigger).toBeFocused(); const historyButton = page.getByRole("button", { name: "展开报告版本" }); await historyButton.click(); await page.getByRole("dialog", { name: "报告版本" }).getByRole("button", { name: /报告第 1 版/ }).click(); await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByRole("region", { name: "任务报告" })).toContainText("上游监测未见明显异常"); await expect(page.getByRole("status")).toContainText("正在阅读历史报告"); await page.getByRole("button", { name: "返回实时内容" }).click(); await page.setViewportSize({ width: 375, height: 800 }); await page.getByRole("button", { name: "展开主要依据" }).click(); const evidence = page.getByRole("dialog", { name: "主要依据", exact: true }); await expect(evidence).toContainText("原始监测记录与来源详情"); await expect.poll(async () => Math.round((await evidence.boundingBox())!.width)).toBe(343); expect((await evidence.boundingBox())!.height).toBeLessThanOrEqual(688); await evidence.screenshot({ path: testInfo.outputPath("report-expanded-narrow.png") }); await evidence.getByRole("button", { name: "关闭详情" }).click(); await expect(evidence).toHaveCount(0); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375); }); test("数据刷新按整份报告错峰揭示,保留图表与滚动位置并支持减少动态效果", async ({ page }, testInfo) => { const report = page.getByRole("article", { name: "调查任务报告" }); const diagram = page.getByRole("region", { name: "调查脉络", exact: true }); await expect(diagram.locator("svg .node")).toHaveCount(2); const before = await report.evaluate((element) => { const graph = element.querySelector(".investigation-diagram svg"); const reader = document.getElementById("reader")!; Object.assign(window, { originalReportGraph: graph }); return { height: element.clientHeight, scrollHeight: reader.scrollHeight }; }); const update = () => page.evaluate(() => (window as unknown as { updateInvestigationReport: () => void }).updateInvestigationReport()); await update(); await expect.poll(() => report.evaluate((element) => element.getAnimations({ subtree: true }) .filter((animation) => (animation.effect as KeyframeEffect).getKeyframes().some((frame) => "translate" in frame)).length)).toBeGreaterThan(3); const sequence = await report.evaluate((element) => { const animations = element.getAnimations({ subtree: true }).filter((animation) => (animation.effect as KeyframeEffect).getKeyframes().some((frame) => "translate" in frame)); // Hold the actual browser animation at a common time to inspect the stagger. element.getAnimations({ subtree: true }).forEach((animation) => { animation.pause(); animation.currentTime = 160; }); return animations.map((animation) => ({ label: ((animation.effect as KeyframeEffect).target as HTMLElement).getAttribute("aria-label"), delay: animation.effect!.getTiming().delay, frames: (animation.effect as KeyframeEffect).getKeyframes().map((frame) => frame.translate) })); }); expect(sequence.map((item) => item.label)).toEqual(expect.arrayContaining(["任务报告", "当前调查进展", "调查脉络", "主要依据", "任务时间线"])); expect(sequence[0].delay).toBe(0); expect(sequence[1].delay).toBe(45); expect(sequence.every((item) => item.frames[0] === "0px 18px")).toBe(true); await page.screenshot({ path: testInfo.outputPath("report-refresh-during.png") }); await report.evaluate((element) => element.getAnimations({ subtree: true }).forEach((animation) => animation.finish())); await expect(report.getByText("新增现场记录已接收,等待复核。", { exact: true })).toBeVisible(); const after = await report.evaluate((element) => ({ sameGraph: element.querySelector(".investigation-diagram svg") === (window as unknown as { originalReportGraph: Element }).originalReportGraph, height: element.clientHeight, scrollHeight: document.getElementById("reader")!.scrollHeight })); expect(after).toEqual({ ...before, sameGraph: true }); await diagram.getByRole("button", { name: "Zoom in", exact: true }).click(); await expect(diagram.locator('[role="application"]')).toHaveCSS("transform", "matrix(1.1, 0, 0, 1.1, 0, 0)"); const scrollTop = await page.locator("#reader").evaluate((element) => element.scrollTop); await update(); await expect(report).toContainText("报告第 3 版"); expect(await page.locator("#reader").evaluate((element) => element.scrollTop)).toBe(scrollTop); await expect(diagram.locator('[role="application"]')).toHaveCSS("transform", "matrix(1.1, 0, 0, 1.1, 0, 0)"); await page.emulateMedia({ reducedMotion: "reduce" }); await update(); await expect(report).toContainText("报告第 4 版"); expect(await report.evaluate((element) => element.getAnimations({ subtree: true }).length)).toBe(0); await page.setViewportSize({ width: 375, height: 800 }); await page.emulateMedia({ reducedMotion: "no-preference" }); await page.locator("#reader").evaluate((element) => { element.scrollTop = 0; }); await update(); await expect(report).toContainText("报告第 5 版"); // Pointer interaction must end the refresh before shared-layout expansion starts. await page.getByRole("button", { name: "展开当前结果", exact: true }).dispatchEvent("pointerdown"); await page.getByRole("button", { name: "展开当前结果", exact: true }).click(); const detail = page.getByRole("dialog", { name: "当前结果", exact: true }); await expect(detail).toBeVisible(); await expect.poll(async () => Math.round((await detail.boundingBox())!.width)).toBe(343); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375); await page.screenshot({ path: testInfo.outputPath("report-refresh-narrow-detail.png") }); });