Files
next-tjwater-frontend/tests/browser/investigation-report-cards.e2e.ts
T
jiang ec23d44b02 feat(agent): support Responses steering and project-aware tools
Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running
turns active while users steer or queue follow-ups and edit pending delivery.
Refresh the model catalog before generation and preserve approval mode selection.

Integrate scoped investigation messaging/presentation, geographic place selection,
current Agent composer styles and supporting workbench contracts.

Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and
production build. Preserve direct Server/Agent routing and project authentication.
2026-10-19 18:46:58 +08:00

238 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('<!doctype html><html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="reader" class="scheduled-feed-scroll" style="max-width:680px;margin:auto;padding:16px;height:100vh;overflow:auto"></main></body></html>');
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") });
});