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.
238 lines
17 KiB
TypeScript
238 lines
17 KiB
TypeScript
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") });
|
||
});
|