feat: expand intelligent investigation workspace
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c0e4231b83
223 changed files with 13155 additions and 5304 deletions
+4 -1
View File
@@ -51,6 +51,8 @@ test("Agent separates conversation navigation, context and composer from the map
await expect(context.getByRole("button")).toHaveCount(0);
await expect(form).toHaveCount(1);
await expect(form).toHaveCSS("background-color", "rgb(255, 255, 255)");
const restingBorder = await form.evaluate((element) => getComputedStyle(element).borderColor);
const restingShadow = await form.evaluate((element) => getComputedStyle(element).boxShadow);
const panelBox = (await panel.boundingBox())!;
const headerBox = (await header.boundingBox())!;
const divider = panel.locator("[data-agent-header-divider]");
@@ -58,7 +60,8 @@ test("Agent separates conversation navigation, context and composer from the map
expect(panelBox.x).toBe(0);
expect(contextBox.y).toBeGreaterThanOrEqual(headerBox.y + headerBox.height);
await form.locator("textarea").focus();
await expectThemeColor(form, "border-color", "--action-blue");
await expect(form).toHaveCSS("border-color", restingBorder);
await expect(form).not.toHaveCSS("box-shadow", restingShadow);
await header.getByRole("button", { name: "选择主题" }).click();
await expect(page.getByRole("menuitem", { name: "新建主题", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "新建项目", exact: true })).toHaveCount(0);
@@ -6,13 +6,14 @@ import { responsesFixture } from "./support/responses-fixture";
const block = {
version: "answer-ui@1", block_id: "components-motion", fallback_text: "组件交互演练,不代表现场事实。",
spec: { root: "root", elements: {
root: { type: "Stack", props: { columns: "one", gap: "normal", intent: "report" }, children: ["result", "note", "metric", "table", "evidence", "form", "location"] },
root: { type: "Stack", props: { columns: "one", gap: "normal", intent: "report" }, children: ["result", "note", "metric", "table", "evidence", "form", "extraForm", "location"] },
result: { type: "Result", props: { title: "演练结果", summary: "待现场核验。", status: "unconfirmed", source: "浏览器演练" }, children: [] },
note: { type: "Text", props: { title: "数据边界", tone: "info", text: "仅供交互验收。" }, children: [] },
metric: { type: "Metric", props: { label: "演练入口压力", value: "0.2130", unit: "MPa", source: "浏览器演练" }, children: [] },
table: { type: "Table", props: { title: "演练记录", columns: ["对象", "压力(MPa)"], rows: [["入口一", "0.2130"], ["入口二", "0.2870"]] }, children: [] },
evidence: { type: "Evidence", props: { title: "演练依据", summary: "尚未核验现场事实。", source: "浏览器演练", kind: "demo", evidence_id: "DEMO-MOTION", observed_at: null }, children: [] },
form: { type: "FollowUpForm", props: { title: "现场核验", description: "由你补充后带入对话。", fields: [{ name: "record", label: "现场记录", kind: "text", required: true }] }, children: [] },
extraForm: { type: "FollowUpForm", props: { title: "其他核验", description: "可选补充", fields: [{ name: "extra", label: "其他记录", kind: "text", required: false }] }, children: [] },
location: { type: "MapLocation", props: { label: "定位演练入口", layer: "junctions", feature_ids: ["DEMO-ENTRY"], source: "浏览器演练" }, children: [] },
} },
};
@@ -82,17 +83,9 @@ for (const width of [1440, 320]) for (const reduce of [false, true]) {
const location = ui.locator(".answer-ui-location");
await location.getByRole("button", { name: "查看关联要素", exact: true }).click();
await expect(location.getByText("DEMO-ENTRY", { exact: true })).toBeVisible();
await ui.getByRole("checkbox", { name: "选择第 1 行", exact: true }).check();
await ui.getByRole("button", { name: "将所选行加入分析", exact: true }).click();
const tray = ui.getByRole("region", { name: "分析内容篮" });
await expect(tray).toContainText("已选 1 项分析内容");
await tray.getByRole("button", { name: "演练记录", exact: true }).click();
await expect(tray.getByText(/对象:入口一;压力(MPa):0.2130/)).toBeVisible();
await tray.getByRole("textbox").fill("核查演练资料");
await ui.getByRole("button", { name: "将演练入口压力加入分析", exact: true }).click();
await tray.getByRole("button", { name: "移除演练入口压力", exact: true }).click();
await expect(tray.getByRole("textbox")).toHaveValue("核查演练资料");
await expect(editor).toHaveValue("保留同一个编辑器的现场草稿");
await expect(ui.getByRole("checkbox")).toHaveCount(0);
await expect(ui.getByRole("button", { name: /加入分析|针对.*提问/ })).toHaveCount(0);
await expect(ui.getByRole("region", { name: "分析内容篮" })).toHaveCount(0);
await ui.getByRole("textbox", { name: "搜索演练记录" }).fill("不存在的入口");
await expect(ui.getByRole("cell", { name: "当前筛选没有匹配的数据。" })).toBeVisible();
await expect(ui.getByText("勾选数据行可加入分析或继续提问。", { exact: true })).toHaveCount(0);
@@ -106,8 +99,7 @@ for (const width of [1440, 320]) for (const reduce of [false, true]) {
await expect(editor).toBeVisible();
await expect(editor).toHaveValue("保留同一个编辑器的现场草稿");
await page.evaluate(() => document.fonts.ready);
await ui.locator(".answer-ui-form").screenshot({ path: testInfo.outputPath(`form-${width}-${reduce}.png`) });
await ui.getByRole("group", { name: "现场核验", exact: true }).screenshot({ path: testInfo.outputPath(`form-${width}-${reduce}.png`) });
await evidence.screenshot({ path: testInfo.outputPath(`evidence-${width}-${reduce}.png`) });
await tray.screenshot({ path: testInfo.outputPath(`basket-${width}-${reduce}.png`) });
});
}
+141
View File
@@ -0,0 +1,141 @@
import { createHash } from "node:crypto";
import { expect, test, type Page } from "@playwright/test";
import { answerUiDemoBlock } from "./support/answer-ui-demo";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import type { AnswerViewStateRecord, AnswerViewStateWrite } from "../../src/features/agent/api/answer-view-state";
const block = {
...answerUiDemoBlock,
spec: { ...answerUiDemoBlock.spec, elements: {
...answerUiDemoBlock.spec.elements,
root: { ...answerUiDemoBlock.spec.elements.root, children: ["metrics", "chart", "records", "evidence", "location", "form"] },
records: { type: "Table", props: { title: "演练监测记录", columns: ["位置", "压力"], rows: [["北侧", "0.30"], ["南侧", "0.28"]] }, children: [] }
} }
};
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (typeof value === "object" && value !== null) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical((value as Record<string, unknown>)[key])}`).join(",")}}`;
return JSON.stringify(value);
}
const digest = createHash("sha256").update(canonical(block.spec)).digest("hex");
const empty = (): AnswerViewStateRecord => ({ version: "answer-view-state@1", message_id: "state-answer", block_id: "pressure", revision: 0, schema_digest: digest, nodes: {}, block: { evidenceFilter: "all" }, updated_at: null });
const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, PUT, OPTIONS", "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Project-Id" };
async function setup(page: Page) {
const records = new Map<string, AnswerViewStateRecord>();
const writes: AnswerViewStateWrite[] = [];
const receipts = new Map<string, AnswerViewStateRecord>();
const controls = { failAfterCommit: false };
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
await mockAgentApi(page, { sessions: ["A", "B"].map((name) => ({ session_id: `state-${name}`, title: `交互状态演练 ${name}`, created_at: Date.now(), updated_at: Date.now() })) });
await page.route("**/api/v1/session/state-*?**", async (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith("/ui-state")) return route.fallback();
const session = url.pathname.split("/").at(-1)!;
await route.fulfill({ headers, json: { session_id: session, title: `交互状态演练 ${session.slice(-1)}`, purpose: "ordinary_chat", status: "active", is_streaming: false,
messages: [{ id: "state-answer", role: "assistant", parts: [
{ type: "data-answer_ui", id: "pressure", data: block },
{ type: "data-final_answer", data: { content: "演练交互状态,不涉及业务写入。", ui_block_ids: ["pressure"] } }
] }] } });
});
await page.route("**/api/v1/session/state-*/ui-state**", async (route) => {
const method = route.request().method();
if (method === "OPTIONS") return route.fulfill({ status: 204, headers });
const session = new URL(route.request().url()).pathname.split("/").at(-2)!;
const current = records.get(session) ?? empty();
if (method === "GET") return route.fulfill({ headers, json: current });
const input = route.request().postDataJSON() as AnswerViewStateWrite;
writes.push(input);
const receipt = receipts.get(input.request_id);
if (receipt) return route.fulfill({ headers, json: receipt });
if (input.expected_revision !== current.revision) return route.fulfill({ status: 409, headers, json: { code: "ANSWER_VIEW_STATE_CONFLICT", detail: "演练并发修改", current } });
const next = { ...current, nodes: input.nodes, block: input.block, revision: current.revision + 1, updated_at: new Date().toISOString() };
records.set(session, next); receipts.set(input.request_id, next);
if (controls.failAfterCommit) { controls.failAfterCommit = false; return route.abort("failed"); }
await route.fulfill({ headers, json: next });
});
return { records, writes, controls };
}
async function openPanel(page: Page, width: number) {
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
}
async function openSession(page: Page, name: string) {
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("button", { name: /历史/ }).click();
await page.locator("button.agent-history-session-trigger").filter({ hasText: `交互状态演练 ${name}` }).click();
const ui = panel.locator('[data-answer-ui="pressure"]');
await expect(ui).toBeVisible();
await expect(panel.locator('[data-answer-state="saved"]')).toBeVisible();
return { panel, ui };
}
for (const width of [1440, 320]) {
test(`interactive state survives reload and isolates sessions at ${width}px`, async ({ page }) => {
test.setTimeout(60000);
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
const { records } = await setup(page);
await page.goto("/", { waitUntil: "domcontentloaded" }); await openPanel(page, width);
let { ui } = await openSession(page, "A");
await ui.getByLabel("现场位置(必填)").fill("演练北侧草稿");
const chart = ui.locator(".answer-ui-chart");
await chart.getByRole("button", { name: "参考压力(MPa)", exact: true }).click();
await chart.getByRole("tab", { name: "数据", exact: true }).click();
await chart.getByRole("textbox", { name: /搜索/ }).fill("09:00");
await ui.getByRole("textbox", { name: "搜索演练监测记录", exact: true }).fill("北侧");
await ui.getByRole("button", { name: /查看演练证据/ }).click();
await expect.poll(() => records.get("state-A")?.nodes).toMatchObject({
form: { values: { location: "演练北侧草稿" } }, chart: { view: "table", hidden: [1], table: { query: "09:00", selection: [] } },
records: { query: "北侧", selection: [] }, evidence: { open: true }
});
await page.reload({ waitUntil: "domcontentloaded" }); await openPanel(page, width);
({ ui } = await openSession(page, "A"));
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("演练北侧草稿");
await expect(ui.locator(".answer-ui-chart").getByRole("tab", { name: "数据", exact: true })).toHaveAttribute("aria-selected", "true");
await expect(ui.getByRole("textbox", { name: "搜索演练监测记录", exact: true })).toHaveValue("北侧");
await expect(ui.getByText("仅用于组件验收,不代表已核验现场事实。", { exact: true })).toBeVisible();
({ ui } = await openSession(page, "B"));
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("");
await ui.getByLabel("现场位置(必填)").fill("演练南侧草稿");
await expect.poll(() => records.get("state-B")?.nodes.form).toMatchObject({ values: { location: "演练南侧草稿" } });
({ ui } = await openSession(page, "A"));
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("演练北侧草稿");
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ path: `test-results/answer-state-restored-${width}.png` });
});
}
test("failed writes retain drafts and retry idempotently; conflicts require an explicit choice", async ({ page }) => {
test.setTimeout(60000);
await page.emulateMedia({ reducedMotion: "reduce" });
const { records, writes, controls } = await setup(page);
await page.goto("/", { waitUntil: "domcontentloaded" }); await openPanel(page, 1440);
const { panel, ui } = await openSession(page, "A");
controls.failAfterCommit = true;
await ui.getByLabel("现场位置(必填)").fill("网络失败仍保留");
await expect(panel.getByRole("button", { name: "重试保存" })).toBeVisible();
await expect.poll(() => writes.length).toBe(1);
await panel.getByRole("button", { name: "重试保存" }).click();
await expect(panel.locator('[data-answer-state="saved"]')).toBeVisible();
expect(writes[1]).toEqual(writes[0]);
expect(records.get("state-A")?.revision).toBe(1);
const remote = records.get("state-A")!;
records.set("state-A", { ...remote, revision: 2, nodes: { ...remote.nodes, form: { type: "FollowUpForm", values: { location: "另一个窗口" } } } });
await ui.getByLabel("现场位置(必填)").fill("本地选择保留");
await expect(panel.locator('[data-answer-state="conflict"]')).toBeVisible();
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("本地选择保留");
await panel.getByRole("button", { name: "保留本地并重新保存" }).click();
await expect(panel.locator('[data-answer-state="saved"]')).toBeVisible();
expect(records.get("state-A")?.revision).toBe(3);
expect(records.get("state-A")?.nodes.form).toMatchObject({ values: { location: "本地选择保留" } });
const next = records.get("state-A")!;
records.set("state-A", { ...next, revision: 4, nodes: { ...next.nodes, form: { type: "FollowUpForm", values: { location: "远端已确认草稿" } } } });
await ui.getByLabel("现场位置(必填)").fill("待丢弃草稿");
await expect(panel.locator('[data-answer-state="conflict"]')).toBeVisible();
await panel.getByRole("button", { name: "重新加载远端" }).click();
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("待丢弃草稿");
await panel.getByRole("button", { name: "确认替换本地草稿" }).click();
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("远端已确认草稿");
expect(records.get("state-A")?.revision).toBe(4);
});
+44 -14
View File
@@ -21,6 +21,11 @@ for (const width of [1440, 375, 320]) {
}
]
});
// Restored sessions must complete the pending-interaction check before sending.
await page.route("**/api/v1/session/*/interactions", async (route) => {
if (route.request().method() !== "GET") return route.fallback();
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { items: [] } });
});
const parts = [
{ type: "text", text: "以下资料均为演练数据。" },
{ type: "data-answer_ui", id: "pressure", data: block },
@@ -105,14 +110,13 @@ for (const width of [1440, 375, 320]) {
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
const ui = panel.locator('[data-answer-ui="pressure"]');
await expect(ui).toHaveCount(1);
const process = panel
.locator("details")
.filter({ has: page.locator("summary", { hasText: "查看处理过程" }) });
const process = panel.locator("details[data-agent-process]");
const processToggle = panel.getByRole("button", { name: "查看处理过程", exact: true });
await expect(process).not.toHaveAttribute("open");
await expect(panel.getByText("以下资料均为演练数据。", { exact: true })).toBeHidden();
await process.locator("summary").click();
await processToggle.click();
await expect(panel.getByText("以下资料均为演练数据。", { exact: true })).toBeVisible();
await process.locator("summary").click();
await processToggle.click();
if (width >= 1024) {
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
for (const panelWidth of [400, 640]) {
@@ -129,10 +133,32 @@ for (const width of [1440, 375, 320]) {
}
}
await expect(ui.getByText("0.30", { exact: false }).first()).toBeVisible();
await expect(panel.getByText("交互回答", { exact: true })).toBeVisible();
await expect(panel.getByText("交互回答", { exact: true })).toHaveCount(0);
const contentNav = ui.getByRole("navigation", { name: "回答内容导航" });
const browseAnswer = contentNav.getByRole("button", { name: "浏览回答" });
await browseAnswer.click();
await expect(page.getByRole("menuitem", { name: "查看指标,2项" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(browseAnswer).toBeFocused();
await page.keyboard.press("Enter");
await page.getByRole("menuitem", { name: "查看补充资料,1项" }).focus();
await page.keyboard.press("Enter");
await expect(ui.locator(".answer-ui-form")).toBeFocused();
await ui.getByLabel("现场位置(必填)").fill("导航保留的现场草稿");
await browseAnswer.click();
await page.getByRole("menuitem", { name: "查看趋势,1项" }).click();
await expect(ui.locator(".answer-ui-chart")).toBeFocused();
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("导航保留的现场草稿");
await panel.locator(".agent-conversation-scroll").evaluate((element) => { element.scrollTop = 0; });
await expect(panel.getByText("查看演练监测资料", { exact: true })).toBeVisible();
await panel.screenshot({ path: `test-results/agent-chat-redesign-${width}.png` });
await ui.getByRole("group", { name: /按回车查看数据点/ }).focus();
await page.keyboard.press("Enter");
await ui.getByRole("combobox", { name: "选择数据点" }).click();
await page.getByRole("option", { name: "09:00", exact: true }).click();
await expect(ui.getByRole("region", { name: "数据点详情" }).locator("dl")).toContainText("0.28");
await ui.getByRole("button", { name: "关闭数据点详情", exact: true }).click();
await expect(ui.getByRole("combobox", { name: "选择数据点" })).toBeHidden();
const lineTab = ui.getByRole("tab", { name: "折线", exact: true });
await lineTab.focus();
await page.keyboard.press("ArrowRight");
@@ -175,24 +201,29 @@ for (const width of [1440, 375, 320]) {
await copy.click();
await expect(panel.getByRole("button", { name: "回复已复制", exact: true }).first()).toBeVisible();
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain("仍需用户补充现场核验资料。");
// The same editor and data selection move into the workspace and back.
// The same editor and data filter move into the workspace and back.
await ui.getByLabel("现场位置(必填)").fill("尚未提交的现场位置");
await ui.getByRole("tab", { name: "数据", exact: true }).click();
await ui.getByRole("textbox", { name: "搜索演练监测趋势" }).fill("09:00");
await ui.getByRole("checkbox", { name: "选择第 2 行" }).check();
await panel.getByRole("button", { name: "展开查看", exact: true }).click();
const workspace = page.getByRole("region", { name: "展开报告工作区", exact: true });
await expect(workspace).toBeVisible();
await expect(page.getByLabel("现场位置(必填)")).toHaveCount(1);
await expect(workspace.getByLabel("现场位置(必填)")).toHaveValue("尚未提交的现场位置");
await expect(workspace.getByRole("textbox", { name: "搜索演练监测趋势" })).toHaveValue("09:00");
await expect(workspace.getByRole("checkbox", { name: "选择第 2 行" })).toBeChecked();
await workspace.getByRole("tab", { name: "折线", exact: true }).click();
for (const close of await page.getByRole("button", { name: "关闭通知", exact: true }).all()) await close.click();
await page.evaluate(() => document.fonts.ready);
const geometry = await captureReadingGeometry(workspace, testInfo);
expect(geometry.metricsHeight).toBeLessThan(145);
expect(geometry.chartOffset).toBeLessThan(430);
// The expanded reader reserves room for larger text and the task heading.
// The chart still starts within one viewport, and the document scrolls inside its window.
expect(geometry.chartOffset).toBeLessThan(600);
const readerBounds = (await workspace.boundingBox())!;
expect(readerBounds.x).toBeGreaterThan(0);
expect(readerBounds.y).toBeGreaterThan(0);
expect(readerBounds.x + readerBounds.width).toBeLessThan(width);
expect(readerBounds.y + readerBounds.height).toBeLessThan(900);
expect(geometry.horizontalOverflow).toBe(false);
await page.screenshot({ path: `test-results/answer-workspace-${width}.png` });
await workspace.getByRole("tab", { name: "数据", exact: true }).click();
@@ -201,12 +232,11 @@ for (const width of [1440, 375, 320]) {
await expect(ui.getByLabel("现场位置(必填)")).toHaveValue("尚未提交的现场位置");
await expect(panel.getByRole("button", { name: "展开查看", exact: true })).toBeFocused();
await panel.getByRole("button", { name: "展开查看", exact: true }).click();
await workspace.locator("[data-interactive-table]").getByRole("button", { name: "针对这部分提问" }).click();
await expect(workspace.getByRole("checkbox")).toHaveCount(0);
await expect(workspace.getByRole("button", { name: /加入分析|针对.*提问/ })).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(workspace).toHaveCount(0);
await panel.getByText("查看引用内容", { exact: true }).click();
await expect(panel.getByText("引用内容块:pressure", { exact: false })).toBeVisible();
expect(prompts).toHaveLength(1);
await panel.getByRole("button", { name: "移除内容引用" }).click();
// Leave the current editor before restoring canonical persisted history.
await panel.getByRole("button", { name: "新建 Agent 对话" }).click();
await expect(ui).toHaveCount(0);
-2
View File
@@ -61,7 +61,6 @@ for (const width of [1440, 375]) for (const reduce of [false, true]) {
await ui.getByRole("tab", { name: "数据", exact: true }).click();
await expect(ui.getByRole("group", { name: "当前区间摘要" })).toHaveCount(0);
await ui.getByRole("textbox", { name: /搜索节点17038/ }).fill("08:30");
await ui.getByRole("checkbox", { name: "选择第 7 行", exact: true }).check();
const line = ui.getByRole("tab", { name: "折线", exact: true });
await line.focus();
await page.keyboard.press("Enter");
@@ -78,7 +77,6 @@ for (const width of [1440, 375]) for (const reduce of [false, true]) {
await expect(plot).toBeFocused();
await ui.getByRole("tab", { name: "数据", exact: true }).click();
await expect(ui.getByRole("textbox", { name: /搜索节点17038/ })).toHaveValue("08:30");
await expect(ui.getByRole("checkbox", { name: "选择第 7 行", exact: true })).toBeChecked();
await line.click();
await expect(content).toHaveCSS("opacity", "1");
expect(await ui.evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(false);
@@ -0,0 +1,103 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { responsesFixture } from "./support/responses-fixture";
test.setTimeout(60_000);
test("service detection recovers without a reload and retains the draft", async ({ page }) => {
await page.clock.install();
await mockWorkbenchContext(page);
await mockAgentApi(page);
let available = false;
let healthReads = 0;
const headers = { "Access-Control-Allow-Origin": "*" };
await page.route("**/health", (route) => {
healthReads++;
return route.fulfill({ headers, status: available ? 200 : 503, json: { ok: available, ready: available } });
});
await page.route("**/api/v1/models", (route) => available ? route.fallback() : route.fulfill({ headers, status: 503, json: { message: "temporarily unavailable" } }));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
const state = panel.locator("header").getByRole("status");
await expect(state).toHaveText("离线");
const prompt = panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
await prompt.fill("恢复后继续核查压力");
available = true;
await page.clock.runFor(5_000);
await expect(state).toHaveText("在线");
await expect(prompt).toHaveValue("恢复后继续核查压力");
available = false;
await page.clock.runFor(15_000);
await expect(state).toHaveText("离线");
available = true;
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
await expect(state).toHaveText("在线");
expect(healthReads).toBeGreaterThanOrEqual(4);
});
test("a completed business failure does not mark a healthy Agent offline", async ({ page }) => {
await mockWorkbenchContext(page);
await mockAgentApi(page);
await page.route("**/health", (route) => route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { ok: true, ready: true } }));
await page.route("**/api/v1/responses", (route) => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
body: responsesFixture([{ type: "start", messageId: "business-failure" }, { type: "error", errorText: "本次数据查询失败" }, { type: "finish", finishReason: "error" }])
}));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
const state = panel.locator("header").getByRole("status");
await expect(state).toHaveText("在线");
await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("核对压力");
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
await expect(page.getByText("本次数据查询失败", { exact: true }).first()).toBeVisible();
await expect(state).toHaveText("在线");
});
test("a disconnected session resubscribes without replaying input or moving an older reader", async ({ page }) => {
await page.clock.install();
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
const sessionId = "recover-stream";
await mockAgentApi(page, { sessions: [{ session_id: sessionId, title: "断线中的压力分析", created_at: Date.now(), updated_at: Date.now() }] });
const headers = { "Access-Control-Allow-Origin": "*" };
const history = Array.from({ length: 18 }, (_, i) => ({ id: `older-${i}`, role: "assistant", parts: [{ type: "text", text: `历史核查记录 ${i}:需要保留这段已读内容和阅读位置。` }] }));
const messages = (text: string) => [...history, { id: "stream-result", role: "assistant", parts: [{ type: "text", text }] }];
let subscriptions = 0;
let generationRequests = 0;
await page.route("**/health", (route) => route.fulfill({ headers, json: { ok: true, ready: true } }));
await page.route(`**/api/v1/session/${sessionId}?**`, (route) => route.fulfill({ headers, json: {
session_id: sessionId, title: "断线中的压力分析", purpose: "ordinary_chat", status: "active", is_streaming: true, run_status: "running", messages: messages("已收到第一段结果。")
} }));
await page.route(`**/api/v1/session/${sessionId}/stream**`, (route) => {
subscriptions++;
const finished = subscriptions > 1;
return route.fulfill({ headers: { ...headers, "Content-Type": "text/event-stream" }, body:
`event: state\ndata: ${JSON.stringify({ session_id: sessionId, messages: messages(finished ? "断线期间的结果已恢复。" : "已收到第一段结果。"), is_streaming: !finished, run_status: finished ? "completed" : "running" })}\n\n`
});
});
await page.route("**/api/v1/responses", (route) => { generationRequests++; return route.abort(); });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("button", { name: /历史/ }).click();
await page.locator("button.agent-history-session-trigger").filter({ hasText: "断线中的压力分析" }).click();
await expect.poll(() => subscriptions).toBe(1);
const prompt = panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
await prompt.fill("保留未发送的补充问题");
const scroll = panel.locator(".agent-conversation-scroll");
await page.clock.runFor(100);
await scroll.hover();
await page.mouse.wheel(0, -2_400);
await page.clock.runFor(100);
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeLessThanOrEqual(2);
await page.clock.runFor(2_000);
await expect.poll(() => subscriptions).toBe(2);
await expect(panel.locator('[data-agent-message-id="stream-result"]')).toContainText("断线期间的结果已恢复。");
await expect(panel.locator('[data-agent-message-id="older-0"]')).toHaveCount(1);
await expect(prompt).toHaveValue("保留未发送的补充问题");
expect(await scroll.evaluate((element) => element.scrollTop)).toBeLessThanOrEqual(2);
expect(generationRequests).toBe(0);
});
+2 -1
View File
@@ -49,7 +49,8 @@ for (const width of [320, 375, 400, 640]) {
const contextBox = (await context.boundingBox())!;
const titleBox = (await titleElement.boundingBox())!;
const topicBox = (await topic.boundingBox())!;
expect(titleBox.width).toBeGreaterThanOrEqual(contextBox.width - 26);
const titleInset = await context.evaluate((element) => parseFloat(getComputedStyle(element).paddingInlineStart));
expect(titleBox.width).toBeCloseTo(contextBox.width - titleInset * 2, 0);
expect(titleBox.height).toBeGreaterThanOrEqual(20);
if (width < 640) expect(titleBox.height).toBeGreaterThan(20);
expect(titleBox.height).toBeLessThanOrEqual(40);
@@ -0,0 +1,252 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { responsesFixture } from "./support/responses-fixture";
// Explicitly synthetic evidence used only to exercise the generated component tree.
const comparison = {
version: "answer-ui@1",
block_id: "pressure-comparison",
fallback_text: "演练:北区压力0.2130MPa,南区0.2870MPa;待补充现场核验。",
spec: {
root: "root",
elements: {
root: { type: "Stack", props: { columns: "one", gap: "normal" }, children: ["caution", "metrics", "records", "evidence", "inference", "location", "form"] },
caution: { type: "Text", props: { text: "以下为浏览器演练资料,不能据此执行现场调度。", tone: "warning" }, children: [] },
metrics: { type: "Stack", props: { columns: "two", gap: "compact" }, children: ["north", "south"] },
north: { type: "Metric", props: { label: "北区压力", value: "0.2130", unit: "MPa", source: "演练监测快照" }, children: [] },
south: { type: "Metric", props: { label: "南区压力", value: "0.2870", unit: "MPa", source: "演练监测快照" }, children: [] },
records: { type: "Table", props: {
title: "演练监测记录", columns: ["监测对象", "压力(MPa)", "记录时间"],
rows: [["演练北区入口", "0.2130", "08:10"], ["演练南区入口", "0.2870", "08:10"]],
}, children: [] },
evidence: { type: "Evidence", props: {
title: "演练记录来源", summary: "这组数据来自浏览器测试,不代表现场核实。", source: "浏览器验收数据",
kind: "demo", observed_at: "2026-10-26T08:10:00+08:00", evidence_id: "DEMO-PRESSURE-26",
}, children: [] },
inference: { type: "Evidence", props: {
title: "待核实的压力判断", summary: "单次压力差异不足以确认漏损,需要后续核查。", source: "基于演练资料的推断",
kind: "inference", observed_at: null, evidence_id: null,
}, children: [] },
location: { type: "MapLocation", props: { label: "定位演练北区入口", layer: "junctions", feature_ids: ["DEMO-NORTH-26"], source: "演练对象" }, children: [] },
form: { type: "FollowUpForm", props: {
title: "补充现场核查", description: "输入的资料由你确认后发送。",
fields: [{ name: "site_record", label: "现场核查记录", kind: "text", required: true }],
}, children: [] },
},
},
};
for (const width of [1440, 320]) {
test(`keeps display-only answer data and form drafts while expanding at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
await mockAgentApi(page);
const prompts: string[] = [];
await page.route("**/api/v1/responses", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback();
const body = route.request().postDataJSON() as {
input: Array<{ role: string; content: Array<{ type: string; text?: string }> }>;
};
prompts.push(body.input.filter((item) => item.role === "user").at(-1)?.content
.filter((part) => part.type === "input_text").map((part) => part.text ?? "").join("") ?? "");
const answer = prompts.length === 1 ? "两处压力存在差异,仍需结合现场记录判断原因。" : "已收到补充问题,继续核对演练资料。";
await route.fulfill({
headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
body: responsesFixture([
{ type: "start", messageId: `intelligent-answer-${prompts.length}` },
{ type: "text-start", id: "answer" },
{ type: "text-delta", id: "answer", delta: answer },
{ type: "text-end", id: "answer" },
...(prompts.length === 1 ? [{ type: "data-answer_ui", id: comparison.block_id, data: comparison }] : []),
{ type: "data-final_answer", data: { content: answer, ui_block_ids: prompts.length === 1 ? [comparison.block_id] : [] } },
{ type: "finish", finishReason: "stop" },
]),
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板", includeHidden: true });
const composer = panel.locator(".agent-panel-prompt-textarea");
await composer.fill("比较这组演练监测资料,协助我选择继续核查的依据。");
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
const answer = panel.locator('[data-answer-ui="pressure-comparison"]');
await expect(answer).toHaveCount(1);
await expect(answer.getByRole("note")).toContainText("浏览器演练资料");
await answer.getByRole("textbox", { name: "搜索演练监测记录" }).fill("北区");
await answer.getByRole("button", { name: /演练记录来源.*浏览器验收数据/ }).click();
await answer.getByLabel("现场核查记录(必填)").fill("留待核对的现场草稿");
await expect(answer.getByRole("button", { name: "收起补充资料", exact: true })).toHaveCount(0);
await expect(answer.getByText("直接探索已加载数据", { exact: true })).toHaveCount(0);
await expect(answer.getByText("区分记录、推断与用户资料", { exact: true })).toHaveCount(0);
await expect(answer.getByLabel("现场核查记录(必填)")).toHaveValue("留待核对的现场草稿");
await expect(answer.getByRole("textbox", { name: "搜索演练监测记录" })).toHaveValue("北区");
expect(prompts).toHaveLength(1);
await panel.getByRole("button", { name: "展开查看", exact: true }).click();
const workspace = page.getByRole("region", { name: "展开报告工作区", exact: true });
await expect(workspace.getByLabel("现场核查记录(必填)")).toHaveValue("留待核对的现场草稿");
const backdrop = page.locator(".report-reading-backdrop");
await expect(backdrop).toBeVisible();
const backdropBounds = (await backdrop.boundingBox())!;
expect(backdropBounds).toEqual({ x: 0, y: 0, width, height: 900 });
const reportDialog = page.getByRole("dialog", { name: "对话中的分析报告", exact: true });
await expect(reportDialog).toBeVisible();
await expect(reportDialog).toHaveCSS("animation-name", "none");
await expect(reportDialog).toHaveCSS("border-radius", "12px");
const readingBounds = (await workspace.boundingBox())!;
expect(readingBounds.x).toBeGreaterThan(0);
expect(readingBounds.y).toBeGreaterThan(0);
expect(readingBounds.x + readingBounds.width).toBeLessThan(width);
expect(readingBounds.y + readingBounds.height).toBeLessThan(900);
expect(await workspace.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(false);
await page.evaluate(() => document.fonts.ready);
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => {
for (const button of buttons) (button as HTMLButtonElement).click();
});
await page.screenshot({ path: testInfo.outputPath(`intelligent-workspace-${width}.png`) });
await page.keyboard.press("Escape");
await expect(workspace).toHaveCount(0);
await expect(backdrop).toHaveCount(0);
await expect(answer.getByRole("checkbox")).toHaveCount(0);
await expect(answer.getByRole("button", { name: /加入分析|针对.*提问/ })).toHaveCount(0);
await expect(answer.getByRole("region", { name: "分析内容篮" })).toHaveCount(0);
expect(prompts).toHaveLength(1);
const horizontalOverflow = await panel.evaluate((element) => element.scrollWidth > element.clientWidth);
expect(horizontalOverflow).toBe(false);
await composer.fill("核查".repeat(5001));
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
await expect(panel.getByRole("alert")).toContainText("超过 10,000 字");
await expect(composer).toHaveValue("核查".repeat(5001));
expect(prompts).toHaveLength(1);
await composer.fill("根据这份演练资料继续核查北区压力。");
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
await expect.poll(() => prompts.length).toBe(2);
expect(prompts[1]).toContain("根据这份演练资料继续核查北区压力");
expect(prompts[1]).not.toContain("引用内容块");
await expect(panel.getByText("已收到补充问题,继续核对演练资料。", { exact: true })).toBeVisible();
if (width === 1440) {
await page.emulateMedia({ reducedMotion: "no-preference" });
await panel.getByRole("button", { name: "展开查看", exact: true }).click();
await expect(reportDialog).toBeVisible();
await expect(reportDialog).not.toHaveCSS("animation-name", "none");
await reportDialog.evaluate(async (element) => {
await Promise.allSettled(element.getAnimations({ subtree: true }).filter((animation) => Number.isFinite(Number(animation.effect?.getComputedTiming().endTime))).map((animation) => animation.finished));
});
await expect(workspace.getByLabel("现场核查记录(必填)")).toHaveValue("留待核对的现场草稿");
await page.keyboard.press("Escape");
await expect(reportDialog).toHaveCount(0);
await expect(panel.getByRole("button", { name: "展开查看", exact: true })).toBeFocused();
}
});
}
const markdownReport = `## 演练压力核查报告
这是一份浏览器演练报告。请结合记录时间核查压力差异,并保留原始精度与单位。
### 监测记录
| 监测对象 | 压力(MPa) | 记录时间 | 核查状态 |
| --- | ---: | --- | --- |
| 演练北区入口 | 0.2130 | 08:10 | 待现场核查 |
| 演练南区入口 | 0.2870 | 08:10 | 待现场核查 |
> 这些数据仅用于界面验收,不代表真实供水情况。
### 核查流程
\`\`\`mermaid
flowchart LR
A[核对监测记录] --> B[比较压力差异]
B --> C[补充现场资料]
C --> D[人工复核结论]
\`\`\`
`;
for (const width of [1440, 320]) {
test(`markdown report tables and diagrams stay readable in bounded windows at ${width}px`, async ({ page, context }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
await mockAgentApi(page);
await page.route("**/api/v1/responses", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback();
await route.fulfill({
headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
body: responsesFixture([
{ type: "start", messageId: "markdown-reading" },
{ type: "text-start", id: "answer" },
{ type: "text-delta", id: "answer", delta: markdownReport },
{ type: "text-end", id: "answer" },
{ type: "data-final_answer", data: { content: markdownReport, ui_block_ids: [] } },
{ type: "finish", finishReason: "stop" },
]),
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板", includeHidden: true });
await panel.locator(".agent-panel-prompt-textarea").fill("请整理演练压力报告。");
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
await expect(panel.getByRole("heading", { name: "演练压力核查报告" })).toBeVisible();
await expect(panel.locator(".agent-chat-message-header")).toHaveCount(0);
const paragraph = panel.getByText("这是一份浏览器演练报告。请结合记录时间核查压力差异,并保留原始精度与单位。", { exact: true });
expect(await paragraph.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize))).toBeGreaterThanOrEqual(15);
const table = panel.locator('[data-streamdown="table-wrapper"]');
await expect(table.getByRole("cell", { name: "0.2130", exact: true })).toBeVisible();
const graph = panel.locator("[data-markdown-diagram]");
// Streamdown defers diagram rendering until the block enters the viewport.
await graph.scrollIntoViewIfNeeded();
await expect(graph.locator("svg[id]")).toHaveCount(1);
const assertBounded = async (dialog: ReturnType<typeof page.getByRole>) => {
await expect(dialog).toBeVisible();
await dialog.evaluate(async (element) => {
await Promise.allSettled(element.getAnimations().filter((animation) => Number.isFinite(Number(animation.effect?.getComputedTiming().endTime))).map((animation) => animation.finished));
});
const bounds = (await dialog.boundingBox())!;
expect(bounds.x).toBeGreaterThan(0);
expect(bounds.y).toBeGreaterThan(0);
expect(bounds.x + bounds.width).toBeLessThan(width);
expect(bounds.y + bounds.height).toBeLessThan(900);
expect(await dialog.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(false);
};
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await table.getByRole("button", { name: "复制表格", exact: true }).click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain("0.2130");
await table.getByRole("button", { name: /放大查看|View fullscreen/ }).click();
const tableDialog = page.getByRole("dialog");
await assertBounded(tableDialog);
await expect(tableDialog).not.toContainText("完整数据保留在表格内");
await expect(table.locator("table")).toBeVisible();
await expect(table.locator("table")).toContainText("0.2130");
await expect(table.getByRole("button", { name: "放大查看表格", exact: true, includeHidden: true })).toHaveText("");
await expect(tableDialog.getByRole("cell", { name: "0.2130", exact: true })).toBeVisible();
await page.evaluate(() => document.fonts.ready);
await page.getByRole("button", { name: "关闭通知", exact: true, includeHidden: true }).evaluateAll((buttons) => { for (const button of buttons) (button as HTMLButtonElement).click(); });
await page.screenshot({ path: testInfo.outputPath(`markdown-table-${width}.png`) });
await page.keyboard.press("Escape");
await expect(tableDialog).toHaveCount(0);
await expect(table.getByRole("button", { name: "放大查看表格", exact: true })).toBeFocused();
await graph.getByRole("button", { name: /放大查看|View fullscreen/ }).click();
const graphDialog = page.getByRole("dialog");
await assertBounded(graphDialog);
await expect(graphDialog.locator("svg[id]")).toHaveCount(1);
await graphDialog.getByRole("button", { name: "Zoom in", exact: true }).click();
const zoom = "matrix(1.1, 0, 0, 1.1, 0, 0)";
await expect(graphDialog.locator('[role="application"]')).toHaveCSS("transform", zoom);
const labelBounds = (await graphDialog.locator(".nodeLabel").first().boundingBox())!;
expect(labelBounds.height).toBeGreaterThanOrEqual(18);
await page.screenshot({ path: testInfo.outputPath(`markdown-mermaid-${width}.png`) });
await page.keyboard.press("Escape");
await expect(graphDialog).toHaveCount(0);
await expect(graph.getByRole("button", { name: "放大查看图示", exact: true })).toBeFocused();
await expect(graph.locator('[role="application"]')).toHaveCSS("transform", zoom);
expect(await panel.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(false);
});
}
@@ -0,0 +1,99 @@
import { expect, test } from "@playwright/test";
import { responseFixtureWriter } from "./support/responses-fixture";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { mockScadaApi } from "./support/mock-scada-api";
for (const layer of ["pipes", "scada"] as const) {
test(`Agent location matches map-debug selection for ${layer} and clears independently`, async ({ page }, testInfo) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockScadaApi(page, [{ device_id: "D1", device_type: "pressure", node_id: "J1", link_id: null, api_query_id: null,
measurement_unit: "m", transmission_mode: "test", transmission_frequency: "1min", reliability: 1,
x: null, y: null, longitude: 121.48, latitude: 31.1 }]);
await page.route("**/runtime-config.js", (route) => route.fulfill({ contentType: "application/javascript", body:
'globalThis.__TJWATER_CONFIG__={TJWATER_AUTH_MODE:"disabled",TJWATER_SERVER_API_BASE_URL:"https://tjwater-api.invalid",TJWATER_AGENT_URL:"http://127.0.0.1:8787",TJWATER_MAP_URL:"https://workbench-map.invalid/geoserver",TJWATER_GEOSERVER_WORKSPACE:"tjwater_next",TJWATER_ENABLE_DEV_PANEL:"true",TJWATER_ENABLE_MSW:"false"};' }));
await mockAgentApi(page);
const id = layer === "pipes" ? "P-1" : "D1";
await page.route("**/geoserver/**", (route) => {
if (!new URL(route.request().url()).searchParams.get("cql_filter")) return route.fallback();
return route.fulfill({ json: { type: "FeatureCollection", features: [{ type: "Feature", properties: { id: "P-1", diameter: 400 },
geometry: { type: "LineString", coordinates: [[121.475, 31.1], [121.485, 31.105]] } }] } });
});
let receipts = 0;
await page.route("**/api/v1/frontend-actions/*/result", (route) => {
if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*", "Access-Control-Allow-Methods": "POST, OPTIONS" } });
expect(route.request().postDataJSON().status).toBe("succeeded");
receipts++;
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { accepted: true } });
});
await page.route("**/api/v1/responses", (route) => {
const body = route.request().postDataJSON();
const now = Date.now();
const sessionId = body.conversation ?? "style-session";
const write = responseFixtureWriter(sessionId);
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" }, body: [
{ type: "start", messageId: `style-${layer}` },
{ type: "data-frontend_action", data: { version: "frontend-action@2", actionId: `locate-${layer}`, toolCallId: `locate-${layer}`,
sessionId, command: { action: "locate_features", layer, feature_ids: [id] }, issuedAt: now, expiresAt: now + 15000 } },
{ type: "data-final_answer", data: { content: "已定位目标。" } }, { type: "finish", finishReason: "stop" }
].map(write).join("") });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-selected")));
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const dev = page.getByTestId("map-dev-panel");
await dev.getByRole("tab", { name: "地图调试", exact: true }).click();
if (layer === "scada") {
await dev.getByRole("combobox", { name: "业务源" }).click();
await page.getByRole("option", { name: "SCADA", exact: true }).click();
}
await dev.getByRole("textbox", { name: "要素 ID(可多个)" }).fill(id);
await dev.getByRole("button", { name: "定位并高亮", exact: true }).click();
await expect(dev.getByRole("status").filter({ hasText: "本次命中" })).toContainText("本次命中 1 个");
const manualState = () => page.evaluate(({ layer, id }) => window.__waterNetworkMap?.getFeatureState(
layer === "scada" ? { source: layer, id } : { source: layer, sourceLayer: layer, id }
).highlighted ?? false, { layer, id });
await expect.poll(manualState).toBe(true);
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.locator("textarea").fill("定位目标");
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
await expect.poll(() => receipts).toBe(1);
const paints = await page.evaluate((layer) => {
const map = window.__waterNetworkMap!;
const shape = layer === "pipes" ? "line" : "circle";
const pick = (source: string, native: boolean) => map.getStyle().layers.filter((item) => "source" in item && item.source === source && item.type === shape && (!native || /-selected(?:-|$)/.test(item.id)))
.map((item) => ({ minzoom: item.minzoom, paint: Object.fromEntries(Object.entries("paint" in item ? item.paint ?? {} : {}).filter(([key]) => !key.includes("opacity") && !key.endsWith("-transition"))) }));
return { native: pick(layer, true), agent: pick("chat-presentation-highlights", false) };
}, layer);
expect(paints.native.length).toBeGreaterThan(0);
expect(paints.agent).toEqual(paints.native);
const renderedAgent = () => page.evaluate((id) => {
const map = window.__waterNetworkMap!;
const layers = map.getStyle().layers.filter((item) => "source" in item && item.source === "chat-presentation-highlights").map((item) => item.id);
return map.queryRenderedFeatures(undefined, { layers }).some((feature) => String(feature.properties?.id ?? feature.properties?.device_id ?? feature.id) === id);
}, id);
await expect.poll(renderedAgent).toBe(true);
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
await dev.getByRole("tab", { name: "地图调试", exact: true }).click();
await dev.getByRole("button", { name: "清除高亮", exact: true }).click();
await expect.poll(manualState).toBe(false);
const agentCount = () => page.evaluate(async () => (await (window.__waterNetworkMap?.getSource("chat-presentation-highlights") as { getData: () => Promise<{ features: unknown[] }> }).getData()).features.length);
await expect.poll(agentCount).toBe(1);
await expect.poll(renderedAgent).toBe(true);
await page.screenshot({ path: testInfo.outputPath(`${layer}-unified-location.png`) });
if (layer === "scada") {
await dev.getByRole("combobox", { name: "业务源" }).click();
await page.getByRole("option", { name: "SCADA", exact: true }).click();
}
await dev.getByRole("textbox", { name: "要素 ID(可多个)" }).fill(id);
await dev.getByRole("button", { name: "仅高亮", exact: true }).click();
await expect.poll(manualState).toBe(true);
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
await panel.getByRole("button", { name: "新建 Agent 对话", exact: true }).click();
await expect.poll(agentCount).toBe(0);
await expect.poll(manualState).toBe(true);
});
}
+53 -9
View File
@@ -4,29 +4,36 @@ import { mockAgentApi } from "./support/mock-agent-api";
import { mockScadaApi } from "./support/mock-scada-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
test("chart and history actions send success only after rendering, and final answer replaces process text", async ({ page }) => {
test("unified chart and map commands send success only after rendering, and final answer replaces process text", async ({ page }, testInfo) => {
test.setTimeout(60000);
await mockWorkbenchContext(page);
await page.route("**/runtime-config.js", (route) => route.fulfill({ contentType: "application/javascript", body: 'globalThis.__TJWATER_CONFIG__ = { TJWATER_AUTH_MODE:"disabled", TJWATER_SERVER_API_BASE_URL:"https://tjwater-api.invalid", TJWATER_AGENT_URL:"http://127.0.0.1:8787", TJWATER_MAP_URL:"https://workbench-map.invalid/geoserver", TJWATER_GEOSERVER_WORKSPACE:"tjwater_next", TJWATER_WORKBENCH_RUNTIME_MODE:"auto", TJWATER_ENABLE_MSW:"false", TJWATER_ENABLE_DEV_PANEL:"true" };' }));
await mockScadaApi(page, [{ device_id: "D1", device_type: "pressure", node_id: "J1", link_id: null, api_query_id: null, measurement_unit: "m", transmission_mode: "test", transmission_frequency: "1min", reliability: 1, x: null, y: null, longitude: 121.48, latitude: 31.1 }]);
await mockAgentApi(page);
const receipts: Array<{ actionId: string; status: string; output: unknown }> = [];
const chart = { title: "监测压力", chart_type: "line", x_data: ["t1", "t2"], series: [{ name: "D1 scada_raw", data: [20, 21] }] };
await page.route("**/api/v1/frontend-action-registry", (route) => route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { schema_version: "frontend-action-registry@1", actions: ["show_chart", "view_history", "zoom_to_map", "locate_features"].map((id) => ({ id, version: "frontend-action@1" })) } }));
await page.route("**/api/v1/frontend-action-registry", (route) => route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { schema_version: "frontend-action-registry@2", actions: ["open_chart", "close_chart", "highlight_features", "clear_highlights", "zoom_to_map", "locate_features"].map((id) => ({ id, version: "frontend-action@2" })) } }));
await page.route("**/api/v1/frontend-actions/*/result", async (route) => {
if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*", "Access-Control-Allow-Methods": "POST, OPTIONS" } }); return; }
receipts.push(route.request().postDataJSON());
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { accepted: true } });
});
let responseNumber = 0;
await page.route("**/api/v1/responses", async (route) => {
responseNumber += 1;
const now = Date.now();
const action = (name: string, params: unknown, actionId: string) => ({ version: "frontend-action@1", actionId, toolCallId: actionId, sessionId: "migrated-session", name, params, issuedAt: now, expiresAt: now + 15000 });
const action = (command: Record<string, unknown>, actionId: string) => ({ version: "frontend-action@2", actionId, toolCallId: actionId, sessionId: "migrated-session", command, issuedAt: now, expiresAt: now + 15000 });
const commands = responseNumber === 2
? [{ action: "clear_highlights" }, { action: "close_chart", chart_id: "chat-pressure" }]
: [{ action: "highlight_features", layer: "scada", feature_ids: ["D1"], effect: "pulse" },
{ action: "open_chart", chart_id: "chat-pressure", chart, evidence_refs: [] }];
const chunks = [
{ type: "start", messageId: "assistant-migrated" },
{ type: "data-progress", data: { session_id: "migrated-session", id: "business-data", phase: "data", status: "completed", title: "获取业务数据" } },
{ type: "text-start", id: "text" }, { type: "text-delta", id: "text", delta: "正在整理过程说明" }, { type: "text-end", id: "text" },
{ type: "data-frontend_action", data: action("show_chart", chart, "chart-action") },
{ type: "data-frontend_action", data: action("view_history", { element_type: "junction", element_id: "J1", start: "2026-09-01T00:00:00Z", end: "2026-09-01T01:00:00Z", chart }, "history-action") },
{ type: "data-frontend_action", data: action("locate_features", { layer: "scada", feature_ids: ["D1", "missing"] }, "locate-action") },
{ type: "data-frontend_action", data: action("zoom_to_map", { center: [121.48, 31.1], zoom: 14.5 }, "zoom-action") },
...commands.map((command, index) => ({ type: "data-frontend_action", data: action(command, `command-${responseNumber}-${index}`) })),
...(responseNumber === 1 ? [{ type: "data-frontend_action", data: action({ action: "locate_features", layer: "scada", feature_ids: ["D1", "missing"] }, "locate-action") }] : []),
...(responseNumber === 1 ? [{ type: "data-frontend_action", data: action({ action: "zoom_to_map", center: [121.48, 31.1], zoom: 14.5 }, "zoom-action") }] : []),
{ type: "data-final_answer", data: { session_id: "migrated-session", content: "最终答复:压力曲线已核实。" } },
{ type: "finish", finishReason: "stop" },
];
@@ -40,10 +47,47 @@ test("chart and history actions send success only after rendering, and final ans
await page.getByRole("button", { name: "发送 Agent 指令" }).filter({ visible: true }).first().click();
await expect(page.getByText("最终答复:压力曲线已核实。", { exact: true })).toBeVisible();
await expect(page.getByText("正在整理过程说明", { exact: true })).toBeHidden();
await expect(page.locator('[data-agent-ui-result="chart-action"] svg[role="img"]')).toBeVisible();
await expect(page.locator('[data-agent-ui-result="history-action"] svg[role="img"]')).toBeVisible();
const display = page.getByLabel("对话展示", { exact: true });
await expect(display.locator('[data-investigation-chart="chat-pressure"] svg[role="img"]')).toBeVisible();
await expect.poll(() => receipts.length).toBe(4);
expect(receipts.find((receipt) => receipt.actionId === "locate-action")?.output).toEqual({ located_ids: ["D1"], missing_ids: ["missing"], fitted: true });
expect(receipts.every((receipt) => receipt.status === "succeeded")).toBe(true);
const sourceSize = (source: string) => page.evaluate(async (source) => {
const data = await (window.__waterNetworkMap?.getSource(source) as {
getData: () => Promise<{ features: unknown[] }>
} | undefined)?.getData();
return data?.features.length ?? 0;
}, source);
await expect.poll(() => sourceSize("chat-presentation-highlights")).toBe(1);
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const tools = page.getByTestId("investigation-dev-tools");
await tools.getByLabel("调查展示 JSONL", { exact: true }).fill([
{ record_type: "presentation", command: { action: "highlight_features", layer: "scada", feature_ids: ["D1"], effect: "pulse" } },
{ record_type: "presentation", command: { action: "open_chart", chart_id: "dev-pressure", chart, evidence_refs: [] } },
].map((record) => JSON.stringify(record)).join("\n"));
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(page.getByLabel("本地预览展示", { exact: true }).locator('[data-investigation-chart="dev-pressure"]')).toBeVisible();
await expect.poll(() => sourceSize("preview-presentation-highlights")).toBe(1);
await page.screenshot({ path: testInfo.outputPath("unified-chat-dev.png") });
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const send = async (text: string) => {
await prompt.fill(text);
await page.getByRole("button", { name: "发送 Agent 指令" }).filter({ visible: true }).first().click();
};
await send("清除本轮对话展示");
await expect.poll(() => receipts.length).toBe(6);
await expect(display.locator('[data-investigation-chart="chat-pressure"]')).toHaveCount(0);
await expect.poll(() => sourceSize("chat-presentation-highlights")).toBe(0);
await expect.poll(() => sourceSize("preview-presentation-highlights")).toBe(1);
await send("重新展示压力曲线");
await expect.poll(() => receipts.length).toBe(8);
await expect(display.locator('[data-investigation-chart="chat-pressure"]')).toBeVisible();
await page.getByRole("button", { name: "新建 Agent 对话", exact: true }).click();
await expect(display).toContainText("当前没有展示结果");
await expect(display.getByRole("combobox", { name: "切换展示动作" })).toBeVisible();
await expect.poll(() => sourceSize("chat-presentation-highlights")).toBe(0);
await expect.poll(() => sourceSize("preview-presentation-highlights")).toBe(1);
await expect(page.locator('[data-investigation-chart="dev-pressure"]')).toBeVisible();
});
@@ -0,0 +1,414 @@
import { expect, test } from "@playwright/test";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { responseFixtureWriter, responsesFixture } from "./support/responses-fixture";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
for (const width of [1440, 320]) {
for (const reducedMotion of ["reduce", "no-preference"] as const) {
test(`completed process uses one disclosure and exposes every step at ${width}px with ${reducedMotion} motion`, async ({
page
}, testInfo) => {
test.setTimeout(60_000);
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion });
await page.addInitScript(() => {
Object.defineProperty(window, "Audio", { configurable: true, value: class {
preload = ""; onended = null; onerror = null;
async play() {} pause() {} load() {} removeAttribute() {}
} });
});
let releaseSpeech!: () => void;
const speechReady = new Promise<void>((resolve) => { releaseSpeech = resolve; });
await page.route("**/api/tts/edge", async (route) => {
await speechReady;
await route.fulfill({ contentType: "audio/mpeg", body: "mock-audio" });
});
await mockWorkbenchContext(page);
await mockAgentApi(page);
const steps = [
{
id: "request",
title: "请求处理完成",
detail: "已核对本次请求中的管道编号。",
status: "completed"
},
{
id: "context",
title: "业务资料核对完成",
detail: "使用当前项目的查询结果。",
status: "completed"
},
{
id: "geocode",
phase: "tool",
title: "mcp_tjwater_geocode 已完成",
detail: "mcp_tjwater_geocode 已执行完成;关键参数:keyword=育秀路人民路路口",
status: "completed",
tool: {
name: "mcp_tjwater_geocode",
call_id: "geocode-call",
input: { keyword: "育秀路人民路路口" }
}
},
{
id: "nearby",
phase: "tool",
title: "mcp_tjwater_query_nearby_elements 已完成",
detail: "查询附近管道",
status: "completed",
tool: {
name: "mcp_tjwater_query_nearby_elements",
call_id: "nearby-call",
input: { center: [121.45688, 30.91065], element_type: "pipe", radius_m: 200, limit: 10 }
}
},
{
id: "missing",
title: "一处关联要素未找到",
detail: "未将缺失要素标记为定位成功。",
status: "error"
}
];
await page.route("**/api/v1/responses", (route) =>
route.fulfill({
headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
body: responsesFixture([
{ type: "start", messageId: "process-example" },
...steps.map((step) => ({
type: "data-progress",
data: { phase: "visualization", ...step }
})),
{
type: "data-final_answer",
data: { content: "已定位三根管道,另有一处关联要素未找到。" }
},
{ type: "finish", finishReason: "stop" }
])
})
);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page
.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ })
.click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel
.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" })
.fill("核对管道定位结果");
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
await expect(
panel.getByText("已定位三根管道,另有一处关联要素未找到。", { exact: true })
).toBeVisible();
const process = panel.locator("details[data-agent-process]");
await expect(process).not.toHaveAttribute("open");
const toggle = panel.getByRole("button", { name: "查看处理过程", exact: true });
await toggle.scrollIntoViewIfNeeded();
const toolbar = panel.locator("[data-agent-message-toolbar]").last();
const voice = toolbar.getByRole("button", { name: "语音播放", exact: true });
const copy = toolbar.getByRole("button", { name: "复制回复", exact: true });
await expect(voice).toBeVisible();
await expect(copy).toBeVisible();
const boxes = await Promise.all([voice, toggle, copy, toolbar].map((node) => node.boundingBox()));
expect(boxes[1]!.x).toBeLessThan(boxes[0]!.x);
expect(boxes[0]!.x).toBeLessThan(boxes[2]!.x);
expect(Math.abs(boxes[1]!.x - boxes[3]!.x)).toBeLessThanOrEqual(1);
const centers = boxes.slice(0, 3).map((box) => box!.y + box!.height / 2);
expect(Math.max(...centers) - Math.min(...centers)).toBeLessThanOrEqual(1);
expect(Math.abs(boxes[2]!.x + boxes[2]!.width - boxes[3]!.x - boxes[3]!.width)).toBeLessThanOrEqual(1);
for (const control of [voice, toggle, copy]) {
await expect(control.locator("svg").first()).toHaveCSS("width", "16px");
await expect(control.locator("svg").first()).toHaveCSS("height", "16px");
}
console.log("reply-toolbar-geometry", width, JSON.stringify(boxes));
await toolbar.screenshot({ path: testInfo.outputPath(`reply-toolbar-${width}.png`) });
if (reducedMotion === "reduce") {
await voice.click();
await expect(toolbar.getByRole("button", { name: "正在生成语音", exact: true })).toBeVisible();
await expect(process).not.toHaveAttribute("open");
releaseSpeech();
await expect(toolbar.getByRole("button", { name: "暂停语音播放", exact: true })).toBeVisible();
expect(await toolbar.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await toolbar.screenshot({ path: testInfo.outputPath(`reply-toolbar-playing-${width}.png`) });
await toolbar.getByRole("button", { name: "暂停语音播放", exact: true }).click();
await toolbar.getByRole("button", { name: "继续语音播放", exact: true }).click();
await toolbar.getByRole("button", { name: "停止语音播放", exact: true }).click();
await expect(voice).toBeVisible();
await expect(copy).toBeVisible();
await expect(process).not.toHaveAttribute("open");
}
await expectStableToggle(page, toggle, process);
await expect(process).toHaveAttribute("open", "");
await expect(process).toHaveCSS("background-color", "rgb(255, 255, 255)");
await expect(process.locator("[data-agent-progress]")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
const successIcons = process.locator('.status-icon[data-status-tone="success"] svg');
await expect(successIcons).toHaveCount(4);
expect(new Set(await successIcons.evaluateAll((icons) => icons.map((icon) => icon.getAttribute("class")))).size).toBe(1);
await expect(successIcons.first()).toHaveCSS("width", "14px");
await toggle.screenshot({ path: testInfo.outputPath(`process-trigger-open-${width}.png`) });
await expect(process.getByRole("list", { name: "全部执行进度" }).locator("li")).toHaveCount(
5
);
await expect(process.getByRole("button", { name: /执行进度/ })).toHaveCount(0);
await expect(process.getByText("执行进度", { exact: true })).toHaveCount(0);
await expect(process.locator("details[data-agent-tool]")).toHaveCount(2);
const geocode = process
.locator("details[data-agent-tool]")
.filter({ has: page.getByText("mcp_tjwater_geocode", { exact: true }) });
const nearby = process
.locator("details[data-agent-tool]")
.filter({ has: page.getByText("mcp_tjwater_query_nearby_elements", { exact: true }) });
await expect(geocode.locator("pre")).toBeHidden();
await expect(nearby.locator("pre")).toBeHidden();
for (const tool of [geocode, nearby]) {
await expect(tool).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(tool).toHaveCSS("border-top-width", "0px");
}
await expect(process.getByText("mcp_tjwater_geocode 已完成", { exact: true })).toHaveCount(0);
const toolToggle = geocode.locator(":scope > summary");
await toolToggle.scrollIntoViewIfNeeded();
await expectStableToggle(page, toolToggle, geocode);
await expect(geocode.locator("pre")).toContainText("育秀路人民路路口");
await expectStableToggle(page, toolToggle, geocode);
await expect(geocode.locator("pre")).toBeHidden();
await nearby.locator(":scope > summary").click();
await expect(nearby.locator("pre")).toContainText("121.45688");
await expect(nearby.locator("pre")).toContainText('"radius_m": 200');
await expect(nearby).toHaveAttribute("data-disclosure-motion", "idle");
await expect(process.getByText("一处关联要素未找到", { exact: true })).toBeVisible();
await expect(process.getByLabel("失败", { exact: true })).toBeVisible();
expect(
await process.evaluate((element) => element.scrollWidth - element.clientWidth)
).toBeLessThanOrEqual(1);
const alignment = await measureProcessAlignment(process);
console.log("process-icon-alignment", width, reducedMotion, JSON.stringify(alignment));
expect(
Math.max(...alignment.statusCentersX.map((x) => Math.abs(x - alignment.headerCenterX)))
).toBeLessThanOrEqual(1);
expect(Math.abs(alignment.headerTextLeft - alignment.stepTextLeft)).toBeLessThanOrEqual(1);
expect(Math.abs(alignment.parameterTextLeft - alignment.toolParameterAnchorLeft)).toBeLessThanOrEqual(1);
for (const row of alignment.toolTitles) {
for (const y of [row.statusCenterY, row.symbolCenterY, row.chevronCenterY])
expect(Math.abs(y - row.firstLineCenterY)).toBeLessThanOrEqual(1);
}
await process.screenshot({ path: testInfo.outputPath(`single-process-${width}.png`) });
await toggle.focus();
await page.keyboard.press("Enter");
await expect(process).not.toHaveAttribute("open");
await expect(process.getByText("mcp_tjwater_geocode", { exact: true })).toBeHidden();
await toggle.focus();
await page.keyboard.press("Enter");
await expect(process).toHaveAttribute("open", "");
await expect(process).toHaveAttribute("data-disclosure-motion", "idle");
await expect(process.locator(":scope > [data-agent-disclosure-content]")).toHaveCSS(
"opacity",
"1"
);
await page.keyboard.press("Enter");
await expect(process).not.toHaveAttribute("open");
await expect(
panel.getByText("已定位三根管道,另有一处关联要素未找到。", { exact: true })
).toBeVisible();
});
}
}
for (const width of [1440, 320]) {
test(`process stays below growing text and folds without final_answer at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
let appendText!: () => void;
let finish!: () => void;
const server = createServer((_request, response) => {
response.writeHead(200, { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" });
const encode = responseFixtureWriter();
const send = (chunk: Record<string, unknown>) => response.write(encode(chunk));
send({ type: "start", messageId: "legacy-process" });
send({ type: "data-progress", data: { id: "query", phase: "tool", status: "error", title: "查询超时", detail: "请稍后重试。" } });
send({ type: "data-todo_update", data: { session_id: "legacy-session", todos: [{ id: "verify", content: "核对模拟时段", status: "completed" }] } });
send({ type: "data-answer_ui", data: {
version: "answer-ui@1", block_id: "process-note", phase: "process", fallback_text: "已核对时段映射。",
spec: { root: "note", elements: { note: { type: "Text", props: { text: "已核对时段映射。", tone: "neutral" }, children: [] } } }
} });
send({ type: "text-start", id: "answer" });
send({ type: "text-delta", id: "answer", delta: "三根管道的模拟结果已整理。" });
appendText = () => send({ type: "text-delta", id: "answer", delta: "\n\n这些结果属于模型计算,仍需核查现场情况。" });
finish = () => {
send({ type: "text-end", id: "answer" });
send({ type: "finish", finishReason: "stop" });
response.end();
};
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
await page.route("**/api/v1/responses", (route) => route.continue({ url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/stream` }));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("核对模拟结果");
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
const message = panel.locator('[data-agent-message-id="legacy-process"]');
const report = message.locator("[data-answer-parts]").first();
const progress = message.locator("[data-agent-progress]");
const note = message.getByText("已核对时段映射。", { exact: true });
const todo = message.getByText("核对模拟时段", { exact: true });
await expect(report).toContainText("三根管道的模拟结果已整理。");
for (const process of [progress, note, todo]) {
await expect(process).toBeVisible();
expect((await process.boundingBox())!.y).toBeGreaterThanOrEqual((await report.boundingBox())!.y + (await report.boundingBox())!.height);
}
appendText();
await expect(report).toContainText("仍需核查现场情况。");
expect((await progress.boundingBox())!.y).toBeGreaterThanOrEqual((await report.boundingBox())!.y + (await report.boundingBox())!.height);
await message.screenshot({ path: testInfo.outputPath(`process-streaming-below-${width}.png`) });
// A tall-message screenshot can move the narrow viewport away from the
// bottom. This case verifies automatic archival while following latest;
// reader-initiated expansion is covered by agent-answer-transition.
const latest = panel.getByRole("button", { name: "滚动到最新消息", exact: true });
if (await latest.isVisible()) await latest.click();
await expect(latest).toBeHidden();
finish();
const toggle = message.getByRole("button", { name: "查看处理过程", exact: true });
await expect(toggle).toBeVisible();
await expect(progress).toBeHidden();
await expect(note).toBeHidden();
await expect(todo).toBeHidden();
await expect(report).toContainText("三根管道的模拟结果已整理。");
await expect(message.getByText("部分查询或工具未完成,可展开处理过程查看原因。", { exact: true })).toBeVisible();
const toolbar = message.locator("[data-agent-message-toolbar]");
const speech = toolbar.getByRole("button", { name: "语音播放", exact: true });
expect((await toggle.boundingBox())!.x).toBeLessThan((await speech.boundingBox())!.x);
expect((await toolbar.boundingBox())!.y).toBeGreaterThanOrEqual((await report.boundingBox())!.y + (await report.boundingBox())!.height);
await toggle.click();
await expect(progress).toBeVisible();
await expect(note).toBeVisible();
await expect(todo).toBeVisible();
await message.screenshot({ path: testInfo.outputPath(`process-completed-below-${width}.png`) });
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
}
test("completed todos use the process footer while pending questions and approvals stay visible", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
await page.route("**/api/v1/session/*/questions/question", (route) => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" }, json: { status: "pending" }
}));
let request = 0;
await page.route("**/api/v1/responses", (route) => {
const pending = request++ > 0;
return route.fulfill({
headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
body: responsesFixture([
{ type: "start", messageId: pending ? "pending-input" : "todo-only" },
{ type: "text-start", id: "answer" },
{ type: "text-delta", id: "answer", delta: pending ? "请确认核查范围。" : "核对工作已完成。" },
{ type: "data-todo_update", data: { session_id: "details-session", todos: [{ id: "verify", content: "核对管道资料", status: "completed" }] } },
...(pending ? [
{ type: "data-question_request", data: { request_id: "question", session_id: "details-session", questions: [{ header: "核查范围", question: "要核查哪个片区?", options: [{ label: "片区 A", description: "供水片区" }] }] } },
{ type: "data-permission_request", data: { request_id: "permission", session_id: "details-session", permission: "model.simulate", patterns: ["test-model"] } }
] : []),
{ type: "text-end", id: "answer" },
{ type: "finish", finishReason: "stop" }
])
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
const prompt = panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
const send = panel.getByRole("button", { name: "发送 Agent 指令", exact: true });
await prompt.fill("核对资料");
await send.click();
const first = panel.locator('[data-agent-message-id="todo-only"]');
await expect(first.getByText("核对管道资料", { exact: true })).toBeHidden();
await first.getByRole("button", { name: "查看处理过程", exact: true }).click();
await expect(first.getByText("核对管道资料", { exact: true })).toBeVisible();
await prompt.fill("补充核查范围");
await send.click();
const pending = panel.locator('[data-agent-message-id="pending-input"]');
await expect(pending.getByText("要核查哪个片区?", { exact: true })).toBeVisible();
await expect(pending.getByRole("button", { name: "允许一次", exact: true })).toBeVisible();
await expect(pending.getByRole("button", { name: "跳过", exact: true })).toBeVisible();
await expect(pending.getByRole("button", { name: "查看处理过程", exact: true })).toHaveCount(0);
});
async function expectStableToggle(
page: import("@playwright/test").Page,
toggle: import("@playwright/test").Locator,
disclosure: import("@playwright/test").Locator
) {
const before = await toggle.evaluate((element) => {
const message = element.closest<HTMLElement>("[data-agent-message-id]")!;
const scroll = element.closest<HTMLElement>(".agent-conversation-scroll")!;
const samples: number[] = [];
const start = performance.now();
(window as typeof window & { __processPositionSamples?: number[] }).__processPositionSamples =
samples;
const sample = () => {
samples.push(message.getBoundingClientRect().top);
if (performance.now() - start < 350 || samples.length < 12) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
return { messageTop: message.getBoundingClientRect().top, scrollTop: scroll.scrollTop };
});
await toggle.click();
await expect(disclosure).toHaveAttribute("data-disclosure-motion", "idle");
await page.waitForFunction(
() =>
((window as typeof window & { __processPositionSamples?: number[] }).__processPositionSamples
?.length ?? 0) >= 12
);
const samples = await page.evaluate(
() =>
(window as typeof window & { __processPositionSamples?: number[] })
.__processPositionSamples ?? []
);
expect(Math.max(...samples.map((top) => Math.abs(top - before.messageTop)))).toBeLessThanOrEqual(
2
);
}
async function measureProcessAlignment(process: import("@playwright/test").Locator) {
return process.evaluate((element) => {
const centerX = (node: Element) => {
const box = node.getBoundingClientRect();
return box.left + box.width / 2;
};
const centerY = (node: Element) => {
const box = node.getBoundingClientRect();
return box.top + box.height / 2;
};
const statuses = [...element.querySelectorAll("[data-agent-progress] .status-icon")];
const toolTitles = [...element.querySelectorAll("[data-agent-tool] summary")].map((row) => {
const text = row.querySelector("[data-tool-name]")!;
return {
statusCenterY: centerY(row.querySelector(".status-icon")!),
symbolCenterY: centerY(row.querySelector("[data-tool-symbol]")!),
chevronCenterY: centerY(row.querySelector("[data-tool-chevron]")!),
firstLineCenterY:
text.getBoundingClientRect().top +
Number.parseFloat(getComputedStyle(text).lineHeight) / 2
};
});
return {
headerCenterX: centerX(element.closest("[data-agent-message-id]")!.querySelector("[data-process-toggle-icon]")!),
headerTextLeft: element.closest("[data-agent-message-id]")!.querySelector("[data-process-toggle-label]")!.getBoundingClientRect().left,
statusCentersX: statuses.map(centerX),
toolTitles,
stepTextLeft: element
.querySelector("[data-agent-progress] li.grid > span.min-w-0")!
.getBoundingClientRect().left,
toolParameterAnchorLeft: element.querySelector("[data-agent-tool][open] [data-tool-symbol]")!.getBoundingClientRect().left,
parameterTextLeft: element
.querySelector("[data-agent-tool][open] [data-agent-disclosure-content] > div > p")!
.getBoundingClientRect().left
};
});
}
+6 -1
View File
@@ -37,7 +37,7 @@ test("submits a custom answer with the actionable id after a later tool placehol
}
}
]);
await expect(panel.getByText("已回答", { exact: true })).toBeVisible();
await expect(panel.getByText("已回答", { exact: true })).toHaveCount(1);
await expect(panel.getByRole("button", { name: "提交回答" })).toHaveCount(0);
});
@@ -111,6 +111,11 @@ async function mockQuestionApi(page: Page, replies: Array<{ pathname: string; bo
return;
}
if (request.method() === "GET" && pathname.includes("/session/question-session/questions/")) {
await route.fulfill({ json: { status: "pending" } });
return;
}
if (pathname.endsWith("/models")) {
await route.fulfill({
json: {
+20 -10
View File
@@ -4,7 +4,7 @@ import { mockAgentApi } from "./support/mock-agent-api";
import { captureReadingGeometry } from "./support/reading-geometry";
for (const width of [1600, 375, 320]) {
test(`formal rich reports preserve drafts, map and versioned references at ${width}px`, async ({ page }, testInfo) => {
test(`formal rich reports preserve drafts, map and report versions at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 960 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockAgentApi(page);
@@ -32,6 +32,10 @@ for (const width of [1600, 375, 320]) {
title: "供水压力演练核验", current_conclusion: "当前演练数据提示压力变化,仍待现场核验。",
conclusion_boundary: "仅覆盖已加载的演练记录。", stage: "final", outcome: "concluded",
data_gaps: [], recommendations: ["核对现场位置及对应时段。"], pending_items: [], evidence_refs: ["DEMO-001"],
diagram: { title: "核查流程", mermaid: "flowchart LR\nA[核对监测]", node_refs: [
{ node_id: "A", label: "核对监测", status: "completed", evidence_refs: ["DEMO-001"], action_refs: [],
map_targets: [{ source_id: "scada", feature_id: "DEMO-P001" }] },
] },
ui_blocks: [
{ block, evidence_refs: ["DEMO-001"] },
{ block: { version: "answer-ui@future", block_id: "future", fallback_text: "扩展分析摘要:等待下一次现场核验。", spec: {} }, evidence_refs: ["DEMO-001"] },
@@ -59,6 +63,7 @@ for (const width of [1600, 375, 320]) {
display: width < 1024 && reading.active ? "none" : "block",
} }, React.createElement(InvestigationTaskReport, { task,
onContinue: (discussion) => { Object.assign(window, { quotedReport: discussion }); },
onLocate: (targets) => { Object.assign(window, { diagramLocatedTargets: targets }); },
renderAction: noAction, renderEvidence: () => React.createElement("p", null, "原始演练记录"),
onLoadReports: async () => ({ items: [report, historical], next_offset: null }),
})),
@@ -83,7 +88,7 @@ for (const width of [1600, 375, 320]) {
await expect(workspace.getByRole("region", { name: "主要依据", exact: true })).toBeFocused();
await testInfo.attach("chapter-jump-geometry", {
body: JSON.stringify(await workspace.evaluate((root) => {
const scroll = root.querySelector("[data-reading-scroll]")!;
const scroll = root.querySelector('[data-reading-scroll="true"]')!;
return {
scrollTop: scroll.scrollTop,
maxScroll: scroll.scrollHeight - scroll.clientHeight,
@@ -96,7 +101,7 @@ for (const width of [1600, 375, 320]) {
});
await expect(chapters.getByRole("button", { name: "依据", exact: true })).toHaveAttribute("aria-current", "location");
const navigationTop = (await chapters.boundingBox())!.y;
const scrollTop = (await workspace.locator("[data-reading-scroll]").boundingBox())!.y;
const scrollTop = (await workspace.locator('[data-reading-scroll="true"]').boundingBox())!.y;
expect(Math.abs(navigationTop - scrollTop)).toBeLessThan(2);
await chapters.getByRole("button", { name: "分析", exact: true }).click();
await expect(workspace.getByRole("region", { name: "交互分析", exact: true })).toBeFocused();
@@ -140,7 +145,7 @@ for (const width of [1600, 375, 320]) {
await page.getByRole("button", { name: "返回报告", exact: true }).click();
}
expect(await map.evaluate((element) => element === (window as unknown as { originalMap: Element }).originalMap)).toBe(true);
await workspace.locator("[data-reading-scroll]").evaluate((element) => { element.scrollTop = 0; });
await workspace.locator('[data-reading-scroll="true"]').evaluate((element) => { element.scrollTop = 0; });
await expect(chapters.getByRole("button", { name: "结论", exact: true })).toHaveAttribute("aria-current", "location");
await page.evaluate(() => document.fonts.ready);
const geometry = await captureReadingGeometry(workspace, testInfo);
@@ -155,12 +160,17 @@ for (const width of [1600, 375, 320]) {
await page.getByRole("dialog", { name: "报告版本" }).getByRole("button", { name: /报告第 2 版/ }).click();
await expect(workspace).toContainText("历史结论:初次演练核验。");
await workspace.getByRole("tab", { name: "数据", exact: true }).click();
await workspace.getByRole("checkbox", { name: "选择第 2 行" }).check();
await workspace.locator("[data-interactive-table]").getByRole("button", { name: "针对这部分提问" }).click();
const quote = await page.evaluate(() => (window as unknown as { quotedReport: { context: { report_revision: number }; reference: { title: string; text: string } } }).quotedReport);
expect(quote.context.report_revision).toBe(2);
expect(quote.reference.title).toContain("报告第 2 版");
expect(quote.reference.text).toContain("0.28");
await expect(workspace.locator("[data-interactive-table]").getByRole("table")).toContainText("0.28");
await expect(workspace.locator("[data-interactive-table]").getByRole("checkbox")).toHaveCount(0);
await expect(workspace.getByRole("button", { name: /加入分析|针对这部分提问/ })).toHaveCount(0);
const diagram = workspace.getByRole("region", { name: "调查脉络", exact: true });
await diagram.getByRole("button", { name: /^核对监测 · 已完成/ }).click();
const nodeDetails = diagram.getByLabel("节点详情 核对监测");
await expect(nodeDetails).toContainText("原始演练记录");
await expect(nodeDetails.getByRole("button", { name: "围绕此节点提问" })).toHaveCount(0);
await nodeDetails.getByRole("button", { name: "在地图查看", exact: true }).click();
expect(await page.evaluate(() => (window as unknown as { diagramLocatedTargets: unknown }).diagramLocatedTargets))
.toEqual([{ source_id: "scada", feature_id: "DEMO-P001" }]);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
});
}
+14 -2
View File
@@ -20,13 +20,15 @@ test.beforeEach(async ({ page }) => {
});
for (const width of [1440, 375]) {
test(`model options show hover and keyboard highlight without changing selection at ${width}px`, async ({ page }) => {
test(`model options show hover and keyboard highlight without changing selection at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
// Radix hides the trigger from the accessibility tree while its modal menu is open.
const trigger = page.locator('button[aria-label="选择 Agent 模型"]');
await expect(trigger).toHaveAccessibleDescription("当前模型:快速");
await expectQuietCaretHover(trigger);
await page.locator("form.agent-panel-input-form").screenshot({ path: testInfo.outputPath("model-trigger-hover.png"), animations: "disabled" });
await trigger.click();
await waitForMenuAnimations(page.getByRole("menu"));
const fast = page.getByRole("menuitem", { name: /快速回答和日常任务处理/ });
@@ -52,11 +54,13 @@ for (const width of [1440, 375]) {
await expect(trigger).toBeFocused();
});
test(`approval options highlight both selected and unselected modes at ${width}px`, async ({ page }) => {
test(`approval options highlight both selected and unselected modes at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const trigger = page.locator('button[aria-label="权限批准模式"]');
await expectQuietCaretHover(trigger);
await page.locator("form.agent-panel-input-form").screenshot({ path: testInfo.outputPath("approval-trigger-hover.png"), animations: "disabled" });
await trigger.click();
await waitForMenuAnimations(page.getByRole("menu"));
const request = page.getByRole("menuitem", { name: /需要审批的请求逐次确认/ });
@@ -160,6 +164,14 @@ test("does not send a generation request when the refreshed catalog is empty", a
expect(requests).toEqual([]);
});
async function expectQuietCaretHover(trigger: Locator) {
const caret = trigger.locator(".agent-panel-toolbar-caret");
const idleColor = await caret.evaluate((element) => getComputedStyle(element).color);
await trigger.hover();
await expect(caret).toHaveCSS("color", idleColor);
await expect(trigger).toHaveCSS("border-radius", "10px");
}
async function waitForMenuAnimations(menu: Locator) {
await expect(menu).toBeVisible();
await menu.evaluate((element) => Promise.all(
@@ -0,0 +1,88 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
for (const width of [1440, 375, 320]) {
test(`task semantics keep results readable and comparisons responsive at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
const title = "管道定位核对";
await mockAgentApi(page, { sessions: [{ session_id: "semantic-answer", title, created_at: Date.now(), updated_at: Date.now() }] });
const result = (name: string, status: string, summary: string) => ({ type: "Result", props: { title: name, status, summary, source: "演练回执,仅用于界面验收" }, children: [] });
const response = {
version: "answer-ui@1", block_id: "result", fallback_text: "三根管道已定位,高亮未确认。",
spec: { root: "root", elements: {
root: { type: "Stack", props: { columns: "one", gap: "normal", intent: "response", density: "compact" }, children: ["result", "limit"] },
result: result("管道定位结果", "partial", "已将 533793、486121、523616 定位到地图视图。"),
limit: { type: "Text", props: { title: "能力限制", tone: "info", text: "当前工具未提供独立高亮操作,因此无法确认高亮。" }, children: [] },
} },
};
const comparison = {
version: "answer-ui@1", block_id: "comparison", fallback_text: "定位成功,高亮未确认。",
spec: { root: "root", elements: {
root: { type: "Stack", props: { columns: "one", gap: "normal", intent: "comparison", density: "comfortable" }, children: ["located", "highlighted"] },
located: result("地图定位", "success", "三根管道已定位。"),
highlighted: result("高亮核对", "unconfirmed", "缺少高亮回执,保留未确认状态。"),
} },
};
const compact = {
version: "answer-ui@1", block_id: "compact", fallback_text: "演练压力 0.30MPa。",
spec: { root: "root", elements: {
root: { type: "Stack", props: { columns: "one", gap: "normal" }, children: ["metric", "evidence"] },
metric: { type: "Metric", props: { label: "演练压力", value: "0.30", unit: "MPa", source: "演练读数" }, children: [] },
evidence: { type: "Evidence", props: { title: "演练来源", summary: "仅用于界面验收。", source: "浏览器演练", kind: "demo", evidence_id: "DEMO-UI", observed_at: null }, children: [] },
} },
};
const headers = { "Access-Control-Allow-Origin": "*" };
await page.route("**/api/v1/session/semantic-answer?**", (route) => route.fulfill({ headers, json: {
session_id: "semantic-answer", title, purpose: "ordinary_chat", status: "active", is_streaming: false,
messages: [response, comparison, compact].map((block) => ({ id: block.block_id, role: "assistant", parts: [
{ type: "data-answer_ui", data: block },
{ type: "data-final_answer", data: { content: block.block_id === "result" ? "定位与高亮的核对结果如下。" : "两个操作分别核对。", ui_block_ids: [block.block_id] } },
] })),
} }));
await page.route("**/api/v1/session/semantic-answer/interactions**", (route) => route.fulfill({ headers, json: { items: [] } }));
await page.route("**/api/v1/session/semantic-answer/ui-state**", (route) => {
const query = new URL(route.request().url()).searchParams;
return route.fulfill({ headers, json: {
version: "answer-view-state@1", message_id: query.get("message_id"), block_id: query.get("block_id"),
revision: 0, schema_digest: "semantic-answer-fixture", nodes: {}, block: { evidenceFilter: "all" }, updated_at: null,
} });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("button", { name: /历史/ }).click();
await page.locator("button.agent-history-session-trigger").filter({ hasText: title }).click();
const short = panel.locator('[data-answer-ui="result"]');
const compare = panel.locator('[data-answer-ui="comparison"]');
await expect(short.getByRole("group", { name: "管道定位结果" })).toHaveAttribute("data-result-status", "partial");
await expect(short.getByRole("note")).toHaveText("能力限制当前工具未提供独立高亮操作,因此无法确认高亮。");
await expect(panel.locator('[data-answer-state="saved"]')).toHaveCount(3);
await expect(panel.getByRole("button", { name: "展开查看", exact: true })).toHaveCount(0);
const compactAnswer = panel.locator('[data-answer-ui="compact"]');
await expect(compactAnswer.getByRole("group", { name: "演练压力" })).toBeVisible();
await expect(compactAnswer.getByRole("navigation", { name: "回答内容导航" })).toHaveCount(0);
await compactAnswer.getByRole("button", { name: /演练来源/ }).click();
await expect(compactAnswer.getByText("仅用于界面验收。", { exact: true })).toBeVisible();
await compactAnswer.screenshot({ path: `/tmp/agent-semantic-compact-${width}.png` });
const grid = compare.locator(".answer-ui-stack-comparison");
await expect(grid).toHaveAttribute("data-answer-intent", "comparison");
expect(await grid.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(width >= 1024 ? 2 : 1);
expect(await short.locator('[aria-label="回答概览"]').evaluate((element) => getComputedStyle(element).gap)).toBe("12px");
expect(await grid.evaluate((element) => getComputedStyle(element).gap)).toBe("16px");
const overflow = await panel.evaluate((element) => [...element.querySelectorAll("[data-answer-ui]")].some((node) => node.scrollWidth > node.clientWidth));
expect(overflow).toBe(false);
// Fixture map warnings may stack outside the viewport; dismiss them before visual capture.
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => {
for (const button of buttons) (button as HTMLButtonElement).click();
});
await expect(page.getByRole("button", { name: "关闭通知", exact: true })).toHaveCount(0);
await compare.scrollIntoViewIfNeeded();
await page.evaluate(() => document.fonts.ready);
await compare.screenshot({ path: `/tmp/agent-semantic-comparison-${width}.png` });
await short.scrollIntoViewIfNeeded();
await short.screenshot({ path: `/tmp/agent-semantic-result-${width}.png` });
});
}
+71
View File
@@ -0,0 +1,71 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
for (const width of [1440, 375, 320]) {
test(`short structured replies stay inline at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
const title = "管道定位结果";
await mockAgentApi(page, { sessions: [{ session_id: "short-answer", title, created_at: Date.now(), updated_at: Date.now() }] });
const content = "已将管道 **533793、486121、523616** 定位到地图视图。但当前可用操作未提供独立的管道高亮功能,所以不能确认它们已高亮。";
const warning = "已将三根管道定位到地图视图。当前可用工具未提供独立的管道高亮操作,因此不能确认已高亮。";
const block = {
version: "answer-ui@1", block_id: "location-note", fallback_text: warning,
spec: { root: "note", elements: { note: { type: "Text", props: { tone: "warning", text: warning }, children: [] } } },
};
await page.route("**/api/v1/session/short-answer?**", (route) => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
json: {
session_id: "short-answer", title, purpose: "ordinary_chat", status: "active", is_streaming: false,
messages: [{ id: "short-reply", role: "assistant", parts: [
{ type: "text", text: content }, { type: "data-answer_ui", data: block },
{ type: "data-final_answer", data: { content, ui_block_ids: ["location-note"] } },
] }],
},
}));
await page.route("**/api/v1/session/short-answer/ui-state**", (route) => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
json: {
version: "answer-view-state@1", message_id: "short-reply", block_id: "location-note",
revision: 0, schema_digest: "short-note-fixture", nodes: {},
block: { evidenceFilter: "all" }, updated_at: null,
},
}));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("button", { name: /历史/ }).click();
await page.locator("button.agent-history-session-trigger").filter({ hasText: title }).click();
const message = panel.locator('[data-agent-message-id="short-reply"]');
const note = message.getByRole("note");
await expect(note).toHaveText(`注意事项${warning}`);
await expect(message.locator('[data-answer-state="saved"]')).toBeVisible();
await page.evaluate(() => document.fonts.ready);
const geometry = await message.evaluate((element) => {
const paragraph = element.querySelector(".agent-streaming-response")!;
const header = element.querySelector(".agent-chat-message-header")!;
const note = element.querySelector('[role="note"]')!;
const description = note.querySelector('[data-slot="alert-description"]')!;
return {
headerToBody: header ? paragraph.getBoundingClientRect().top - header.getBoundingClientRect().bottom : 0,
bodyToNote: note.getBoundingClientRect().top - paragraph.getBoundingClientRect().bottom,
noteHeight: note.getBoundingClientRect().height,
noteBackground: getComputedStyle(note).backgroundColor,
bodyFont: getComputedStyle(paragraph).fontSize,
descriptionFont: getComputedStyle(description).fontSize,
overflows: element.scrollWidth > element.clientWidth,
};
});
console.log(JSON.stringify({ width, ...geometry }));
await message.screenshot({ path: `/tmp/agent-short-answer-${width}.png` });
await expect(message.getByRole("button", { name: "展开查看", exact: true })).toHaveCount(0);
expect(geometry.headerToBody).toBeLessThanOrEqual(16);
expect(geometry.bodyToNote).toBeGreaterThanOrEqual(19);
expect(geometry.bodyToNote).toBeLessThanOrEqual(21);
expect(geometry.noteBackground).toBe("rgb(255, 255, 255)");
expect(geometry.overflows).toBe(false);
await expect(message.getByText("533793、486121、523616", { exact: true })).toBeVisible();
});
}
+206
View File
@@ -0,0 +1,206 @@
import { expect, test, type Page } from "@playwright/test";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { responsesFixture } from "./support/responses-fixture";
const voiceCatalog = {
defaultVoice: "zh-CN-XiaoxiaoNeural",
voices: [
{ id: "zh-CN-XiaoxiaoNeural", name: "Xiaoxiao", locale: "zh-CN", gender: "Female" },
{ id: "zh-CN-YunyangNeural", name: "Yunyang", locale: "zh-CN", gender: "Male" },
{ id: "zh-CN-XiaoyiNeural", name: "Xiaoyi", locale: "zh-CN", gender: "Female" }
]
};
test("voice loading bypasses a cached catalog from an earlier application version", async ({ page, baseURL }) => {
let legacy = true;
let catalogRequests = 0;
const oldCatalog = { ...voiceCatalog, voices: [
...voiceCatalog.voices,
{ id: "en-US-AriaNeural", name: "Aria", locale: "en-US", gender: "Female" }
] };
const server = createServer((request, response) => {
if (request.url === "/api/tts/edge/voices") {
catalogRequests += 1;
response.setHeader("Content-Type", "application/json");
response.setHeader("Cache-Control", "private, max-age=3600");
response.end(JSON.stringify(legacy ? oldCatalog : voiceCatalog));
} else {
response.setHeader("Content-Type", "text/html");
response.end("<!doctype html><title>Speech catalog cache regression</title>");
}
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
try {
await page.goto(`http://127.0.0.1:${port}`);
expect(await page.evaluate(async () => (await (await fetch("/api/tts/edge/voices")).json()).voices.length)).toBe(4);
legacy = false;
// Confirm that ordinary fetch really reuses the cached legacy response.
expect(await page.evaluate(async () => (await (await fetch("/api/tts/edge/voices")).json()).voices.length)).toBe(4);
expect(catalogRequests).toBe(1);
const catalog = await page.evaluate(async (viteOrigin) => {
const { fetchSpeechVoiceCatalog } = await import(`${viteOrigin}/src/features/agent/speech-voice-catalog.ts`);
return fetchSpeechVoiceCatalog(`${location.origin}/api/tts/edge/voices`);
}, baseURL);
expect(catalog.voices).toEqual(voiceCatalog.voices);
expect(catalogRequests).toBe(2);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
async function openSpeechMessage(page: Page, options: { liveVoices?: boolean; catalogUnavailable?: boolean; onCatalogRead?: () => void } = {}) {
if (!options.liveVoices) await page.route("**/api/tts/edge/voices", (route) => {
options.onCatalogRead?.();
return options.catalogUnavailable
? route.fulfill({ status: 503, json: { error: "音色列表暂时不可用" } })
: route.fulfill({ json: voiceCatalog });
});
await mockWorkbenchContext(page);
await mockAgentApi(page);
await page.route("**/api/v1/responses", (route) => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
body: responsesFixture([
{ type: "start", messageId: "speech-example" },
{ type: "data-progress", data: { id: "checking", phase: "context", title: "运行情况核对完成", status: "completed" } },
{ type: "data-final_answer", data: { content: "当前供水运行平稳。" } },
{ type: "finish", finishReason: "stop" }
])
}));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: (page.viewportSize()?.width ?? 1440) < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("检查供水运行");
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
await expect(panel.getByText("当前供水运行平稳。", { exact: true })).toBeVisible();
return panel;
}
test("speech failure shows an explanation and playback can be retried", async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(window, "Audio", { configurable: true, value: class {
preload = ""; onended = null; onerror = null;
async play() {} pause() {} load() {} removeAttribute() {}
} });
});
let requests = 0;
await page.route("**/api/tts/edge", (route) => {
requests += 1;
return requests === 1
? route.fulfill({ status: 502, contentType: "application/json", body: JSON.stringify({ error: "语音生成失败" }) })
: route.fulfill({ contentType: "audio/mpeg", body: "mock-audio" });
});
const panel = await openSpeechMessage(page);
const play = panel.getByRole("button", { name: "语音播放", exact: true });
await play.click();
const alert = page.getByRole("alert").filter({ hasText: "语音播放失败" });
await expect(alert).toBeVisible();
await expect(alert).toContainText("语音生成失败");
await expect(play).toBeVisible();
await play.click();
await expect(panel.getByRole("button", { name: "暂停语音播放", exact: true })).toBeVisible();
await panel.getByRole("button", { name: "停止语音播放", exact: true }).click();
await expect(play).toBeVisible();
expect(requests).toBe(2);
});
for (const width of [1440, 360, 320]) {
test(`voice selection supports search, persistence and keyboard dismissal at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: width === 320 ? 640 : 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await page.addInitScript(() => {
Object.defineProperty(window, "Audio", { configurable: true, value: class {
preload = ""; onended = null; onerror = null;
async play() {} pause() {} load() {} removeAttribute() {}
} });
});
const requests: Array<{ text: string; voice?: string }> = [];
await page.route("**/api/tts/edge", (route) => {
requests.push(route.request().postDataJSON());
return route.fulfill({ contentType: "audio/mpeg", body: "mock-audio" });
});
let catalogReads = 0;
const panel = await openSpeechMessage(page, { onCatalogRead: () => { catalogReads += 1; } });
const picker = panel.getByRole("button", { name: /^选择朗读音色/ });
await expect(picker).toHaveAccessibleName("选择朗读音色,当前晓晓");
await picker.click();
const dialog = page.getByRole("dialog", { name: "选择朗读音色" });
await expect(dialog).toHaveCSS("background-color", "rgb(255, 255, 255)");
await expect(dialog).toHaveCSS("backdrop-filter", "none");
await expect(dialog.getByText("3 种音色,选择后下次播放生效")).toBeVisible();
await expect(dialog.getByRole("option")).toHaveCount(4);
await page.screenshot({ path: testInfo.outputPath(`voice-menu-${width}.png`) });
const bounds = await dialog.boundingBox();
expect(bounds!.x).toBeGreaterThanOrEqual(0);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width);
await dialog.getByRole("combobox", { name: "搜索音色" }).fill("云扬");
await dialog.getByRole("option", { name: /云扬/ }).click();
await expect(picker).toHaveAccessibleName("选择朗读音色,当前云扬");
await panel.getByRole("button", { name: "语音播放", exact: true }).click();
await expect(panel.getByRole("button", { name: "暂停语音播放", exact: true })).toBeVisible();
expect(await panel.locator("[data-agent-message-toolbar]").last().evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
expect(requests.at(-1)?.voice).toBe("zh-CN-YunyangNeural");
await panel.getByRole("button", { name: "停止语音播放", exact: true }).click();
await picker.click();
await dialog.getByRole("combobox", { name: "搜索音色" }).fill("晓伊");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
await expect(picker).toHaveAccessibleName("选择朗读音色,当前晓伊");
await picker.click();
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
await expect(panel).toBeVisible();
await expect(picker).toBeFocused();
expect(catalogReads).toBe(1);
await openSpeechMessage(page);
await expect(picker).toHaveAccessibleName("选择朗读音色,当前晓伊");
await panel.getByRole("button", { name: "语音播放", exact: true }).click();
await expect(panel.getByRole("button", { name: "暂停语音播放", exact: true })).toBeVisible();
expect(requests.at(-1)?.voice).toBe("zh-CN-XiaoyiNeural");
});
}
test("voice catalog failures can be retried without blocking the default choice", async ({ page }) => {
const panel = await openSpeechMessage(page, { catalogUnavailable: true });
await panel.getByRole("button", { name: /^选择朗读音色/ }).click();
const dialog = page.getByRole("dialog", { name: "选择朗读音色" });
await expect(dialog.getByRole("alert")).toContainText("音色列表加载失败");
await expect(dialog.getByRole("option", { name: /默认音色/ })).toBeVisible();
await page.route("**/api/tts/edge/voices", (route) => route.fulfill({ json: voiceCatalog }));
await dialog.getByRole("button", { name: "重新加载音色" }).click();
await expect(dialog.getByRole("option", { name: /云扬/ })).toBeVisible();
});
test("live Edge TTS lists Mandarin voices and plays the chosen voice in the browser", async ({ page }, testInfo) => {
test.skip(process.env.TJWATER_TEST_LIVE_TTS !== "true", "Requires the real Edge TTS upstream service");
test.setTimeout(60_000);
const panel = await openSpeechMessage(page, { liveVoices: true });
const catalog = await (await page.request.get("/api/tts/edge/voices")).json();
expect(catalog.voices.length).toBeGreaterThan(0);
expect(catalog.voices.every((voice: { locale: string }) => voice.locale === "zh-CN")).toBe(true);
const picker = panel.getByRole("button", { name: /^选择朗读音色/ });
await expect(picker).toHaveAccessibleName("选择朗读音色,当前晓晓", { timeout: 15_000 });
await picker.click();
const dialog = page.getByRole("dialog", { name: "选择朗读音色" });
await expect(dialog.getByRole("option")).toHaveCount(catalog.voices.length + 1);
await dialog.getByRole("combobox", { name: "搜索音色" }).fill("云扬");
await dialog.getByRole("option", { name: /云扬/ }).click();
const responsePromise = page.waitForResponse((response) => response.url().endsWith("/api/tts/edge"));
await panel.getByRole("button", { name: "语音播放", exact: true }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
expect(response.request().postDataJSON().voice).toBe("zh-CN-YunyangNeural");
expect(response.headers()["content-type"]).toBe("audio/mpeg");
expect(Number(response.headers()["content-length"])).toBeGreaterThan(0);
const pause = panel.getByRole("button", { name: "暂停语音播放", exact: true });
await expect(pause).toBeVisible({ timeout: 35_000 });
await pause.click();
await page.screenshot({ path: testInfo.outputPath("speech-paused.png") });
await panel.getByRole("button", { name: "继续语音播放", exact: true }).click();
await expect(pause).toBeVisible();
await panel.getByRole("button", { name: "停止语音播放", exact: true }).click();
await expect(panel.getByRole("button", { name: "语音播放", exact: true })).toBeVisible();
});
+1 -1
View File
@@ -103,7 +103,7 @@ test("multiline follow-up stays reachable in a short desktop viewport", async ({
await expect(prompt).toHaveValue("核对监测点\n补充现场记录");
const initialHeight = (await prompt.boundingBox())!.height;
expect(initialHeight).toBeGreaterThanOrEqual(64);
expect(initialHeight).toBeLessThanOrEqual(70);
expect(initialHeight).toBeLessThanOrEqual(80);
await prompt.fill("监测点、异常时间及现场核验记录。\n".repeat(30));
for (const height of [900, 640]) {
await page.setViewportSize({ width: 1280, height });
+51 -21
View File
@@ -7,7 +7,6 @@ import { expectThemeColor } from "./support/theme-colors";
const STREAM_CHUNK_SIZE = 18;
const STREAM_CHUNK_DELAY_MS = 20;
const PROGRESS_GEOMETRY_TOLERANCE_PX = 0.1;
let streamServer: Server;
let streamServerUrl: string;
@@ -180,6 +179,7 @@ test.afterAll(async () => {
test("stream completion and the plan card remain positionally stable", async ({ page }) => {
await mockScadaApi(page);
await page.route("http://127.0.0.1:8787/health", (route) => route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { ok: true, ready: true } }));
await page.route("**/runtime-config.js", (route) =>
route.fulfill({
contentType: "application/javascript",
@@ -215,6 +215,11 @@ test("stream completion and the plan card remain positionally stable", async ({
return;
}
if (path.endsWith("/interactions")) {
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { items: [] } });
return;
}
const body = path.endsWith("/models")
? {
default_model: "test/model",
@@ -311,6 +316,17 @@ test("stream completion and the plan card remain positionally stable", async ({
await sendButton.click();
const userMessage = page.locator(".agent-panel-user-message");
const assistantMessage = page.locator(".agent-panel-message").first();
const replyStatus = assistantMessage.getByText("正在回复", { exact: true });
await expect(replyStatus).toBeVisible();
await expect(assistantMessage.getByText("正在生成...", { exact: true })).toHaveCount(0);
const statusAlignment = await assistantMessage.evaluate((element) => {
const status = element.querySelector('[role="status"]')!;
const box = element.getBoundingClientRect();
return { left: status.getBoundingClientRect().left - box.left - parseFloat(getComputedStyle(element).paddingLeft), align: getComputedStyle(status).textAlign };
});
expect(Math.abs(statusAlignment.left)).toBeLessThanOrEqual(1);
expect(statusAlignment.align).toBe("left");
await expectThemeColor(userMessage, "background-color", "--agent-user-message-surface");
expect(
await userMessage.evaluate((element) => getComputedStyle(element).backgroundColor)
@@ -331,7 +347,6 @@ test("stream completion and the plan card remain positionally stable", async ({
const liveProgress = page.getByRole("list", { name: "最近执行进度" });
await expect(liveProgress.locator("li")).toHaveCount(3);
await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible();
await expect(liveProgress).toHaveCSS("height", "156px");
await expect.poll(() => liveProgress.evaluate((list) => list.style.height)).toBe("auto");
await page.evaluate(() => {
@@ -425,20 +440,17 @@ test("stream completion and the plan card remain positionally stable", async ({
).__agentProgressGeometry
);
expect(progressGeometry).toBeDefined();
expect(progressGeometry!.maxListHeight - progressGeometry!.minListHeight).toBeLessThanOrEqual(
PROGRESS_GEOMETRY_TOLERANCE_PX
);
expect(
progressGeometry!.maxContainerHeight - progressGeometry!.minContainerHeight
).toBeLessThanOrEqual(PROGRESS_GEOMETRY_TOLERANCE_PX);
// Progress descriptions now wrap exactly like archived process rows. Their
// natural height may change when the recent-step window advances.
expect(progressGeometry!.minListHeight).toBeGreaterThan(0);
const liveProgressRowHeights = await liveProgress
.locator("li")
.evaluateAll((rows) => rows.map((row) => row.getBoundingClientRect().height));
expect(liveProgressRowHeights.every((height) => Math.abs(height - 48) <= 0.01)).toBe(true);
expect(liveProgressRowHeights.every((height) => height >= 44)).toBe(true);
await expect(liveProgress.locator("li").first().locator("span.min-w-0 > span").first()).toHaveCSS(
"white-space",
"nowrap"
"normal"
);
const progressToggle = page
.getByRole("button", { name: /执行进度/ })
@@ -454,10 +466,7 @@ test("stream completion and the plan card remain positionally stable", async ({
const expandedLiveProgress = page.getByRole("list", { name: "全部执行进度" });
await expect(expandedLiveProgress.locator("li")).toHaveCount(4);
await expect(expandedLiveProgress.locator(".status-badge")).toHaveCount(0);
await expectThemeColor(
expandedLiveProgress.locator('[data-progress-status="running"]').first(),
"color", "--action-blue-hover"
);
await expect(expandedLiveProgress.getByRole("img", { name: "进行中", exact: true }).first()).toBeVisible();
await expect(expandedLiveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible();
await expect(
expandedLiveProgress.getByText("正在核对用水过程与供水分区响应关系", { exact: true })
@@ -480,11 +489,23 @@ test("stream completion and the plan card remain positionally stable", async ({
await expect(
page.getByRole("button", { name: "发送 Agent 指令" }).filter({ visible: true }).first()
).toBeVisible({ timeout: 10_000 });
await expect(progressToggle.locator("../..")).toContainText("步骤4/4");
const completedToggle = assistantMessage.getByRole("button", { name: "查看处理过程", exact: true });
await expect(completedToggle).toBeVisible();
await expect(progressToggle).toHaveCount(0);
await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0);
// Completion folds the process even after live steps were inspected.
await expect(assistantMessage.locator("details[data-agent-process]")).not.toHaveAttribute("open");
await completedToggle.click();
await expect(assistantMessage.locator("details[data-agent-process]")).toHaveAttribute("open", "");
await expect(
page.getByText("供水风险判断完成", { exact: true }).filter({ visible: true })
).toBeVisible();
await completedToggle.click();
await expect(
page.getByText("供水风险判断完成", { exact: true }).filter({ visible: true })
).toHaveCount(0);
const returnToLatest = page.getByRole("button", { name: "滚动到最新消息", exact: true });
if (await returnToLatest.isVisible()) await returnToLatest.click();
await expect
.poll(async () =>
page.evaluate(() => {
@@ -550,7 +571,7 @@ test("stream completion and the plan card remain positionally stable", async ({
Math.abs(finalSample!.scrollHeight - finalSample!.clientHeight - finalSample!.scrollTop)
).toBeLessThanOrEqual(2);
await progressToggle.click();
await completedToggle.click();
const expandedProgress = page.getByRole("list", { name: "全部执行进度" });
await expect(expandedProgress.locator("li")).toHaveCount(4);
await expect(
@@ -562,8 +583,8 @@ test("stream completion and the plan card remain positionally stable", async ({
await expect(expandedLongTitle).toHaveCSS("white-space", "normal");
await expect(expandedProgress.locator("li").nth(1)).not.toHaveCSS("height", "48px");
await progressToggle.click();
await prompt.fill("验证展开状态在流结束后保持");
await completedToggle.click();
await prompt.fill("验证流结束后自动收起已展开的处理过程");
await sendButton.click();
const latestProgressToggle = page
@@ -583,9 +604,18 @@ test("stream completion and the plan card remain positionally stable", async ({
await expect(latestProgressRegion).toContainText("全部4/4");
resumeStream();
await expect(latestProgressRegion).toContainText("全部4/4", { timeout: 10_000 });
await expect(latestExpandedProgress).toBeVisible();
await expect(latestExpandedProgress.getByText("供水风险判断完成", { exact: true })).toBeVisible();
const latestCompletedToggle = page.getByRole("button", { name: "查看处理过程", exact: true }).last();
await expect(page.getByRole("button", { name: "发送 Agent 指令", exact: true }).filter({ visible: true })).toBeVisible({ timeout: 10_000 });
await expect(latestCompletedToggle).toBeVisible();
await expect(latestExpandedProgress).toHaveCount(0);
const latestCompletedProcess = page.locator("details[data-agent-process]").last();
await expect(latestCompletedProcess).not.toHaveAttribute("open");
await latestCompletedToggle.click();
await expect(latestCompletedProcess).toHaveAttribute("open", "");
await latestCompletedToggle.click();
await expect(latestCompletedProcess).not.toHaveAttribute("open");
await latestCompletedToggle.click();
await expect(latestCompletedProcess.getByText("供水风险判断完成", { exact: true })).toBeVisible();
const conversationScroll = page.locator(".agent-conversation-scroll");
await conversationScroll.hover();
@@ -0,0 +1,77 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
test("failed recommendations retry in the open panel and keep the draft", async ({ page }) => {
await page.clock.install();
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
let requests = 0;
await page.route("**/api/v1/suggestions", (route) => {
requests++;
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, status: requests === 1 ? 503 : 200,
json: requests === 1 ? { message: "temporarily unavailable" } : { suggestions: [{ question: "核查当前管网压力监测数据", activity_id: null }], generated_at: new Date().toISOString() } });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await expect(panel.getByText("推荐问题暂不可用,可直接输入任务。", { exact: true })).toBeVisible();
const prompt = panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
await prompt.fill("保留尚未发送的问题");
await page.clock.runFor(31_000);
await expect(panel.getByRole("button", { name: /^核查当前管网压力监测数据/ })).toBeVisible();
await expect(prompt).toHaveValue("保留尚未发送的问题");
expect(requests).toBe(2);
});
test("Agent status copy removes boundary newlines and manual recommendation retry preserves input", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
let requests = 0;
await page.route("**/api/v1/suggestions", (route) => route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, status: ++requests === 1 ? 503 : 200,
json: requests === 1 ? { message: "temporarily unavailable" } : { suggestions: [{ question: "核查恢复后的供水监测数据", activity_id: null }] } }));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
const text = "推荐问题暂不可用,可直接输入任务。";
const status = panel.getByText(text, { exact: true });
await expect(status).toBeVisible();
await status.click({ clickCount: 3 });
await page.keyboard.press("Control+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(text);
const prompt = panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
const draft = "第一行\n\n第二行\\n\\n";
await prompt.fill(draft);
await page.keyboard.press("Control+a");
await page.keyboard.press("Control+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(draft);
await panel.getByRole("button", { name: "重试推荐", exact: true }).click();
await expect(panel.getByRole("button", { name: /^核查恢复后的供水监测数据/ })).toBeVisible();
await expect(prompt).toHaveValue(draft);
expect(requests).toBe(2);
});
test("service recovery immediately retries failed recommendations before cache expiry", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
let offline = false;
let requests = 0;
await page.route("**/health", (route) => route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, status: offline ? 503 : 200, json: { ok: !offline, ready: !offline } }));
await page.route("**/api/v1/suggestions", (route) => route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, status: ++requests === 1 ? 503 : 200,
json: requests === 1 ? { message: "temporarily unavailable" } : { suggestions: [{ question: "核查服务恢复后的任务状态", activity_id: null }] } }));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await expect(panel.getByText("推荐问题暂不可用,可直接输入任务。", { exact: true })).toBeVisible();
offline = true;
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
await expect(panel.getByText("离线", { exact: true })).toBeVisible();
offline = false;
await page.evaluate(() => window.dispatchEvent(new Event("online")));
await expect(panel.getByRole("button", { name: /^核查服务恢复后的任务状态/ })).toBeVisible();
expect(requests).toBe(2);
});
@@ -0,0 +1,72 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { responsesFixture } from "./support/responses-fixture";
const title = "空间候选管段(距离近,非故障定位)";
const ids = ["19025", "19026", "19022", "19027"];
const distances = [283.85, 283.86, 291.45, 292.44];
for (const width of [1440, 375]) for (const kind of ["Table", "Chart"] as const) {
test(`${kind} data stays display-only inline and expanded at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 1000 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
await mockAgentApi(page);
const block = { version: "answer-ui@1", block_id: "selection-style", fallback_text: "演练候选管段", spec: {
root: "data", elements: { data: { type: kind, children: [], props: kind === "Table"
? { title, columns: ["管段", "距离(米)"], rows: ids.map((id, index) => [id, String(distances[index])]) }
: { title, chart_type: "line", x_data: ids, series: [{ name: "距离(米)", data: distances }], source: "浏览器演练" } } }
} };
await page.route("**/api/v1/responses", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback();
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" }, body: responsesFixture([
{ type: "start", messageId: "selection-answer" },
{ type: "data-answer_ui", id: block.block_id, data: block },
{ type: "data-final_answer", data: { content: "以下为浏览器演练资料。", ui_block_ids: [block.block_id] } },
{ type: "finish", finishReason: "stop" },
]) });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.locator(".agent-panel-prompt-textarea").fill("查看演练候选管段");
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
let ui = panel.locator('[data-answer-ui="selection-style"]');
await expect(ui).toBeVisible();
if (kind === "Chart") await ui.getByRole("tab", { name: "数据", exact: true }).click();
await expect(ui.getByRole("table")).toContainText("19025");
await expect(ui.getByRole("table")).toContainText("283.85");
await expect(ui.getByRole("columnheader")).toHaveCount(2);
await expect(ui.getByRole("checkbox")).toHaveCount(0);
await expect(ui.getByRole("button", { name: /加入分析|针对.*提问|取消选行/ })).toHaveCount(0);
await expect(ui.getByRole("region", { name: "分析内容篮" })).toHaveCount(0);
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
await page.evaluate(() => document.fonts.ready);
await ui.locator("table").screenshot({ path: testInfo.outputPath("inline-display.png") });
const sort = ui.getByRole("button", { name: "按距离(米)排序", exact: true });
await sort.click();
await sort.click();
await expect(ui.getByRole("columnheader", { name: "距离(米)" })).toHaveAttribute("aria-sort", "descending");
await expect(ui.locator("tbody tr").first()).toContainText("19027");
await ui.getByRole("textbox", { name: `搜索${title}` }).fill("19025");
await expect(ui.locator("tbody tr")).toHaveCount(1);
await panel.getByRole("button", { name: "展开查看", exact: true }).click();
const workspace = page.getByRole("region", { name: "展开报告工作区", exact: true });
ui = workspace.locator('[data-answer-ui="selection-style"]');
await expect(ui.getByRole("textbox", { name: `搜索${title}` })).toHaveValue("19025");
await expect(ui.getByRole("columnheader", { name: "距离(米)" })).toHaveAttribute("aria-sort", "descending");
await expect(ui.getByRole("checkbox")).toHaveCount(0);
await expect(ui.locator('tbody tr[data-state="selected"]')).toHaveCount(0);
await expect(ui.getByRole("button", { name: /加入分析|针对.*提问|取消选行/ })).toHaveCount(0);
await ui.getByRole("textbox", { name: `搜索${title}` }).fill("");
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
await ui.locator("[data-interactive-table]").screenshot({ path: testInfo.outputPath("expanded-display.png") });
await ui.getByRole("button", { name: "按距离(米)排序", exact: true }).focus();
await page.keyboard.press("Enter");
await expect(ui.getByRole("columnheader", { name: "距离(米)" })).toHaveAttribute("aria-sort", "none");
await ui.getByRole("textbox", { name: `搜索${title}` }).fill("没有匹配的管段");
await expect(ui.getByRole("cell", { name: "当前筛选没有匹配的数据。" })).toHaveAttribute("colspan", "2");
await expect(ui.getByRole("checkbox")).toHaveCount(0);
});
}
@@ -0,0 +1,41 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
for (const width of [1440, 375, 320]) {
test(`conversation title aligns with assistant text ${width}`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
const title = "育秀路人民路路口部分查找三根大管径的管道";
await mockAgentApi(page, { sessions: [{ session_id: "header-preview", title, created_at: Date.now(), updated_at: Date.now() }] });
await page.route("**/api/v1/session/header-preview?**", route => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" }, json: {
session_id: "header-preview", title, purpose: "ordinary_chat", status: "active", is_streaming: false,
messages: [{ id: "prompt", role: "user", parts: [{ type: "text", text: "重新定位下" }] },
{ id: "reply", role: "assistant", parts: [{ type: "text", text: Array.from({ length: 35 }, (_, i) => `第${i + 1}条核查记录:核对管道位置、压力与流量。`).join("\n\n") }] }]
}
}));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("button", { name: "打开 Agent 历史记录" }).click();
await page.locator(".agent-history-session-trigger").filter({ hasText: title }).click();
const context = panel.locator(".agent-chat-context-bar");
await expect(context).toBeVisible();
await expect(context).toHaveCSS("text-align", "left");
const paragraph = panel.locator('[data-agent-message-id="reply"] .agent-streaming-response p').first();
await expect(paragraph).toBeVisible();
await page.evaluate(() => document.fonts.ready);
const textLeft = (element: Element) => {
const text = document.createTreeWalker(element, NodeFilter.SHOW_TEXT).nextNode()!;
const range = document.createRange();
range.selectNodeContents(text);
return range.getClientRects()[0].x;
};
const titleX = await context.getByText(title, { exact: true }).evaluate(textLeft);
const paragraphX = await paragraph.evaluate(textLeft);
console.log(JSON.stringify({ width, titleX, paragraphX }));
await panel.screenshot({ path: `/tmp/agent-title-alignment-${width}.png` });
expect(Math.abs(titleX - paragraphX)).toBeLessThanOrEqual(1);
});
}
@@ -0,0 +1,93 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import type { AnswerViewStateRecord, AnswerViewStateWrite } from "../../src/features/agent/api/answer-view-state";
for (const width of [1440, 375, 320]) {
test(`final UI anchors isolate process content and restore final controls at ${width}px`, async ({ page, context }) => {
test.setTimeout(60000);
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
const sessionId = "anchored-answer", title = "最终界面定位";
await mockAgentApi(page, { sessions: [{ session_id: sessionId, title, created_at: Date.now(), updated_at: Date.now() }] });
const process = {
version: "answer-ui@1", block_id: "query-process", phase: "process", fallback_text: "中间查询资料,仅过程使用。",
spec: { root: "note", elements: { note: { type: "Text", props: { text: "中间查询资料,仅过程使用。", tone: "neutral" }, children: [] } } },
};
const table = {
version: "answer-ui@1", block_id: "report-table", phase: "final", fallback_text: "最终读数:北侧 0.30 MPa,南侧 0.28 MPa。",
spec: { root: "table", elements: { table: { type: "Table", props: { title: "最终读数", columns: ["位置", "压力"], rows: [["北侧", "0.30 MPa"], ["南侧", "0.28 MPa"]] }, children: [] } } },
};
const form = {
version: "answer-ui@1", block_id: "report-form", phase: "final", fallback_text: "可补充现场记录。",
spec: { root: "form", elements: { form: { type: "FollowUpForm", props: { title: "补充现场资料", description: "由用户主动补充。", fields: [{ name: "record", label: "现场记录", kind: "text", required: true }] }, children: [] } } },
};
const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, PUT, POST, OPTIONS", "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Project-Id" };
await page.route(`**/api/v1/session/${sessionId}?**`, (route) => route.fulfill({ headers, json: {
session_id: sessionId, title, purpose: "ordinary_chat", status: "active", is_streaming: false,
messages: [{ id: "anchored", role: "assistant", parts: [
{ type: "text", text: "正在核对查询记录。" },
...[process, table, form].map((block) => ({ type: "data-answer_ui", id: block.block_id, data: block })),
{ type: "data-final_answer", data: { content: "前置结论\n\n[[ui:report-table]]\n\n表后分析\n\n[[ui:report-form]]\n\n行动建议", ui_block_ids: ["report-table", "query-process", "report-form"] } },
] }],
} }));
await page.route(`**/api/v1/session/${sessionId}/interactions**`, (route) => route.fulfill({ headers, json: { items: [] } }));
const records = new Map<string, AnswerViewStateRecord>();
const reads: string[] = [];
await page.route(`**/api/v1/session/${sessionId}/ui-state**`, async (route) => {
if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers });
const input = route.request().method() === "PUT" ? route.request().postDataJSON() as AnswerViewStateWrite : null;
const blockId = input?.block_id ?? new URL(route.request().url()).searchParams.get("block_id")!;
reads.push(blockId);
const current = records.get(blockId) ?? { version: "answer-view-state@1" as const, message_id: "anchored", block_id: blockId, revision: 0, schema_digest: "fixture", nodes: {}, block: { evidenceFilter: "all" }, updated_at: null };
const next = input ? { ...current, nodes: input.nodes, block: input.block, revision: current.revision + 1, updated_at: new Date().toISOString() } : current;
records.set(blockId, next);
await route.fulfill({ headers, json: next });
});
const openHistory = async () => {
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("button", { name: /历史/ }).click();
await page.locator("button.agent-history-session-trigger").filter({ hasText: title }).click();
return panel.locator('[data-agent-message-id="anchored"]');
};
await page.goto("/", { waitUntil: "domcontentloaded" });
let message = await openHistory();
await expect(message.getByText("前置结论", { exact: true })).toBeVisible();
const finalTable = message.locator('[data-answer-ui="report-table"]');
const finalForm = message.locator('[data-answer-ui="report-form"]');
await expect(finalTable).toHaveAttribute("data-answer-phase", "final");
const ordered = await message.evaluate((element) => [...element.querySelectorAll('.agent-streaming-response, [data-answer-ui]')]
.filter((node) => !node.closest("details"))
.map((node) => node.getAttribute("data-answer-ui") ?? node.textContent?.trim()));
expect(ordered).toEqual(["前置结论", "report-table", "表后分析", "report-form", "行动建议"]);
await expect(message.locator('[data-answer-ui="query-process"]')).toBeHidden();
await finalTable.getByRole("textbox", { name: "搜索最终读数" }).fill("北侧");
await finalForm.getByLabel("现场记录(必填)").fill("未提交的现场草稿");
await expect.poll(() => records.get("report-form")?.nodes.form).toMatchObject({ type: "FollowUpForm", values: { record: "未提交的现场草稿" } });
await expect.poll(() => records.get("report-table")?.nodes.table).toMatchObject({ type: "Table", query: "北侧" });
await message.getByText("查看处理过程", { exact: true }).click();
await expect(message.locator('[data-answer-ui="query-process"]')).toBeVisible();
expect(reads).not.toContain("query-process");
expect(reads).not.toContain("anchored-process");
await message.getByText("查看处理过程", { exact: true }).click();
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await message.getByRole("button", { name: "复制回复", exact: true }).click();
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied).toContain("最终读数");
expect(copied).not.toContain("中间查询资料");
expect(copied).not.toContain("[[ui:");
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
await finalTable.screenshot({ path: `/tmp/agent-ui-anchors-table-${width}.png` });
await finalForm.screenshot({ path: `/tmp/agent-ui-anchors-form-${width}.png` });
await message.getByText("前置结论", { exact: true }).scrollIntoViewIfNeeded();
await page.getByRole("complementary", { name: "Agent 命令面板" }).screenshot({ path: `/tmp/agent-ui-anchors-${width}.png` });
await page.reload({ waitUntil: "domcontentloaded" });
message = await openHistory();
await expect(message.getByLabel("现场记录(必填)")).toHaveValue("未提交的现场草稿");
await expect(message.getByRole("textbox", { name: "搜索最终读数" })).toHaveValue("北侧");
expect(reads).not.toContain("query-process");
expect(await message.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(false);
});
}
+18 -1
View File
@@ -1,7 +1,9 @@
import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
test.beforeEach(async ({ page }) => {
await mockWorkbenchContext(page);
await mockAgentApi(page);
});
@@ -10,7 +12,7 @@ async function openAgentPanel(page: Page) {
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
}
test("speech capability detection preserves the server hydration tree", async ({ page }) => {
test("speech capability detection preserves the server hydration tree", async ({ page }, testInfo) => {
const hydrationErrors: string[] = [];
page.on("console", (message) => {
@@ -31,6 +33,21 @@ test("speech capability detection preserves the server hydration tree", async ({
await expect(page.getByRole("button", { name: "语音输入" })).toBeVisible();
await expect(page.getByRole("button", { name: "权限批准模式" })).toBeVisible();
expect(hydrationErrors).toEqual([]);
const form = page.locator("form.agent-panel-input-form");
await form.screenshot({ path: testInfo.outputPath("composer-idle.png"), animations: "disabled" });
await form.getByRole("textbox").fill("检查城南压力");
await form.screenshot({ path: testInfo.outputPath("composer-ready.png"), animations: "disabled" });
const voice = form.getByRole("button", { name: "语音输入", exact: true });
await voice.hover();
await expect(voice).toHaveCSS("border-radius", "50%");
await form.screenshot({ path: testInfo.outputPath("composer-voice-hover.png"), animations: "disabled" });
await page.setViewportSize({ width: 375, height: 900 });
const mobilePanel = page.getByRole("complementary", { name: "Agent 命令面板" });
if (!await mobilePanel.isVisible()) await page.getByRole("button", { name: "打开 Agent 面板", exact: true }).click();
const mobileVoice = mobilePanel.getByRole("button", { name: "语音输入", exact: true });
await mobileVoice.hover();
await expect(mobileVoice).toHaveCSS("border-radius", "50%");
await mobilePanel.locator("form").screenshot({ path: testInfo.outputPath("composer-voice-hover-narrow.png"), animations: "disabled" });
});
test("speech recognition failure clears the active animation and explains the error", async ({ page }) => {
+19 -17
View File
@@ -13,6 +13,7 @@ const now = "2026-09-24T08:00:00Z";
test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常结束", async ({ page }, testInfo) => {
test.setTimeout(90_000);
await page.emulateMedia({ reducedMotion: "reduce" });
await mockAgentApi(page);
await page.route("**/runtime-config.js", (route) => route.fulfill({ contentType: "text/javascript", body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
TJWATER_AUTH_MODE: "disabled", TJWATER_SERVER_API_BASE_URL: "http://server.test",
@@ -88,7 +89,13 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
work_order_snapshot: null
};
const firstReport = task.latest_report!;
let notifications: OperationalTaskNotification[] = [];
let notifications: OperationalTaskNotification[] = [{
notification_id: "87000000-0000-4000-8000-000000000002", task_id: taskId,
kind: "action_approval_pending", title: "待确认现场采样工单", description: "请审核派单内容",
lifecycle_status: "active", subject_type: "action", subject_id: actionId,
report_reference: null, resolved_at: null, resolution_reason: null,
status: "unread", version: 1, required_permission: null, created_at: now, read_at: null, read_by: null
}];
const summary = (item: OperationalTask): OperationalTaskSummary => {
const { latest_plan, latest_report, latest_progress, evidence, actions, access_requests, permissions, work_order_snapshot, ...value } = item;
void latest_plan; void latest_report; void latest_progress; void evidence; void actions; void access_requests; void permissions; void work_order_snapshot;
@@ -184,7 +191,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
await expect(diagram.locator("svg[id]")).toHaveCount(1);
await expect(diagram).toContainText("常规核查");
const captureDiagramControls = async (surface: typeof diagram) => {
return surface.locator("button[title]").evaluateAll((buttons) => buttons.map((button) => {
return surface.locator("button[title]:has([data-investigation-diagram-control])").evaluateAll((buttons) => buttons.map((button) => {
const rect = button.getBoundingClientRect();
const style = getComputedStyle(button);
const icon = button.querySelector("svg");
@@ -292,22 +299,17 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
await panel.getByRole("button", { name: "Agent 协作" }).click();
const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" });
const review = agentPanel.getByRole("region", { name: "任务协作状态" });
await expect(review).toContainText("现场采样需要派单");
await expect(review.getByRole("heading", { name: "关联预约工单" })).toBeVisible();
await expect(review).toContainText("调查任务只保留创建关联");
await expect(review).toContainText("预约工单");
await expect(review.getByText("数据访问申请", { exact: true })).toHaveCount(0);
await testInfo.attach("linked-appointment-review-desktop", {
body: await review.screenshot({ path: testInfo.outputPath("linked-appointment-review-desktop.png") }), contentType: "image/png"
});
await page.setViewportSize({ width: 375, height: 720 });
const mobileAgentSheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
if (!await mobileAgentSheet.isVisible()) await page.getByRole("button", { name: "打开 Agent 面板" }).click();
await expect(mobileAgentSheet.getByRole("region", { name: "任务协作状态" })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
await page.setViewportSize({ width: 1280, height: 720 });
await expect(agentPanel.getByRole("region", { name: "任务协作状态" })).toHaveCount(0);
await expect(agentPanel.getByRole("button", { name: "确认并创建预约工单" })).toHaveCount(0);
await expect(agentPanel.getByRole("group", { name: "对话引用" })).toBeVisible();
await agentPanel.screenshot({ path: testInfo.outputPath("discussion-without-fixed-review.png") });
// Narrow-screen conversation layout is covered by investigation-main-chat.e2e.ts.
await page.getByRole("button", { name: /查看待办与告警/ }).click();
await page.getByRole("menuitem").filter({ hasText: "待确认现场采样工单" }).click();
const pending = panel.getByLabel("当前需处理", { exact: true });
const review = pending.locator(`[data-action-id="${actionId}"]`);
await expect(review).toBeVisible();
await expect(review.getByRole("heading", { name: "关联预约工单" })).toBeVisible();
await expect(panel.getByLabel("数据访问审核")).toHaveCount(0);
await review.getByRole("button", { name: "编辑草稿" }).click();
await expect(review.getByRole("combobox", { name: "工单类型" })).toHaveCount(0);
@@ -0,0 +1,68 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
for (const kind of ["peripheral", "dev"] as const) {
test(`${kind} panel keeps outside clicks, dragging and wheel navigation available`, async ({ page }) => {
await mockWorkbenchContext(page);
await mockAgentApi(page);
await page.route("**/runtime-config.js", (route) => route.fulfill({
contentType: "application/javascript",
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
TJWATER_AUTH_MODE: "disabled", TJWATER_ENABLE_DEV_PANEL: "true",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_MAPBOX_ACCESS_TOKEN: "", TJWATER_TIANDITU_TOKEN: "", TJWATER_ENABLE_MSW: "false"
})};`
}));
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean(window.__waterNetworkMap));
const trigger = page.getByRole("button", { name: kind === "peripheral" ? "外围系统" : "切换地图 Dev Panel", exact: true });
await trigger.click();
const panel = page.getByRole("complementary", { name: kind === "peripheral" ? "外围系统" : "地图开发面板", exact: true });
await expect(panel).toBeVisible();
await page.locator(".maplibregl-canvas-container").evaluate((element) => {
element.setAttribute("data-pointer-probe", "0");
for (const type of ["pointerdown", "wheel"]) element.addEventListener(type, () => {
element.setAttribute("data-pointer-probe", String(Number(element.getAttribute("data-pointer-probe")) + 1));
}, true);
});
// Controls inside a floating panel consume their own pointer input.
const heading = panel.getByRole("heading", { level: 2 });
await heading.click();
await page.mouse.wheel(0, 100);
await expect(page.locator(".maplibregl-canvas-container")).toHaveAttribute("data-pointer-probe", "0");
const canvas = page.locator(".maplibregl-canvas");
const bounds = (await canvas.boundingBox())!;
const point = { x: bounds.x + 150, y: bounds.y + 340 };
await expect.poll(() => page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.classList.contains("maplibregl-canvas") ?? false, point)).toBe(true);
await page.mouse.click(point.x, point.y);
await expect(page.locator(".maplibregl-canvas-container")).toHaveAttribute("data-pointer-probe", "1");
const center = await page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray());
await page.mouse.move(point.x, point.y);
await page.mouse.down();
await page.mouse.move(point.x + 100, point.y + 30, { steps: 8 });
await page.mouse.up();
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray())).not.toEqual(center);
const zoom = await page.evaluate(() => window.__waterNetworkMap!.getZoom());
await page.mouse.wheel(0, -200);
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap!.getZoom())).toBeGreaterThan(zoom);
await expect(panel).toBeVisible();
await expect(trigger).toHaveAttribute("aria-pressed", "true");
// Opening and closing the other panel must not leave an invisible input shield.
if (kind === "dev") {
await page.getByRole("button", { name: "外围系统", exact: true }).click();
await expect.poll(() => page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.classList.contains("maplibregl-canvas") ?? false, point)).toBe(true);
await page.getByRole("button", { name: "关闭外围系统", exact: true }).click();
await panel.getByRole("tab", { name: "地图调试", exact: true }).click();
await expect(panel.getByRole("button", { name: "定位并高亮", exact: true })).toBeEnabled();
}
await panel.getByRole("button", { name: kind === "peripheral" ? "关闭外围系统" : "关闭 Dev Panel", exact: true }).click();
await expect(panel).toBeHidden();
await expect.poll(() => page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.classList.contains("maplibregl-canvas") ?? false, point)).toBe(true);
});
}
+44 -6
View File
@@ -1,7 +1,7 @@
import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { expectThemeColor } from "./support/theme-colors";
import { expectThemeColor, readThemeColor } from "./support/theme-colors";
test.beforeEach(async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
@@ -14,9 +14,11 @@ test("Agent composer uses a neutral shadow instead of a blue focus border", asyn
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const form = page.locator("form.agent-panel-control");
const inputSurface = form.locator(".agent-panel-input-body");
const textarea = page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
const restingShadow = await form.evaluate((element) => getComputedStyle(element).boxShadow);
const restingBorder = await form.evaluate((element) => getComputedStyle(element).borderColor);
const restingSurface = await inputSurface.evaluate((element) => getComputedStyle(element).backgroundColor);
expect(restingShadow).not.toBe("none");
const restingBox = await form.boundingBox();
await textarea.click();
@@ -25,6 +27,7 @@ test("Agent composer uses a neutral shadow instead of a blue focus border", asyn
await expect(form).not.toHaveCSS("box-shadow", restingShadow);
await expect(form).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, -2)");
await expect(textarea).toHaveCSS("outline-style", "none");
await expect(inputSurface).toHaveCSS("background-color", restingSurface);
const liftedBox = await form.boundingBox();
expect(liftedBox?.x).toBeCloseTo(restingBox!.x, 1);
expect(liftedBox?.y).toBeCloseTo(restingBox!.y - 2, 1);
@@ -64,6 +67,33 @@ test("shared fields and grouped inputs do not stack native outlines and focus ri
await page.locator("#focus-fields").screenshot({ path: "test-results/form-focus/shared-fields.png" });
});
test("filled fields retain readable text inside muted labels and keep placeholder and disabled states", async ({ page }) => {
await renderFields(page);
const input = page.getByLabel("共享输入", { exact: true });
const textarea = page.getByLabel("共享多行输入", { exact: true });
for (const control of [input, textarea]) {
await control.evaluate((element) => {
const label = document.createElement("label");
label.className = "text-muted-foreground";
element.replaceWith(label);
label.append(element);
});
await expectThemeColor(control, "color", "--ink");
expect(await control.evaluate((element) => getComputedStyle(element, "::placeholder").color))
.toBe(await readThemeColor(control, "--ink-muted"));
await control.fill("已填写的供水核查信息");
await expectThemeColor(control, "color", "--ink");
await expect(control).toHaveCSS("opacity", "1");
await control.blur();
await expectThemeColor(control, "color", "--ink");
await control.evaluate((element) => { (element as HTMLInputElement).readOnly = true; });
await expectThemeColor(control, "color", "--ink");
await expect(control).toHaveCSS("opacity", "1");
await control.evaluate((element) => { (element as HTMLInputElement).disabled = true; });
await expect(control).toHaveCSS("opacity", "0.5");
}
});
test("borderless selects and choice controls keep one theme keyboard focus indicator", async ({ page }) => {
await renderFields(page);
const borderless = page.getByRole("combobox", { name: "无边框选择" });
@@ -101,14 +131,17 @@ for (const width of [320, 375]) {
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
const textarea = page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
const material = page.locator("form.agent-panel-input-form");
const inputSurface = page.locator(".agent-panel-input-body");
const restingBorder = await material.evaluate((element) => getComputedStyle(element).borderColor);
const restingShadow = await material.evaluate((element) => getComputedStyle(element).boxShadow);
const restingSurface = await inputSurface.evaluate((element) => getComputedStyle(element).backgroundColor);
const restingBox = await material.boundingBox();
await textarea.click();
await expect(material).toHaveCSS("border-width", "1px");
await expect(material).toHaveCSS("border-color", restingBorder);
await expect(material).not.toHaveCSS("box-shadow", restingShadow);
await expect(textarea).toHaveCSS("outline-style", "none");
await expect(inputSurface).toHaveCSS("background-color", restingSurface);
expect(await material.boundingBox()).toEqual(restingBox);
await page.screenshot({ path: `test-results/form-focus/mobile-${width}.png` });
});
@@ -117,11 +150,16 @@ for (const width of [320, 375]) {
async function renderFields(page: Page) {
await page.evaluate(async () => {
// Load the production components through Vite, so the test exercises their real CSS.
const paths = ["/node_modules/.vite/deps/react.js", "/node_modules/.vite/deps/react-dom_client.js",
// Use the app's exact React modules, including Vite's per-port cache and hash.
const entry = await fetch("/src/main.tsx").then((response) => response.text());
const reactPath = entry.match(/["']([^"']*\/react\.js(?:\?[^"']*)?)["']/)?.[1];
const domPath = entry.match(/["']([^"']*\/react-dom_client\.js(?:\?[^"']*)?)["']/)?.[1];
if (!reactPath || !domPath) throw new Error("App React dependency URLs not found");
const paths = [reactPath, domPath,
"/src/shared/ui/input.tsx", "/src/shared/ui/textarea.tsx", "/src/shared/ui/input-group.tsx",
"/src/shared/ui/field-select.tsx", "/src/shared/ui/date-time-field.tsx", "/src/shared/ui/select.tsx",
"/src/shared/ui/checkbox.tsx", "/src/shared/ui/radio.tsx", "/src/shared/ai-elements/prompt-input.tsx"];
const [react, dom, inputs, textareas, groups, selects, dates, primitives, checkboxes, radios, prompts] = await Promise.all(paths.map((path) => import(path)));
"/src/shared/ui/checkbox.tsx", "/src/shared/ui/radio.tsx"];
const [react, dom, inputs, textareas, groups, selects, dates, primitives, checkboxes, radios] = await Promise.all(paths.map((path) => import(path)));
const h = react.default.createElement;
const container = document.createElement("div");
container.id = "focus-fields";
@@ -132,9 +170,9 @@ async function renderFields(page: Page) {
h(textareas.Textarea, { "aria-label": "共享多行输入", placeholder: "共享多行输入" }),
h(groups.InputGroup, { "aria-label": "组合输入区" }, h(groups.InputGroupInput, { "aria-label": "组合输入", placeholder: "组合输入" })),
h(selects.FieldSelect, { label: "选择字段", value: "first", onValueChange: () => {}, options: [{ value: "first", label: "第一个选项" }, { value: "second", label: "第二个选项" }] }),
h(dates.DateTimeField, { label: "日期字段", value: "2026-10-09T09:00", onValueChange: () => {} }),
h(dates.DateTimeField, { label: "日期字段", value: "2026-10-09T09:00", onChange: () => {} }),
h(primitives.Select, { defaultValue: "first" },
h(prompts.PromptInputSelectTrigger, { "aria-label": "无边框选择" }, h(primitives.SelectValue)),
h(primitives.SelectTrigger, { "aria-label": "无边框选择", className: "form-field-inset border-none bg-transparent shadow-none" }, h(primitives.SelectValue)),
h(primitives.SelectContent, null,
h(primitives.SelectItem, { value: "first" }, "第一个选项"),
h(primitives.SelectItem, { value: "second" }, "第二个选项")
@@ -0,0 +1,528 @@
import { expect, test } from "@playwright/test";
import { investigationFixture, mockInvestigationPresentation, selectInvestigation } from "./support/mock-investigation-presentation";
test("live investigation charts remain visible while Dev is open", async ({ page }) => {
const task = investigationFixture(1, "合成爆管展示核查");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
await expect(page.getByLabel("调查实时展示", { exact: true })).toContainText("跟随所选调查");
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
await mock.emit(task, { action: "open_chart", chart_id: "live-pressure", evidence_refs: ["dev:synthetic"],
chart: { title: "实时合成压力核查", chart_type: "line", x_data: ["08:00", "08:15"], series: [{ name: "压力水头(m)", data: [32, 28] }] } });
await expect(page.locator('[data-investigation-chart="live-pressure"]')).toBeVisible();
await expect(page.getByLabel("调查实时展示", { exact: true })).not.toContainText("图表当前不可见");
await page.getByRole("button", { name: "收起展示", exact: true }).click();
await mock.emit(task, { action: "open_chart", chart_id: "while-collapsed", evidence_refs: ["dev:synthetic"],
chart: { title: "收起期间的合成图表", chart_type: "line", x_data: ["08:00"], series: [{ name: "压力水头(m)", data: [32] }] } });
const activity = page.getByRole("list", { name: "动作识别记录" });
await expect(activity.locator("li")).toHaveCount(2);
await expect(activity.locator("li").last()).toContainText("已应用");
await page.getByRole("button", { name: "展开展示", exact: true }).click();
await expect(page.locator('[data-investigation-chart="while-collapsed"]')).toBeVisible();
expect(mock.mutations).toEqual([]);
});
test("Dev validates and previews JSONL locally, rejects invalid actions and clears on task switches", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const task = investigationFixture(1, "合成爆管展示核查");
const other = investigationFixture(2, "另一项合成调查");
task.latest_report = { revision: 1, execution_generation: 1, source_session_id: "investigation-dev-test", published_at: task.created_at,
published_by: "fixture", report: { title: "合成调查报告", current_conclusion: "仅供展示核对", conclusion_boundary: "合成样例",
stage: "interim", outcome: "concluded", stop_reason: null, data_gaps: [], evidence_refs: ["dev:report"], recommendations: [], pending_items: [],
ui_blocks: [{ evidence_refs: ["dev:report"], block: {
version: "answer-ui@1", block_id: "saved-table", phase: "final", fallback_text: "报告候选列表", spec: {
root: "table", elements: { table: { type: "Table", props: {
title: "已保存的候选列表", columns: ["候选", "依据"], rows: [["DEMO-PIPE-03", "合成资料"]]
}, children: [] } }
}
} }] } };
const mock = await mockInvestigationPresentation(page, [task, other]);
await selectInvestigation(page, task.title);
await expect(page.getByLabel("调查实时展示", { exact: true })).toContainText("跟随所选调查");
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const dev = page.getByTestId("map-dev-panel");
const tools = dev.getByTestId("investigation-dev-tools");
await expect(tools).toContainText("报告图表 0 · 表格 1");
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
await expect(page.getByRole("button", { name: "关闭通知", exact: true })).toHaveCount(0);
await testInfo.attach("investigation-dev-default", { body: await page.screenshot({ path: testInfo.outputPath("dev-default.png") }), contentType: "image/png" });
await tools.getByRole("button", { name: "载入当前报告", exact: true }).click();
const input = tools.getByLabel("调查展示 JSONL", { exact: true });
await expect(input).toHaveValue(/ui_block/);
const reportInput = await input.inputValue();
await dev.getByRole("tab", { name: "地图调试", exact: true }).click();
await expect(dev.getByRole("button", { name: "定位并高亮", exact: true })).toBeVisible();
await expect(input).toBeHidden();
await dev.getByRole("tab", { name: "调查测试", exact: true }).click();
await expect(input).toHaveValue(reportInput);
await expect(dev.getByRole("button", { name: "重置地图调试效果", exact: true })).toBeHidden();
await expect(tools.getByRole("button", { name: "执行本地预览", exact: true })).toBeEnabled();
await tools.getByRole("button", { name: "校验 JSONL", exact: true }).click();
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(page.getByLabel("本地预览展示", { exact: true }).getByRole("region", { name: "展示结果" })).toContainText("已保存的候选列表");
await tools.getByRole("button", { name: "载入合成样例", exact: true }).click();
await tools.getByRole("button", { name: "校验 JSONL", exact: true }).click();
await expect(tools).toContainText("已识别 3 条展示指令、1 个界面块、1 条调查数据");
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(tools).toContainText("本地预览完成");
await expect(page.locator('[data-investigation-chart="dev-synthetic-pressure"]')).toBeVisible();
await expect(page.getByLabel("本地预览展示", { exact: true }).getByRole("region", { name: "展示结果" })).toContainText("合成候选对比");
await expect(tools.getByRole("list", { name: "动作识别记录" })).toContainText("本地测试");
const sourceSize = (source: string) => page.evaluate(async (source) => {
const map = window.__waterNetworkMap;
const data = await (map?.getSource(source) as { getData: () => Promise<{ features: unknown[] }> } | undefined)?.getData();
return data?.features.length ?? 0;
}, source);
await expect.poll(() => sourceSize("preview-presentation-highlights")).toBe(1);
await expect.poll(() => sourceSize("preview-presentation-overlay")).toBe(1);
const download = page.waitForEvent("download");
await tools.getByRole("button", { name: "下载测试 JSONL" }).click();
expect((await download).suggestedFilename()).toBe("investigation-preview-1.jsonl");
await testInfo.attach("investigation-dev-desktop", { body: await page.screenshot({ path: testInfo.outputPath("dev-desktop.png") }), contentType: "image/png" });
await page.setViewportSize({ width: 1024, height: 640 });
expect(await dev.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(tools.getByRole("button", { name: "执行本地预览", exact: true })).toBeEnabled();
await page.locator('[data-investigation-chart="dev-synthetic-pressure"]').getByRole("img").scrollIntoViewIfNeeded();
const chartViewport = (await page.getByLabel("本地预览展示", { exact: true }).locator("[data-investigation-content]").boundingBox())!;
const plot = (await page.locator('[data-investigation-chart="dev-synthetic-pressure"]').getByRole("img", { name: "本地合成压力趋势", exact: true }).boundingBox())!;
expect(plot.y).toBeGreaterThanOrEqual(chartViewport.y - 1);
expect(plot.y + plot.height).toBeLessThanOrEqual(chartViewport.y + chartViewport.height + 1);
await testInfo.attach("investigation-dev-compact", { body: await page.screenshot({ path: testInfo.outputPath("dev-compact.png") }), contentType: "image/png" });
await page.setViewportSize({ width: 1440, height: 900 });
await input.fill('{"record_type":"presentation","command":{"action":"execute","command":"arbitrary-command"}}');
await tools.getByRole("button", { name: "校验 JSONL", exact: true }).click();
await expect(tools.getByRole("alert")).toContainText("第 1 行展示指令不合法");
await expect(tools.getByRole("button", { name: "执行本地预览", exact: true })).toBeEnabled();
await tools.getByRole("button", { name: "清除临时展示", exact: true }).click();
await expect.poll(() => sourceSize("preview-presentation-highlights")).toBe(0);
await expect.poll(() => sourceSize("preview-presentation-overlay")).toBe(0);
await expect(page.locator('[data-investigation-chart]')).toHaveCount(0);
await tools.getByRole("button", { name: "载入合成样例", exact: true }).click();
await tools.getByRole("button", { name: "校验 JSONL", exact: true }).click();
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(tools).toContainText("本地预览完成");
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
await page.getByRole("region", { name: "工况任务", exact: true }).getByText(other.title, { exact: true }).first().click();
await expect.poll(() => sourceSize("preview-presentation-highlights")).toBe(0);
await expect.poll(() => sourceSize("preview-presentation-overlay")).toBe(0);
await expect(page.locator('[data-investigation-chart]')).toHaveCount(0);
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
await expect(tools).toContainText(other.title);
await expect(tools.getByRole("button", { name: "收起动作测试" })).toBeVisible();
await expect(tools.getByRole("textbox", { name: "调查展示 JSONL" })).toHaveValue("");
await page.setViewportSize({ width: 375, height: 720 });
await expect(page.getByRole("button", { name: "切换地图 Dev Panel" })).toBeHidden();
await expect(dev).toBeHidden();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
expect(mock.mutations).toEqual([]);
});
test("JSONL cases switch directly, reset validation and remove the previous local preview", async ({ page }, testInfo) => {
test.setTimeout(90_000);
const task = investigationFixture(1, "切换 JSONL 案例的合成调查");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const tools = page.getByTestId("investigation-dev-tools");
const input = tools.getByLabel("调查展示 JSONL", { exact: true });
const cases = tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true });
const execute = tools.getByRole("button", { name: "执行本地预览", exact: true });
const preview = page.getByLabel("本地预览展示", { exact: true }).getByRole("region", { name: "展示结果" });
async function choose(label: string) {
await cases.click();
await page.getByRole("option", { name: label, exact: true }).click();
await expect(execute).toBeEnabled();
await expect(input).not.toHaveValue("");
await expect(tools.getByRole("alert")).toHaveCount(0);
}
async function run() {
await tools.getByRole("button", { name: "校验 JSONL", exact: true }).click();
await execute.click();
await expect(tools.getByRole("status")).toContainText("本地预览完成");
}
const scene = (source: string) => page.evaluate(async (source) => {
const data = await (window.__waterNetworkMap?.getSource(source) as {
getData: () => Promise<{ features: Array<{ geometry: { type: string }; properties: Record<string, unknown> | null }> }>
} | undefined)?.getData();
return (data?.features ?? []).map((feature) => ({ type: feature.geometry.type, weight: feature.properties?.weight ?? null }));
}, source);
await choose("压力趋势对比");
await expect(tools.getByLabel("案例预期结果")).toContainText("两条合成压力曲线");
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
await testInfo.attach("jsonl-case-picker", { body: await page.screenshot({ path: testInfo.outputPath("jsonl-cases.png") }), contentType: "image/png" });
await run();
await expect(page.locator('[data-investigation-chart="dev-case-pressure-line"]')).toBeVisible();
// Editing resets validation, but the rendered scene is still removed on the next case switch.
await input.fill(`${await input.inputValue()}\n`);
await choose("候选管段对比");
await expect(page.locator("[data-investigation-chart]")).toHaveCount(0);
await expect(preview).toHaveCount(0);
await run();
await expect(preview).toContainText("合成候选管段对比");
await expect(tools.getByRole("region", { name: "JSONL 检查结果" })).not.toContainText("合成候选管段对比");
await tools.getByText("原始数据检查", { exact: true }).click();
await expect(tools.getByRole("button", { name: "查看第 2 条调查数据" })).toBeVisible();
await choose("隔离阀门清单");
await run();
await expect(preview).toContainText("合成隔离阀门清单");
await expect(preview).not.toContainText("合成候选管段对比");
await choose("地图定位与高亮");
await run();
await expect.poll(() => scene("preview-presentation-highlights")).toHaveLength(1);
await choose("合成影响范围");
await expect.poll(() => scene("preview-presentation-highlights")).toEqual([]);
await expect.poll(() => scene("preview-presentation-overlay")).toEqual([]);
await run();
await expect.poll(() => scene("preview-presentation-overlay")).toEqual([{ type: "Polygon", weight: null }]);
await choose("投诉热力分布");
await run();
await expect.poll(() => scene("preview-presentation-overlay")).toEqual([
{ type: "Point", weight: 1 }, { type: "Point", weight: 3 }, { type: "Point", weight: 2 }
]);
await choose("影响用户统计");
await run();
await expect(page.locator('[data-investigation-chart="dev-case-impact-bar"]')).toBeVisible();
await expect.poll(() => scene("preview-presentation-overlay")).toEqual([]);
await choose("纯 JSONL 数据");
await run();
await expect(preview).toContainText("DEMO-PIPE-03");
await expect(page.locator("[data-investigation-chart]")).toHaveCount(0);
await expect(tools.getByRole("list", { name: "动作识别记录" })).toHaveCount(1);
for (const [label, error] of [
["错误:JSON 语法", "第 2 行不是合法 JSON"],
["错误:不支持的动作", "第 1 行展示指令不合法"],
["错误:图表数据不齐", "第 1 行展示指令不合法"]
]) {
await choose(label);
await tools.getByRole("button", { name: "校验 JSONL", exact: true }).click();
await expect(tools.getByRole("alert")).toContainText(error);
await expect(execute).toBeEnabled();
await execute.click();
await expect(tools.getByRole("alert")).toContainText(error);
await expect(preview).toHaveCount(0);
await expect(tools.getByRole("list", { name: "动作识别记录" })).toHaveCount(1);
}
expect(mock.mutations).toEqual([]);
});
test("JSONL cases preview tables, charts and map overlays without an investigation or live following", async ({ page }, testInfo) => {
const mock = await mockInvestigationPresentation(page, []);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const tools = page.getByTestId("investigation-dev-tools");
await expect(tools.getByText("尚未选择调查", { exact: true })).toBeVisible();
await page.getByRole("checkbox", { name: "跟随所选调查", exact: true }).uncheck();
const cases = tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true });
await expect(cases).toBeEnabled();
await cases.click();
await expect(page.getByRole("listbox")).toBeVisible();
await expect(page.getByRole("option")).toHaveCount(12);
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
await testInfo.attach("case-dropdown-without-task", {
body: await page.screenshot({ path: testInfo.outputPath("dropdown-without-task.png") }), contentType: "image/png"
});
await page.getByRole("option", { name: "候选管段对比", exact: true }).click();
await expect(cases).toContainText("候选管段对比");
await expect(tools.getByLabel("调查展示 JSONL", { exact: true })).toHaveValue(/DEMO-PIPE-01/);
const execute = tools.getByRole("button", { name: "执行本地预览", exact: true });
await expect(execute).toBeEnabled();
await execute.click();
await expect(page.getByLabel("本地预览展示", { exact: true }).getByRole("region", { name: "展示结果" })).toContainText("合成候选管段对比");
await cases.click();
await page.getByRole("option", { name: "影响用户统计", exact: true }).click();
await execute.click();
const display = page.getByLabel("本地预览展示", { exact: true });
await expect(display.locator('[data-investigation-chart="dev-case-impact-bar"]')).toBeVisible();
await expect(display).toContainText("仅当前浏览器");
await expect(execute).toBeEnabled();
await page.getByRole("checkbox", { name: "跟随所选调查", exact: true }).check();
await expect(display.locator('[data-investigation-chart="dev-case-impact-bar"]')).toBeVisible();
await page.getByRole("checkbox", { name: "跟随所选调查", exact: true }).uncheck();
await expect(display.locator('[data-investigation-chart="dev-case-impact-bar"]')).toBeVisible();
await testInfo.attach("preview-without-task", {
body: await page.screenshot({ path: testInfo.outputPath("preview-without-task.png") }), contentType: "image/png"
});
await cases.click();
await page.getByRole("option", { name: "合成影响范围", exact: true }).click();
await execute.click();
await expect(display.getByText("本地合成影响范围", { exact: true })).toBeVisible();
await expect.poll(() => page.evaluate(async () => {
const source = window.__waterNetworkMap?.getSource("preview-presentation-overlay") as
{ getData: () => Promise<{ features: Array<{ geometry: { type: string } }> }> } | undefined;
return (await source?.getData())?.features[0]?.geometry.type;
})).toBe("Polygon");
// A data-only edit does not strand the map preview without its controls.
await tools.getByLabel("调查展示 JSONL", { exact: true }).fill(JSON.stringify({
record_type: "candidate", origin: "synthetic", source_evidence_refs: [], element_id: "DEMO-DATA"
}));
await execute.click();
await expect(page.getByLabel("本地预览展示", { exact: true }).getByRole("region", { name: "展示结果" })).toContainText("DEMO-DATA");
await expect(display.getByText("本地合成影响范围", { exact: true })).toBeVisible();
await tools.getByRole("button", { name: "清除临时展示", exact: true }).click();
await expect(display).toContainText("当前没有展示结果");
await expect(display.getByRole("combobox", { name: "切换展示动作" })).toBeVisible();
await cases.click();
await page.getByRole("option", { name: "错误:图表数据不齐", exact: true }).click();
await execute.click();
await expect(tools.getByRole("alert")).toContainText("第 1 行展示指令不合法");
expect(mock.mutations).toEqual([]);
});
test("presentation panel keeps its header fixed while map controls, chart sources and compact layouts remain usable", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const mock = await mockInvestigationPresentation(page, []);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const tools = page.getByTestId("investigation-dev-tools");
await tools.getByRole("button", { name: "载入合成样例", exact: true }).click();
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(tools.getByRole("status")).toContainText("本地预览完成");
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
const panel = page.getByLabel("本地预览展示", { exact: true });
const header = panel.locator(":scope > header");
const content = panel.locator("[data-investigation-content]");
const chart = panel.locator('[data-investigation-chart="dev-synthetic-pressure"]');
const overlay = panel.getByRole("region", { name: "调查覆盖层" });
const headerY = (await header.boundingBox())!.y;
await overlay.getByRole("button", { name: "隐藏覆盖层", exact: true }).click();
await overlay.getByRole("button", { name: "显示覆盖层", exact: true }).click();
await overlay.getByRole("button", { name: "定位覆盖层", exact: true }).click();
await expect(panel.getByText("镜头跟随已暂停", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "恢复自动定位", exact: true }).click();
await chart.getByRole("button", { name: /查看依据/ }).click();
await expect(chart.getByText("合成测试数据,未保存为调查证据。", { exact: true })).toBeVisible();
expect(Math.abs((await header.boundingBox())!.y - headerY)).toBeLessThanOrEqual(1);
await expect(panel).toHaveCSS("background-color", "rgb(255, 255, 255)");
await expect(chart).toHaveCSS("background-color", "rgb(255, 255, 255)");
await expect(chart.locator(".agent-panel-nested")).toHaveCount(0);
for (const [name, width, height] of [["desktop", 1440, 900], ["compact", 1024, 640], ["mobile", 375, 812]] as const) {
await page.setViewportSize({ width, height });
await chart.getByRole("img", { name: "本地合成压力趋势", exact: true }).scrollIntoViewIfNeeded();
expect(await panel.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
const plot = (await chart.getByRole("img", { name: "本地合成压力趋势", exact: true }).boundingBox())!;
const viewport = (await content.boundingBox())!;
expect(plot.y).toBeGreaterThanOrEqual(viewport.y - 1);
expect(plot.y + plot.height).toBeLessThanOrEqual(viewport.y + viewport.height + 1);
await testInfo.attach(`presentation-${name}`, {
body: await panel.screenshot({ path: testInfo.outputPath(`presentation-${name}.png`) }), contentType: "image/png"
});
}
await page.setViewportSize({ width: 1440, height: 900 });
await panel.getByRole("button", { name: "收起展示", exact: true }).click();
await expect(panel).toHaveAttribute("data-investigation-collapsed", "true");
await expect(chart).toHaveCount(0);
await panel.getByRole("button", { name: "展开展示", exact: true }).click();
await expect(chart).toBeVisible();
await chart.getByRole("button", { name: "关闭图表 本地合成压力趋势", exact: true }).click();
await expect(chart).toHaveCount(0);
await expect(overlay).toBeVisible();
expect(mock.mutations).toEqual([]);
});
for (const reducedMotion of ["no-preference", "reduce"] as const) {
test(`presentation actions remain interruptible with ${reducedMotion} motion`, async ({ page }) => {
test.setTimeout(60_000);
await page.emulateMedia({ reducedMotion });
const mock = await mockInvestigationPresentation(page, []);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const tools = page.getByTestId("investigation-dev-tools");
await tools.getByRole("button", { name: "载入合成样例", exact: true }).click();
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(tools.getByRole("status")).toContainText("本地预览完成");
const panel = page.getByLabel("本地预览展示", { exact: true });
const chart = panel.locator('[data-investigation-chart="dev-synthetic-pressure"]');
const source = chart.getByRole("button", { name: /查看依据/ });
await source.click();
await expect(source).toHaveAttribute("aria-expanded", "true");
await source.click();
await expect(source).toHaveAttribute("aria-expanded", "false");
await source.focus();
await page.keyboard.press("Enter");
await expect(source).toHaveAttribute("aria-expanded", "true");
const overlay = panel.getByRole("region", { name: "调查覆盖层" });
await overlay.getByRole("button", { name: "隐藏覆盖层", exact: true }).click();
await overlay.getByRole("button", { name: "显示覆盖层", exact: true }).click();
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap?.getLayoutProperty("preview-presentation-overlay-point", "visibility"))).toBe("visible");
await page.evaluate(() => {
const map = window.__waterNetworkMap!;
const center = map.getCenter();
map.jumpTo({ center: [center.lng + 0.002, center.lat + 0.001], zoom: 16 });
});
await overlay.getByRole("button", { name: "定位覆盖层", exact: true }).click();
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap?.isMoving())).toBe(false);
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap?.getZoom())).toBeCloseTo(18, 2);
await panel.getByRole("button", { name: "收起展示", exact: true }).click();
await expect(panel).toHaveAttribute("data-investigation-collapsed", "true");
await expect(chart).toHaveCount(0);
await panel.getByRole("button", { name: "展开展示", exact: true }).click();
await expect(chart).toBeVisible();
await chart.getByRole("button", { name: "关闭图表 本地合成压力趋势", exact: true }).click();
await expect(chart).toHaveCount(0);
await expect(overlay).toBeVisible();
expect(mock.mutations).toEqual([]);
});
}
test("presentation history keeps JSONL results and map snapshots after switching cases or clearing", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const mock = await mockInvestigationPresentation(page, []);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const tools = page.getByTestId("investigation-dev-tools");
await tools.getByRole("button", { name: "载入合成样例", exact: true }).click();
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(tools.getByRole("status")).toContainText("本地预览完成");
const panel = page.getByLabel("本地预览展示", { exact: true });
const history = panel.getByRole("combobox", { name: "切换展示动作" });
const overlayData = () => page.evaluate(async () => {
const source = window.__waterNetworkMap?.getSource("preview-presentation-overlay") as { getData: () => Promise<unknown> };
return source.getData();
});
const captured = await overlayData();
await tools.getByLabel("调查展示 JSONL", { exact: true }).fill([
"clear_highlights", "clear_overlay", "clear_scada_analysis"
].map((action) => JSON.stringify({ record_type: "presentation", command: { action, ...(action === "clear_overlay" ? { overlay_id: "dev-synthetic-area" } : {}) } })).join("\n"));
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(tools.getByRole("status")).toContainText("本地预览完成:3 条展示指令");
await expect(history).toContainText("3 个动作");
await expect.poll(overlayData).toEqual({ type: "FeatureCollection", features: [] });
await tools.getByRole("combobox", { name: "JSONL 测试案例" }).click();
await page.getByRole("option", { name: "隔离阀门清单", exact: true }).click();
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(panel.getByRole("region", { name: "展示结果" })).toContainText("合成隔离阀门清单");
await expect(tools.locator("[data-answer-ui]")).toHaveCount(0);
await expect(history).toContainText("4 个动作");
await history.click();
await page.getByRole("option", { name: /^\d+\. 打开图表 · 本地合成压力趋势 · 完成$/ }).click();
await expect(panel).toContainText("历史回看");
await expect(panel.getByRole("region", { name: "展示结果" })).toContainText("合成候选对比");
await expect(panel.locator('[data-investigation-chart="dev-synthetic-pressure"]')).toBeVisible();
await expect.poll(overlayData).toEqual(captured);
await expect(panel.getByRole("button", { name: "隐藏覆盖层" })).toBeDisabled();
await expect(panel.getByRole("button", { name: "关闭图表 本地合成压力趋势" })).toBeDisabled();
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
await testInfo.attach("history-map-and-chart", { body: await panel.screenshot({ path: testInfo.outputPath("history-map-and-chart.png") }), contentType: "image/png" });
await panel.getByRole("button", { name: "回到当前", exact: true }).click();
await expect(panel.getByRole("region", { name: "展示结果" })).toContainText("合成隔离阀门清单");
await expect(panel.locator("[data-investigation-chart]")).toHaveCount(0);
await expect.poll(overlayData).toEqual({ type: "FeatureCollection", features: [] });
await tools.getByRole("button", { name: "清除临时展示", exact: true }).click();
await expect(panel).toContainText("当前没有展示结果");
await expect(history).toContainText("4 个动作");
await panel.getByRole("button", { name: "上一个动作" }).click();
await expect(panel.getByRole("region", { name: "展示结果" })).toContainText("合成隔离阀门清单");
await panel.getByRole("button", { name: "下一个动作" }).click();
await expect(panel).toContainText("当前没有展示结果");
expect(mock.mutations).toEqual([]);
});
test("live actions continue recording without interrupting a historical view and can return to latest", async ({ page }) => {
test.setTimeout(60_000);
const task = investigationFixture(1, "历史回看中的实时调查");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
const panel = page.getByLabel("调查实时展示", { exact: true });
await expect(panel.getByText("实时跟随", { exact: true })).toBeVisible();
const chart = (id: number) => ({ action: "open_chart", chart_id: "live-history", evidence_refs: ["dev:synthetic"], chart: { title: `压力快照 ${id}`, chart_type: "line", x_data: ["08:00"], series: [{ name: "压力", data: [id] }] } });
await mock.emit(task, chart(1));
await expect(panel.getByRole("group", { name: "压力快照 1", exact: true })).toBeVisible();
const switcher = panel.getByRole("combobox", { name: "切换展示动作" });
await expect(switcher).toContainText("压力快照 1");
await switcher.click();
await expect(page.getByRole("option")).toHaveCount(1);
await expect(page.getByRole("option", { name: /当前(最新).*压力快照 1/ })).toBeVisible();
await page.keyboard.press("Escape");
await expect(panel.getByRole("button", { name: "上一个动作" })).toBeDisabled();
await mock.emit(task, chart(2));
await expect(switcher).toContainText("压力快照 2");
await switcher.click();
await expect(page.getByRole("option")).toHaveCount(2);
await page.getByRole("option", { name: "1. 打开图表 · 压力快照 1 · 完成", exact: true }).click();
await expect(panel).toContainText("历史回看");
for (let id = 3; id <= 12; id++) await mock.emit(task, chart(id));
await expect(switcher).toContainText("1. 打开图表");
await switcher.click();
await expect(page.getByRole("option")).toHaveText([
/当前(最新).*压力快照 12/,
...Array.from({ length: 11 }, (_, index) => `${11 - index}. 打开图表 · 压力快照 ${11 - index} · 完成`)
]);
await expect(page.getByRole("option", { name: /当前(最新).*压力快照 12/ })).toBeVisible();
await expect(page.getByRole("option", { name: /^12\. 打开图表/ })).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(panel.getByRole("group", { name: "压力快照 1", exact: true })).toBeVisible();
await expect(panel.getByRole("group", { name: "压力快照 12", exact: true })).toHaveCount(0);
await panel.getByRole("button", { name: "回到当前", exact: true }).click();
await expect(panel.getByRole("group", { name: "压力快照 12", exact: true })).toBeVisible();
await panel.getByRole("button", { name: "上一个动作" }).focus();
await page.keyboard.press("Enter");
await expect(switcher).toContainText("11. 打开图表");
await expect(panel.getByRole("group", { name: "压力快照 11", exact: true })).toBeVisible();
expect(mock.mutations).toEqual([]);
});
test("selecting an investigation restores saved actions, keeps live continuity and deduplicates task reselection", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const task = investigationFixture(1, "已保存展示的调查");
const completed = investigationFixture(2, "已完成的历史调查");
completed.status = "completed";
completed.lifecycle_status = "completed";
const runId = completed.active_run!.run_id;
completed.active_run = null;
const command = (title: string) => ({ action: "open_chart", chart_id: "saved-pressure", evidence_refs: ["saved:reading"], chart: { title, chart_type: "line", x_data: ["08:00"], series: [{ name: "压力", data: [20] }] } });
const overlay = { action: "set_overlay", overlay_id: "saved-area", title: "保存的影响范围", mode: "geometry", evidence_id: "saved:area", data: { type: "FeatureCollection", features: [{ type: "Feature", properties: {}, geometry: { type: "Polygon", coordinates: [[[121.48,31.1],[121.49,31.1],[121.49,31.11],[121.48,31.1]]] } }] } };
const saved = [command("初次压力对比"), { action: "close_chart", chart_id: "saved-pressure" }, overlay, command("复核压力对比")].map((command, index) => ({ task_id: task.task_id, run_id: task.active_run!.run_id, event_seq: index + 1, execution_generation: 1, occurred_at: task.created_at, command }));
saved.push({ task_id: completed.task_id, run_id: runId, event_seq: 5, execution_generation: 1, occurred_at: completed.created_at, command: command("已完成调查的压力曲线") });
const mock = await mockInvestigationPresentation(page, [task, completed], saved);
await selectInvestigation(page, task.title);
const panel = page.getByLabel("调查实时展示", { exact: true });
const switcher = panel.getByRole("combobox", { name: "切换展示动作" });
await expect(switcher).toContainText("4 个动作");
await expect(panel.getByRole("group", { name: "复核压力对比", exact: true })).toBeVisible();
await expect(panel.getByRole("region", { name: "调查覆盖层" })).toContainText("保存的影响范围");
await switcher.click();
await expect(page.getByRole("option")).toHaveCount(4);
await page.getByRole("option", { name: "1. 打开图表 · 初次压力对比 · 完成", exact: true }).click();
await expect(panel.getByRole("group", { name: "初次压力对比", exact: true })).toBeVisible();
await expect(panel.getByRole("region", { name: "调查覆盖层" })).toHaveCount(0);
await mock.emit(task, command("当前实时压力"));
await expect(panel.getByRole("status")).toContainText("后续 4 个动作");
await panel.getByRole("button", { name: "回到当前", exact: true }).click();
await expect(switcher).toContainText("5 个动作");
await expect(panel.getByRole("group", { name: "当前实时压力", exact: true })).toBeVisible();
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
await testInfo.attach("saved-investigation-history", { body: await panel.screenshot({ path: testInfo.outputPath("saved-investigation-history.png") }), contentType: "image/png" });
// Turn off live following, then an already completed task still loads its saved presentation.
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
await page.getByRole("checkbox", { name: "跟随所选调查", exact: true }).uncheck();
await expect(panel.getByText("已载入历史", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const tasks = page.getByRole("region", { name: "工况任务", exact: true });
// Completed tasks can be selected through the all-status filter.
const completedTitle = tasks.getByText(completed.title, { exact: true }).first();
if (!await completedTitle.isVisible()) {
const all = tasks.getByRole("button", { name: /全部/ }).first();
if (await all.isVisible()) await all.click();
}
await completedTitle.click();
await expect(panel.getByRole("group", { name: "已完成调查的压力曲线", exact: true })).toBeVisible();
await expect(switcher).toContainText("1 个动作");
await tasks.getByText(task.title, { exact: true }).first().click();
await expect(switcher).toContainText("5 个动作");
await expect(panel.getByRole("group", { name: "当前实时压力", exact: true })).toBeVisible();
expect(mock.mutations).toEqual([]);
});
@@ -0,0 +1,101 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
for (const width of [1440, 375, 320]) {
test(`investigation diagram displays recorded node states at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockAgentApi(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.setContent('<html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="state-reader" class="workbench-shadcn-surface bg-background text-foreground" style="max-width:760px;margin:auto;padding:12px"></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 { InvestigationDiagramView } = await import("/src/features/workbench/components/investigation-diagram-view.tsx");
const diagram = {
title: "调查节点状态",
mermaid: 'flowchart TD\nP[待启动核查] --> R[核对监测]:::tjAction\nR --> C[核查完成]:::tjHuman\nR --> W[等待系统结果]\nR --> H[等待人工复核]\nC --> F[查询失败]\nC --> S[未选用分支]\nW --> X[取消分支]\nH --> U[状态未记录]\nstyle R fill:#FFF6E8,stroke:#C4A168;',
node_refs: [
{ node_id: "P", label: "待启动核查", status: "pending", evidence_refs: [], action_refs: [] },
{ node_id: "R", label: "核对监测", status: "running", evidence_refs: ["e1"], action_refs: ["50000000-0000-4000-8000-000000000001"] },
{ node_id: "C", label: "核查完成", status: "completed", evidence_refs: [], action_refs: [] },
{ node_id: "W", label: "等待系统结果", status: "waiting", evidence_refs: [], action_refs: [] },
{ node_id: "H", label: "等待人工复核", status: "waiting_human", evidence_refs: [], action_refs: [] },
{ node_id: "F", label: "查询失败", status: "failed", evidence_refs: [], action_refs: [] },
{ node_id: "S", label: "未选用分支", status: "skipped", evidence_refs: [], action_refs: [] },
{ node_id: "X", label: "取消分支", status: "cancelled", evidence_refs: [], action_refs: [] },
{ node_id: "U", label: "状态未记录", evidence_refs: ["e2"], action_refs: [] }
]
};
const root = createRoot(document.getElementById("state-reader"));
const render = (running: boolean) => root.render(React.createElement(InvestigationDiagramView, {
diagram: { ...diagram, node_refs: [...diagram.node_refs] }, running,
renderDetails: () => React.createElement("p", null, "状态来自节点快照,关联证据不代表步骤已经完成。")
}));
Object.assign(window, { setDiagramState: (running: boolean, status = "running") => {
diagram.node_refs[1].status = status;
render(running);
} });
render(true);
});
const diagram = page.getByRole("region", { name: "调查脉络", exact: true });
const node = (className: string) => diagram.locator(`svg g.node.${className}`);
const shape = (className: string) => node(className).locator("rect,polygon,circle,ellipse,path").first();
await expect(diagram.locator("svg g.node")).toHaveCount(9);
for (const [className, fill, stroke] of [
["tjPending", "rgb(246, 248, 252)", "rgb(165, 181, 204)"],
["tjRunning", "rgb(232, 240, 255)", "rgb(66, 99, 199)"],
["tjCompleted", "rgb(237, 247, 244)", "rgb(107, 158, 139)"],
["tjWaiting", "rgb(255, 251, 234)", "rgb(200, 172, 96)"],
["tjWaitingHuman", "rgb(255, 246, 232)", "rgb(196, 161, 104)"],
["tjFailed", "rgb(254, 242, 242)", "rgb(217, 108, 117)"],
["tjSkipped", "rgb(246, 248, 252)", "rgb(165, 181, 204)"],
["tjCancelled", "rgb(241, 245, 249)", "rgb(148, 163, 184)"],
["tjUnknown", "rgb(255, 255, 255)", "rgb(204, 214, 229)"]
]) {
await expect(node(className)).toHaveCount(1);
await expect(shape(className)).toHaveCSS("fill", fill);
await expect(shape(className)).toHaveCSS("stroke", stroke);
}
const legend = diagram.getByLabel("图示状态说明");
await expect(legend).toContainText("等待依赖 / 外部结果");
await expect(legend).toContainText("等待人工处理");
await expect(legend).toContainText("未标注状态");
expect(await shape("tjSkipped").evaluate((element) => getComputedStyle(element).strokeDasharray)).not.toBe("none");
await diagram.getByRole("button", { name: /^核对监测 · 正在执行/ }).click();
await expect(diagram.getByLabel("节点详情 核对监测")).toContainText("正在执行");
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await diagram.screenshot({ path: testInfo.outputPath(`node-states-${width}.png`) });
await diagram.getByRole("button", { name: "放大查看", exact: true }).click();
const reader = page.getByRole("dialog", { name: "调查节点状态", exact: true });
await expect(reader.locator("svg g.node.tjRunning > rect")).toHaveCSS("fill", "rgb(232, 240, 255)");
const bounds = (await reader.boundingBox())!;
expect(bounds.width).toBeLessThan(width);
expect(bounds.height).toBeLessThan(900);
await reader.getByRole("button", { name: "Zoom in", exact: true }).click();
const application = reader.getByRole("application");
const transform = await application.evaluate((element) => {
element.setAttribute("data-state-test-instance", "retained");
return (element as HTMLElement).style.transform;
});
await page.evaluate(() => (window as unknown as { setDiagramState: (running: boolean) => void }).setDiagramState(false));
const archived = reader.locator("svg g.node.tjRecordedRunning > rect");
await expect(archived).toHaveCSS("fill", "rgb(246, 248, 252)");
await expect(reader.getByLabel("图示状态说明")).toContainText("记录时执行中");
await expect(reader.locator("svg g.node.tjCompleted > rect")).toHaveCSS("fill", "rgb(237, 247, 244)");
await expect(reader.locator("svg g.node")).toHaveCount(9);
await expect(application).toHaveAttribute("data-state-test-instance", "retained");
expect(await application.evaluate((element) => (element as HTMLElement).style.transform)).toBe(transform);
await page.evaluate(() => (window as unknown as { setDiagramState: (running: boolean, status: string) => void }).setDiagramState(true, "completed"));
await expect(reader.locator("svg g.node.tjCompleted")).toHaveCount(2);
await expect(reader.locator("svg g.node.tjRunning")).toHaveCount(0);
await expect(reader.getByLabel("节点详情 核对监测")).toContainText("已完成");
});
}
+57 -6
View File
@@ -82,19 +82,59 @@ test("证据提问在预览、展开及更多依据中使用统一实体按钮",
async function expectTaskReferenceLayout(panel: Locator) {
await expect(panel).toHaveAttribute("data-agent-starting", "true");
await expect(panel.getByRole("group", { name: "对话引用" })).toBeVisible();
const reference = panel.getByRole("group", { name: "对话引用" });
await expect(reference).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(reference).toHaveCSS("font-size", "14px");
expect((await reference.boundingBox())!.height).toBeLessThanOrEqual(48);
expect(await reference.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
await expect(panel.locator("form").getByText("任务协作问答", { exact: true })).toHaveCount(0);
await expect(panel.getByRole("group", { name: "推荐问题" })).toHaveCount(0);
await expect(panel.locator("form")).toHaveCount(1);
// Existing task state keeps the normal reading area, as required by agent-chat-ui-design.md.
const status = panel.getByRole("region", { name: "任务协作状态" });
await expect(status).toBeVisible();
await expect(panel.locator("form")).not.toHaveCSS("box-shadow", "none");
const form = panel.locator("form");
const appearance = () => form.evaluate((element) => {
const style = getComputedStyle(element);
return { border: style.border, shadow: style.boxShadow, outline: style.outlineStyle };
});
await panel.getByRole("textbox").evaluate((element) => element.blur());
const unfocusedAppearance = await appearance();
await panel.getByRole("textbox").focus();
const focusedAppearance = await appearance();
expect(focusedAppearance.border).toBe(unfocusedAppearance.border);
expect(focusedAppearance.outline).toBe(unfocusedAppearance.outline);
expect(focusedAppearance.shadow).not.toBe(unfocusedAppearance.shadow);
expect(unfocusedAppearance.outline).toBe("none");
for (const detail of await reference.locator("[data-agent-reference-detail] > span").all()) {
expect((await detail.boundingBox())!.height).toBeLessThanOrEqual(20);
}
const modelBox = (await panel.getByRole("button", { name: "选择 Agent 模型" }).boundingBox())!;
const submitBox = (await panel.getByRole("button", { name: "发送 Agent 指令" }).boundingBox())!;
const approval = panel.getByRole("button", { name: "权限批准模式" });
await expect(approval).toBeEnabled();
await expect(approval).toHaveText("请求批准");
const approvalBox = (await approval.boundingBox())!;
expect(approvalBox.x).toBeLessThan(modelBox.x);
expect(approvalBox.y).toBe(submitBox.y);
await expect(approval.locator("svg").first()).toHaveCSS("width", "16px");
await expect(approval.locator("svg").last()).toHaveCSS("width", "12px");
await expect(panel.getByRole("button", { name: "选择 Agent 模型" }).locator("svg").first()).toHaveCSS("width", "16px");
const sendIcon = panel.getByRole("button", { name: "发送 Agent 指令" }).locator("svg");
await expect(sendIcon).toHaveCSS("width", "20px");
const iconBox = (await sendIcon.boundingBox())!;
expect(Math.abs(iconBox.x + iconBox.width / 2 - submitBox.x - submitBox.width / 2)).toBeLessThan(0.5);
expect(Math.abs(iconBox.y + iconBox.height / 2 - submitBox.y - submitBox.height / 2)).toBeLessThan(0.5);
expect(modelBox.x).toBeLessThan(submitBox.x);
expect(modelBox.y).toBe(submitBox.y);
const formBox = (await form.boundingBox())!;
const toolsBox = (await panel.locator(".agent-panel-input-tools").boundingBox())!;
expect(Math.abs((toolsBox.x - formBox.x) - (formBox.x + formBox.width - toolsBox.x - toolsBox.width))).toBeLessThan(1);
await expect(panel.getByRole("region", { name: "任务协作状态" })).toHaveCount(0);
await expect(panel.getByRole("button", { name: "确认并创建预约工单" })).toHaveCount(0);
await expect.poll(async () => {
const panelBox = (await panel.boundingBox())!;
const composer = (await panel.locator("form").boundingBox())!;
return panelBox.y + panelBox.height - composer.y - composer.height;
}).toBeLessThan(32);
const statusBox = (await status.boundingBox())!;
const composerBox = (await panel.locator("form").boundingBox())!;
expect(composerBox.y).toBeGreaterThanOrEqual(statusBox.y + statusBox.height);
}
test("单一协作入口展示任务引用,保留草稿且仅手动发送,并适配窄屏", async ({ page }, testInfo) => {
@@ -162,10 +202,16 @@ test("单一协作入口展示任务引用,保留草稿且仅手动发送,
await expect(feed.getByRole("button", { name: "Agent 协作", exact: true })).toHaveAttribute("title", "打开调查协作对话,引用此任务,不会直接发送。");
await feed.screenshot({ path: testInfo.outputPath("investigation-entry-desktop.png") });
await feed.getByRole("button", { name: "Agent 协作", exact: true }).click();
// The session fixture still has a legacy title; the creation receipt is authoritative.
await expect(agent.locator(".agent-chat-context-bar > p")).toHaveText(task.title);
await expect(agent.locator("form").getByText("任务协作问答", { exact: true })).toHaveCount(0);
const reference = agent.getByRole("group", { name: "对话引用" });
await expect(reference).toContainText("居民异味投诉调查");
await expect(reference).toContainText("第 1 轮 · 报告第 3 版");
expect((await reference.boundingBox())!.height).toBe(28);
const titleBox = (await reference.locator("[data-agent-reference-title]").boundingBox())!;
const detailBox = (await reference.locator("[data-agent-reference-detail]").boundingBox())!;
expect(Math.abs(titleBox.y - detailBox.y)).toBeLessThanOrEqual(1);
await expect(reference).not.toContainText(/当前|第 2 轮|已结束|调查完成|正在调查/);
await expect(prompt).toHaveAttribute("placeholder", "可询问调查进展、讨论报告或补充信息");
await expect(prompt).toHaveValue("保留的现场补充草稿");
@@ -207,9 +253,14 @@ test("单一协作入口展示任务引用,保留草稿且仅手动发送,
expect(await mobileAgent.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe(true);
await page.setViewportSize({ width: 375, height: 900 });
let sentContext: unknown;
const approval = mobileAgent.getByRole("button", { name: "权限批准模式" });
await approval.click();
await page.getByRole("menuitem", { name: /自动批准本轮工具权限请求/ }).click();
await expect(approval).toHaveText("始终允许");
await page.route("**/api/v1/responses", async (route) => {
if (route.request().method() !== "POST") return route.fallback();
sentContext = route.request().postDataJSON().tjwater.task_context;
expect(route.request().postDataJSON().tjwater.approval_mode).toBe("always");
expect(route.request().postDataJSON().conversation).toBe(`collaboration-${taskId}`);
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "X-Vercel-AI-UI-Message-Stream": "v1" }, contentType: "text/event-stream",
body: responsesFixture([{ type: "start", messageId: "reply" }, { type: "finish", finishReason: "stop" }]) });
@@ -0,0 +1,99 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { investigationFixture, mockInvestigationPresentation } from "./support/mock-investigation-presentation";
import type { OperationalTaskNotification } from "../../src/features/workbench/api/operational-task-client";
for (const width of [1440, 375]) test(`task question keyboard submission and retry at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await mockWorkbenchContext(page);
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="questions" style="max-width:680px;margin:auto;padding:16px"></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 paths unavailable");
const reactModule = await import(reactUrl);
const React = reactModule.default ?? reactModule;
const clientModule = await import(clientUrl);
const { createRoot } = clientModule.default ?? clientModule;
const { InvestigationQuestions } = await import("/src/features/workbench/components/investigation-questions.tsx");
const calls: unknown[][] = [];
Object.assign(window, { questionCalls: calls });
const question = { question_request_id: "q1", version: 1, execution_generation: 1, status: "pending", answers: [],
questions: [{ question_id: "flow", title: "是否有流量计记录", body: "已查询接入测点,需要补充事件时段的计量依据。",
options: [{ id: "unknown", label: "目前未知" }, { id: "yes", label: "有计量记录" }], mode: "single", allow_custom: true, required: true }] };
createRoot(document.getElementById("questions")).render(React.createElement(InvestigationQuestions, {
task: { questions: [question], execution_generation: 1, lifecycle_status: "waiting_human", active_run: null,
permissions: { can_answer_questions: true } },
onAnswer: async (...args: unknown[]) => {
calls.push(args);
if (calls.length === 1) throw new Error("网络暂时不可用,请重试");
}
}));
});
const submit = page.getByRole("button", { name: "提交回答并继续调查" });
await expect(submit).toBeDisabled();
await page.getByRole("radio", { name: "目前未知" }).focus();
await page.keyboard.press("Space");
await page.getByRole("textbox").fill("目前没有记录,请继续已有候选分析。");
await submit.focus(); await page.keyboard.press("Enter");
await expect(page.getByRole("alert")).toContainText("网络暂时不可用");
await expect(page.getByRole("textbox")).toHaveValue("目前没有记录,请继续已有候选分析。");
await submit.focus(); await page.keyboard.press("Enter");
await expect(page.getByText("回答已保存,调查已排队继续。")).toBeVisible();
const calls = await page.evaluate(() => (window as unknown as { questionCalls: unknown[][] }).questionCalls);
expect(calls).toHaveLength(2);
expect(calls[0]).toEqual(calls[1]);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
});
test("task notification opens the question and submits through the scoped task API", async ({ page }) => {
const task = investigationFixture(11, "等待补充流量依据");
const run = task.active_run!;
task.active_run = null; task.latest_progress = null;
task.lifecycle_status = "waiting_human"; task.status = "waiting_human";
task.permissions.can_answer_questions = true;
task.questions = [{ question_request_id: "95000000-0000-4000-8000-000000000011", task_id: task.task_id,
run_id: run.run_id, source_session_id: "investigation-dev-test", execution_generation: 1, version: 1,
status: "pending", created_at: task.created_at, answered_at: null, answered_by: null, answers: [],
questions: [{ question_id: "flow", title: "是否有流量计记录", body: "需要补充事件时段的计量依据。",
options: [{ id: "unknown", label: "目前未知" }], mode: "single", allow_custom: true, required: true }] }];
const notification: OperationalTaskNotification = { notification_id: "96000000-0000-4000-8000-000000000011",
task_id: task.task_id, kind: "human_input_required", title: task.title, description: "是否有流量计记录",
lifecycle_status: "active", subject_type: "question", subject_id: task.questions[0].question_request_id,
resolved_at: null, resolution_reason: null, status: "unread", version: 1, required_permission: "operational_task.write",
created_at: task.created_at, read_at: null, read_by: null };
await mockInvestigationPresentation(page, [task], [], [notification]);
await page.route("**/operational-task-notifications/**/acknowledge", (route) => route.fulfill({ json: { ...notification,
status: "read", version: 2, read_at: task.created_at, read_by: "user" } }));
let submitted: unknown;
await page.route(`**/operational-tasks/${task.task_id}/questions/*/answer`, async (route) => {
submitted = route.request().postDataJSON();
task.questions![0] = { ...task.questions![0], status: "answered", version: 2, answered_by: "user",
answered_at: task.updated_at, answers: route.request().postDataJSON().answers };
task.execution_generation = 2; task.lifecycle_status = "open"; task.status = "queued"; task.revision += 1;
task.active_run = { ...run, generation: 2, status: "queued" };
await route.fulfill({ json: task });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /查看待办与告警,当前 1 条/ }).first().click();
await page.getByText(task.title, { exact: true }).first().click();
const question = page.getByRole("region", { name: "调查提问" });
await expect(question).toBeVisible();
await expect(page.getByRole("region", { name: "工况任务", exact: true }).getByRole("region", { name: "调查提问" })).toBeVisible();
const chatWrites: string[] = [];
page.on("request", (request) => {
if (request.method() === "POST" && /127\.0\.0\.1:8787\/api\/v1\/(sessions|responses)/.test(request.url())) chatWrites.push(request.url());
});
await question.getByRole("radio", { name: "目前未知" }).check();
await question.getByRole("textbox").fill("尚无计量记录");
await question.getByRole("button", { name: "提交回答并继续调查" }).click();
await expect(question.getByText("目前未知;尚无计量记录")).toBeVisible();
expect(submitted).toMatchObject({ expected_version: 1, request_id: expect.any(String),
answers: [{ question_id: "flow", selected_option_ids: ["unknown"], custom_text: "尚无计量记录" }] });
await expect(question.getByRole("button")).toHaveCount(0);
expect(chatWrites).toEqual([]);
});
@@ -43,6 +43,10 @@ test.beforeEach(async ({ page }) => {
task = { ...task, latest_report: { ...report, revision: task.latest_report.revision + 1,
report: { ...report.report, current_conclusion: "新增现场记录已接收,等待复核。" } } };
render();
}, showLongInvestigationReport: () => {
task = { ...task, latest_report: { ...report, revision: task.latest_report.revision + 1,
report: { ...report.report, current_conclusion: "这是仅用于浏览器验证的长篇调查记录,需结合监测时间、资料来源和现场核查结果逐项复核。".repeat(40) } } };
render();
}, showHistoricalUtcReport: () => {
task = { ...task, latest_report: { ...report, revision: 2, report: { ...report.report,
current_conclusion: "上报时间2026-10-08T04:00:00Z,查询04:00–05:00Z。",
@@ -88,7 +92,7 @@ for (const clearProgressDiagram of [false, 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 });
const diagram = page.getByRole("region", { name: "调查脉络", exact: true, includeHidden: 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);
@@ -142,7 +146,8 @@ test("报告摘要与有界详情分层,保留人工事项、阅读版本和
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 expect.poll(async () => Math.round((await detail.boundingBox())!.width)).toBeGreaterThan(704);
expect((await detail.boundingBox())!.width).toBeLessThanOrEqual(900);
await detail.screenshot({ path: testInfo.outputPath("report-expanded-desktop.png") });
await page.evaluate(() => (window as unknown as { updateInvestigationReport: () => void }).updateInvestigationReport());
await expect(detail).toContainText("上游监测未见明显异常,仍需现场核查。");
@@ -164,8 +169,8 @@ test("报告摘要与有界详情分层,保留人工事项、阅读版本和
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 expect.poll(async () => Math.round((await evidence.boundingBox())!.width)).toBe(359);
expect((await evidence.boundingBox())!.height).toBeLessThanOrEqual(704);
await evidence.screenshot({ path: testInfo.outputPath("report-expanded-narrow.png") });
await evidence.getByRole("button", { name: "关闭详情" }).click();
await expect(evidence).toHaveCount(0);
@@ -174,7 +179,7 @@ test("报告摘要与有界详情分层,保留人工事项、阅读版本和
test("数据刷新按整份报告错峰揭示,保留图表与滚动位置并支持减少动态效果", async ({ page }, testInfo) => {
const report = page.getByRole("article", { name: "调查任务报告" });
const diagram = page.getByRole("region", { name: "调查脉络", exact: true });
const diagram = page.getByRole("region", { name: "调查脉络", exact: true, includeHidden: true });
await expect(diagram.locator("svg .node")).toHaveCount(2);
const before = await report.evaluate((element) => {
const graph = element.querySelector(".investigation-diagram svg");
@@ -226,12 +231,68 @@ test("数据刷新按整份报告错峰揭示,保留图表与滚动位置并
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.
// Pointer interaction ends the refresh before the reading dialog opens.
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);
await expect.poll(async () => Math.round((await detail.boundingBox())!.width)).toBe(359);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
await page.screenshot({ path: testInfo.outputPath("report-refresh-narrow-detail.png") });
});
for (const width of [1440, 320]) {
test(`当前结果和图示采用统一暗色阅读窗口 ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
const trigger = page.getByRole("button", { name: "展开当前结果", exact: true });
await trigger.click();
const result = page.getByRole("dialog", { name: "当前结果", exact: true });
await expect(result).toBeVisible();
await expect(result).toHaveCSS("animation-name", "none");
await expect(result).toHaveCSS("border-radius", "12px");
const backdrop = page.locator(".investigation-detail-backdrop");
await expect(backdrop).toHaveCSS("animation-name", "none");
await expect(backdrop).toHaveCSS("background-color", /0?\.8|80%/);
expect(await backdrop.boundingBox()).toEqual({ x: 0, y: 0, width, height: 900 });
const bounds = (await result.boundingBox())!;
expect(bounds.x).toBeGreaterThanOrEqual(8);
expect(bounds.y).toBeGreaterThan(0);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(width - 8);
expect(bounds.y + bounds.height).toBeLessThan(900);
await expect(result.getByText("上游监测未见明显异常,仍需现场核查。", { exact: true })).toHaveCSS("font-size", "16px");
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: testInfo.outputPath(`current-result-${width}.png`) });
if (width === 320) {
await page.evaluate(() => (window as unknown as { showLongInvestigationReport: () => void }).showLongInvestigationReport());
await result.getByRole("button", { name: "查看最新内容", exact: true }).click();
const scroll = result.locator(".investigation-detail-scroll");
expect(await scroll.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
expect((await result.boundingBox())!.height).toBeLessThanOrEqual(792);
await scroll.evaluate((element) => { element.scrollTop = element.scrollHeight; });
await expect(result.getByText("核实楼栋二次供水记录。", { exact: true })).toBeInViewport();
expect(await result.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(false);
}
await page.keyboard.press("Escape");
await expect(result).toHaveCount(0);
await expect(trigger).toBeFocused();
const diagram = page.getByRole("region", { name: "调查脉络", exact: true, includeHidden: true });
await diagram.scrollIntoViewIfNeeded();
await expect(diagram.locator("svg .node")).toHaveCount(2);
await diagram.locator(".investigation-diagram svg[id]").evaluate((element) => { Object.assign(window, { originalExpandedGraph: element }); });
const expandGraph = diagram.getByRole("button", { name: "放大查看", exact: true });
await expandGraph.click();
const graphWindow = page.getByRole("dialog", { name: "现场核查路线", exact: true });
await expect(graphWindow).toBeVisible();
expect(await graphWindow.locator(".investigation-diagram svg[id]").evaluate((element) => element === (window as unknown as { originalExpandedGraph: Element }).originalExpandedGraph)).toBe(true);
await expect(diagram.locator("[data-reading-preview] svg[id]")).toBeVisible();
await graphWindow.getByRole("button", { name: "Zoom in", exact: true }).click();
await expect(graphWindow.locator('[role="application"]')).toHaveCSS("transform", "matrix(1.1, 0, 0, 1.1, 0, 0)");
await page.screenshot({ path: testInfo.outputPath(`current-report-diagram-${width}.png`) });
await page.keyboard.press("Escape");
await expect(graphWindow).toHaveCount(0);
await expect(expandGraph).toBeFocused();
await expect(diagram.locator('[role="application"]')).toHaveCSS("transform", "matrix(1.1, 0, 0, 1.1, 0, 0)");
});
}
+1
View File
@@ -27,6 +27,7 @@ test("More clears controller and investigation overlays while keeping the invest
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const dev = page.getByTestId("map-dev-panel");
await dev.getByRole("tab", { name: "地图调试", exact: true }).click();
await dev.getByRole("combobox", { name: "业务源" }).click();
await page.getByRole("option", { name: "SCADA", exact: true }).click();
await dev.getByLabel("要素 ID(可多个)").fill("P1");
+7
View File
@@ -1,4 +1,9 @@
import { expect, test } from "@playwright/test";
import { mockInvestigationPresentation } from "./support/mock-investigation-presentation";
test.beforeEach(async ({ page }) => {
await mockInvestigationPresentation(page, []);
});
test("Dev Panel opens as a floating desktop panel and closes with Escape", async ({ page }) => {
test.setTimeout(60_000);
@@ -14,6 +19,7 @@ test("Dev Panel opens as a floating desktop panel and closes with Escape", async
const panel = page.getByTestId("map-dev-panel");
await expect(panel).toBeVisible();
await panel.getByRole("tab", { name: "地图调试", exact: true }).click();
await expect(panel).toHaveCSS("width", "400px");
await expect(panel).toHaveCSS("border-radius", "16px");
const panelBox = await panel.boundingBox();
@@ -62,6 +68,7 @@ test("labels remain active per business group and clear globally", async ({ page
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const panel = page.getByTestId("map-dev-panel");
await panel.getByRole("tab", { name: "地图调试", exact: true }).click();
await panel.locator("summary").filter({ hasText: "数值标注" }).click();
const apply = panel.getByRole("button", { name: "应用标注", exact: true });
await apply.click();
@@ -20,6 +20,7 @@ for (const width of [1440, 1024]) {
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const panel = page.getByTestId("map-dev-panel");
await panel.getByRole("tab", { name: "地图调试", exact: true }).click();
const ids = panel.getByRole("textbox", { name: "要素 ID(可多个)" });
await ids.fill("P-1,P-2\nP-1 missing");
await panel.getByRole("button", { name: "定位并高亮", exact: true }).click();
@@ -170,7 +170,7 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
scenario_code: "APPOINTMENT-TEST-001",
feedback_requirements: ["回填现场处理结果"],
activity: [],
origin: "synthetic",
origin: "system_data",
updated_at: "2026-09-23T08:30:00Z"
}
};
+127 -24
View File
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { expectThemeColor, readRenderedContrast } from "./support/theme-colors";
test.describe("外围系统", () => {
test.beforeEach(async ({ page }) => {
@@ -37,7 +38,7 @@ test.describe("外围系统", () => {
await page.route("http://map.test/**", (route) => route.fulfill({ status: 204 }));
});
test("外围面板只读取工单能力,不再请求监测回放与测试数据", async ({ page }) => {
test("外围面板只读取工单能力,不再请求监测回放与测试数据", async ({ page }, testInfo) => {
const requestedPaths: string[] = [];
await page.route("**/runtime-config.js", (route) =>
route.fulfill({
@@ -74,7 +75,7 @@ test.describe("外围系统", () => {
return route.fulfill({
json: {
protocol_version: "tjwater-peripheral-sample@1",
origin: "synthetic",
origin: "system_data",
project_id: projectId,
user_id: "50000000-0000-4000-8000-000000000001",
can_write: true,
@@ -99,13 +100,19 @@ test.describe("外围系统", () => {
await page.getByRole("button", { name: "外围系统", exact: true }).click();
const panel = page.getByRole("complementary", { name: "外围系统" });
await expect(panel).toBeVisible();
await expect(panel.getByText("工单接口测试", { exact: true })).toBeVisible();
await expect(panel.getByText("工单管理", { exact: true })).toBeVisible();
await expect(panel.getByText("外围工单系统", { exact: true })).toBeVisible();
await expect(panel.getByText("合成工单环境", { exact: true })).toHaveCount(0);
await expect(panel.getByText("历史监测回放", { exact: true })).toHaveCount(0);
await expect(panel.getByText("测试数据", { exact: true })).toHaveCount(0);
const appointmentList = panel.getByLabel("预约工单列表");
const complaintList = panel.getByLabel("投诉工单列表");
const burstList = panel.getByLabel("爆管上报工单列表");
await expect(appointmentList).toContainText("合成通信核查");
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
await testInfo.attach("peripheral-work-orders", {
body: await page.screenshot({ path: testInfo.outputPath("work-orders.png") }), contentType: "image/png"
});
await expect(appointmentList).toContainText("合成压力偏差复核");
await expect(appointmentList).toContainText("合成零流量样本确认");
await expect(appointmentList).toContainText("待处理");
@@ -113,6 +120,11 @@ test.describe("外围系统", () => {
await expect(appointmentList).toContainText("已完成");
await expect(appointmentList.getByText("工单报告", { exact: true })).toHaveCount(3);
await expect(appointmentList.getByText("暂无现场反馈", { exact: true })).toHaveCount(3);
const appointmentDetails = appointmentList.getByLabel("工单详情 合成通信核查", { exact: true });
await expect(appointmentDetails.getByText("工单报告", { exact: true })).toBeHidden();
await appointmentDetails.locator(":scope > summary").click();
await expect(appointmentDetails.getByText("工单报告", { exact: true })).toBeVisible();
await appointmentDetails.locator(":scope > summary").click();
await expect(complaintList).toContainText("水质投诉:演示小区");
await expect(complaintList.getByRole("region", { name: "投诉工单信息" })).toContainText("发生时间");
await expect(complaintList.getByText("工单报告", { exact: true })).toHaveCount(0);
@@ -142,7 +154,7 @@ test.describe("外围系统", () => {
await expect(
page
.getByRole("complementary", { name: "外围系统" })
.getByText("仅用于验证外围工单接口与同步过程,不代表真实现场处置", { exact: true })
.getByText("录入业务工单并跟踪处理反馈,事件调查依据实际查询和现场记录开展。", { exact: true })
.last()
).toBeVisible();
await expect(page.getByText(/当前不会连接外围工单服务/)).toBeVisible();
@@ -167,7 +179,7 @@ test.describe("外围系统", () => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/context")) return route.fulfill({ json: {
protocol_version: "tjwater-peripheral-sample@1", origin: "synthetic", project_id: projectId,
protocol_version: "tjwater-peripheral-sample@1", origin: "system_data", project_id: projectId,
user_id: "50000000-0000-4000-8000-000000000001", can_write: true,
scenarios: ["normal"], managed_resources: { work_orders: "available", test_data: "available" },
state_transitions: { pending: [], processing: [], pending_review: [], completed: [] }
@@ -183,7 +195,7 @@ test.describe("外围系统", () => {
record_id: "50000000-0000-4000-8000-000000000017",
project_id: projectId,
revision: 1,
origin: "synthetic",
origin: body.origin,
created_at: "2026-10-22T02:03:01Z",
updated_at: "2026-10-22T02:03:01Z",
created_by: "tester",
@@ -244,6 +256,11 @@ test.describe("外围系统", () => {
await expect(panel.getByLabel("SCADA 最新数据概览")).toContainText(/1\s*\/\s*2 个设备/);
expect(latestRequests).toEqual(["324ff7ee-c2d4-5184-a84b-48fb14a2933e"]);
await expect(panel.getByPlaceholder("输入需要演练的异常值")).toHaveCount(0);
await testInfo.attach("scada-overview", {
body: await page.screenshot({ path: testInfo.outputPath("scada-overview.png") }), contentType: "image/png"
});
await panel.getByRole("textbox", { name: "筛选 SCADA 设备" }).fill("pressure");
await expect(panel.getByText("PRESSURE-17", { exact: true })).toBeVisible();
await expect(panel.getByText("FLOW-09", { exact: true })).toHaveCount(0);
@@ -255,12 +272,14 @@ test.describe("外围系统", () => {
await panel.getByPlaceholder("输入需要演练的异常值").scrollIntoViewIfNeeded();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await testInfo.attach(`scada-peripheral-anomaly-${width}`, {
body: await page.screenshot(),
body: await page.screenshot({ path: testInfo.outputPath(`scada-${width}.png`) }),
contentType: "image/png"
});
}
await panel.getByPlaceholder("输入需要演练的异常值").fill("0.08");
await panel.getByPlaceholder("例如:压力突降,偏离近一小时稳定区间").fill("压力突降");
await expectThemeColor(panel.getByPlaceholder("输入需要演练的异常值"), "color", "--ink");
await expectThemeColor(panel.getByPlaceholder("例如:压力突降,偏离近一小时稳定区间"), "color", "--ink");
await panel.getByRole("button", { name: "保存模拟读数", exact: true }).click();
await expect(panel.getByLabel("SCADA 模拟异常列表")).toContainText("PRESSURE-17");
await expect(panel.getByLabel("SCADA 模拟异常列表")).toContainText("0.08 MPa");
@@ -272,7 +291,7 @@ test.describe("外围系统", () => {
});
});
test("管理面板打开时阻止地图接收鼠标行为", async ({ page }) => {
test("项目分析成果打开时保留地图遮挡", async ({ page }) => {
await page.goto("/");
await page.locator(".maplibregl-canvas-container").evaluate((element) => {
@@ -317,11 +336,6 @@ test.describe("外围系统", () => {
.toEqual([]);
};
await page.getByRole("button", { name: "外围系统", exact: true }).click();
await expect(page.getByRole("complementary", { name: "外围系统" })).toBeVisible();
await expectMapInteractionBlocked("外围系统");
await page.getByRole("button", { name: "外围系统", exact: true }).click();
await page.getByRole("button", { name: "项目分析成果" }).click();
await expect(page.getByRole("complementary", { name: "项目分析成果" })).toBeVisible();
await expectMapInteractionBlocked("项目分析成果");
@@ -345,7 +359,7 @@ test.describe("外围系统", () => {
return route.fulfill({ status: 503, json: {} });
}
if (path.endsWith("/context")) return route.fulfill({ json: {
protocol_version: "tjwater-peripheral-sample@1", origin: "synthetic", project_id: projectId,
protocol_version: "tjwater-peripheral-sample@1", origin: "system_data", project_id: projectId,
user_id: "50000000-0000-4000-8000-000000000001", can_write: true,
scenarios: ["normal"], managed_resources: {
work_orders: "not_configured", test_data: "not_configured"
@@ -366,7 +380,7 @@ test.describe("外围系统", () => {
await page.goto("/");
await page.getByRole("button", { name: "外围系统" }).click();
const panel = page.getByRole("complementary", { name: "外围系统" });
await expect(panel.getByText("测试工单存储待供给", { exact: true })).toBeVisible();
await expect(panel.getByText("工单服务未就绪", { exact: true })).toBeVisible();
await expect(panel.getByText("测试数据存储待供给", { exact: true })).toHaveCount(0);
await expect(panel.getByText("历史监测回放", { exact: true })).toHaveCount(0);
expect(managedRequests).toEqual([]);
@@ -387,7 +401,7 @@ test.describe("外围系统", () => {
const url = new URL(request.url());
const path = url.pathname;
const now = "2026-09-18T00:00:00Z";
if (path.endsWith("/context")) return route.fulfill({ json: { protocol_version: "tjwater-peripheral-sample@1", origin: "synthetic", project_id: projectId, user_id: "50000000-0000-4000-8000-000000000001", can_write: true, scenarios: ["normal"], managed_resources: { work_orders: "available", test_data: "available" }, state_transitions: { pending: ["processing"], processing: ["pending_review"], pending_review: ["processing", "completed"], completed: [] } } });
if (path.endsWith("/context")) return route.fulfill({ json: { protocol_version: "tjwater-peripheral-sample@1", origin: "system_data", project_id: projectId, user_id: "50000000-0000-4000-8000-000000000001", can_write: true, scenarios: ["normal"], managed_resources: { work_orders: "available", test_data: "available" }, state_transitions: { pending: ["processing"], processing: ["pending_review"], pending_review: ["processing", "completed"], completed: [] } } });
if (path.endsWith("/work-orders") && request.method() === "GET") return route.fulfill({ json: { items: workOrders, next_cursor: null } });
if (path.endsWith("/work-orders") && request.method() === "POST") {
const body = request.postDataJSON();
@@ -397,7 +411,7 @@ test.describe("外围系统", () => {
: body.work_order_type === "burst_report"
? "爆管上报工单已创建。"
: "预约工单已创建。";
const item = { ...body, work_order_id: "50000000-0000-4000-8000-000000000001", project_id: projectId, revision: 1, origin: "synthetic", created_at: now, updated_at: now, created_by: "tester", updated_by: "tester", activity: [{ sequence: 1, occurred_at: now, actor: "tester", kind: "created", title: "工单创建", detail: createdDetail }] };
const item = { ...body, work_order_id: "50000000-0000-4000-8000-000000000001", project_id: projectId, revision: 1, origin: body.origin, created_at: now, updated_at: now, created_by: "tester", updated_by: "tester", activity: [{ sequence: 1, occurred_at: now, actor: "tester", kind: "created", title: "工单创建", detail: createdDetail }] };
delete item.request_id; workOrders = [item]; return route.fulfill({ status: 201, json: item });
}
if (path.includes("/work-orders/") && request.method() === "PATCH") {
@@ -419,7 +433,9 @@ test.describe("外围系统", () => {
await page.goto("/");
await page.getByRole("button", { name: "外围系统" }).click();
const panel = page.getByRole("complementary", { name: "外围系统" });
await expect(panel.getByText("工单接口测试", { exact: true })).toBeVisible();
await expect(panel.getByText("工单管理", { exact: true })).toBeVisible();
await expect(panel.getByText("外围工单系统", { exact: true })).toBeVisible();
await expect(panel.getByText("合成工单环境", { exact: true })).toHaveCount(0);
await expect(panel.getByLabel("预约工单列表")).toContainText("暂无预约工单");
await expect(panel.getByLabel("投诉工单列表")).toContainText("暂无投诉工单");
await expect(panel.getByRole("textbox", { name: "标题" })).toBeHidden();
@@ -430,6 +446,22 @@ test.describe("外围系统", () => {
await editor.getByRole("textbox", { name: "负责人", exact: true }).fill("测试班组甲");
await editor.getByRole("textbox", { name: "位置", exact: true }).fill("联调测试点西侧");
await editor.getByRole("textbox", { name: "场景编号" }).fill("APPOINTMENT-TEST-001");
// Portaled calendar and select menus keep the solid panel theme and keyboard focus.
await editor.getByRole("button", { name: "预约时间", exact: true }).click();
const calendar = page.getByRole("dialog", { name: "选择预约时间", exact: true });
await expect(calendar).toBeVisible();
await expectThemeColor(calendar.getByRole("textbox", { name: "年份", exact: true }), "color", "--ink");
await expect(calendar).toHaveCSS("background-color", "rgb(255, 255, 255)");
await calendar.getByRole("combobox", { name: "月份", exact: true }).click();
await expect(page.getByRole("listbox")).toHaveCSS("background-color", "rgb(255, 255, 255)");
await page.keyboard.press("Escape");
await expect(calendar.getByRole("combobox", { name: "月份", exact: true })).toBeFocused();
await testInfo.attach("peripheral-calendar", {
body: await page.screenshot({ path: testInfo.outputPath("peripheral-calendar.png") }),
contentType: "image/png"
});
await page.keyboard.press("Escape");
await expect(editor.getByRole("button", { name: "预约时间", exact: true })).toBeFocused();
await editor.getByRole("textbox", { name: "回执要求(每行一项)" }).fill("回传现场处理结果\n上传照片");
await editor.getByRole("button", { name: "创建预约工单" }).click();
await expect(panel.getByText("合成预约维修", { exact: true })).toBeVisible();
@@ -444,6 +476,20 @@ test.describe("外围系统", () => {
await panel.getByRole("button", { name: "编辑工单 合成预约维修" }).click();
editor = panel.getByRole("region", { name: "编辑工单 合成预约维修" });
await expect(panel.getByLabel("预约工单列表").getByRole("region", { name: "编辑工单 合成预约维修" })).toBeVisible();
await testInfo.attach("work-order-editor-text", {
body: await editor.screenshot({ path: testInfo.outputPath("work-order-editor.png") }), contentType: "image/png"
});
console.log("work-order-title-contrast", await readRenderedContrast(editor.getByRole("textbox", { name: "标题" })));
for (const field of await editor.getByRole("textbox").all()) {
await expectThemeColor(field, "color", "--ink");
await expect(field).toHaveCSS("opacity", "1");
}
await page.setViewportSize({ width: 375, height: 900 });
await testInfo.attach("work-order-editor-text-narrow", {
body: await editor.screenshot({ path: testInfo.outputPath("work-order-editor-375.png") }), contentType: "image/png"
});
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
await page.setViewportSize({ width: 1440, height: 900 });
await expect(editor.locator("xpath=ancestor::article")).toContainText("合成预约维修");
await expect(editor.getByRole("textbox", { name: "标题" })).toBeFocused();
await expect(panel.getByRole("button", { name: "编辑工单 合成预约维修" })).toHaveCount(0);
@@ -469,7 +515,7 @@ test.describe("外围系统", () => {
expect(workOrders[0]).toMatchObject({ work_order_type: "general", scenario_code: "APPOINTMENT-TEST-001" });
workOrders = [{ ...workOrders[0], title: "外部系统更新后的预约维修", revision: 3 }];
await panel.getByRole("button", { name: "刷新外围工单" }).click();
await panel.getByRole("button", { name: "刷新工单" }).click();
await expect(panel.getByText("外部系统更新后的预约维修", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "编辑工单 外部系统更新后的预约维修" }).click();
const refreshedEditor = panel.getByRole("region", { name: "编辑工单 外部系统更新后的预约维修" });
@@ -483,7 +529,7 @@ test.describe("外围系统", () => {
await refreshedEditor.locator("xpath=ancestor::article").evaluate((element) => element.scrollIntoView({ block: "start" }));
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await testInfo.attach(`peripheral-work-order-process-${width}`, {
body: await page.screenshot({ path: `/tmp/tjwater-operational-ui-review/peripheral-work-order-edit-${width}.png` }),
body: await page.screenshot({ path: testInfo.outputPath(`peripheral-work-order-edit-${width}.png`) }),
contentType: "image/png"
});
}
@@ -527,13 +573,16 @@ test.describe("外围系统", () => {
await page.getByRole("option", { name: "爆管上报", exact: true }).click();
await panel.getByRole("textbox", { name: "事件位置" }).fill("演示路与样板路交叉口");
await panel.getByRole("textbox", { name: "现场描述" }).fill("路面积水并伴随压力突降");
await expectThemeColor(panel.getByRole("textbox", { name: "事件位置" }), "color", "--ink");
await expectThemeColor(panel.getByRole("textbox", { name: "现场描述" }), "color", "--ink");
await panel.getByRole("button", { name: "创建事件工单" }).click();
await expect(panel.getByText("爆管上报工单", { exact: true }).last()).toBeVisible();
await expect(panel.getByLabel("爆管上报工单列表")).toContainText("爆管上报:演示路与样板路交叉口");
await expect(panel.getByLabel("爆管上报工单列表")).toContainText("爆管上报工单已创建。");
expect(workOrders[0]).toMatchObject({
work_order_type: "burst_report",
scenario_code: "BURST-REPORT-001",
scenario_code: null,
origin: "system_data",
title: "爆管上报:演示路与样板路交叉口",
status: "open",
priority: "urgent"
@@ -541,6 +590,59 @@ test.describe("外围系统", () => {
});
for (const [kind, label] of [["water_quality_complaint", "水质投诉"], ["burst_report", "爆管上报"]]) {
test(`${label}业务录入使用真实来源且不附加演练场景`, async ({ page }) => {
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const orders: Record<string, unknown>[] = [];
await page.route("**/runtime-config.js", (route) => route.fulfill({
contentType: "text/javascript",
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
TJWATER_AUTH_MODE: "disabled", TJWATER_SERVER_API_BASE_URL: "http://server.test",
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "http://peripheral.test"
})}`
}));
await page.route("http://peripheral.test/api/sample/v1/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/context")) return route.fulfill({ json: {
protocol_version: "tjwater-peripheral-sample@1", origin: "system_data", project_id: projectId,
user_id: "50000000-0000-4000-8000-000000000001", can_write: true, scenarios: ["normal"],
managed_resources: { work_orders: "available", test_data: "available" }, state_transitions: {}
} });
if (path.endsWith("/work-orders") && request.method() === "POST") {
const body = request.postDataJSON() as Record<string, unknown>;
expect(body).toMatchObject({ work_order_type: kind, origin: "system_data", scenario_code: null });
const order = { ...body, work_order_id: "50000000-0000-4000-8000-000000000001",
project_id: projectId, revision: 1, created_at: "2026-10-29T01:00:00Z", updated_at: "2026-10-29T01:00:00Z",
created_by: "operator", updated_by: "operator", activity: [] };
delete order.request_id;
orders.push(order);
return route.fulfill({ status: 201, json: order });
}
if (path.endsWith("/work-orders")) return route.fulfill({ json: { items: orders, next_cursor: null } });
return route.fulfill({ status: 404, json: {} });
});
// Keep this check bounded to peripheral intake; investigation presentation has separate coverage.
await page.route("http://server.test/api/v1/operational-tasks", (route) =>
route.fulfill({ status: 503, json: { detail: "investigation unavailable in intake test" } }));
await page.goto("/");
await page.getByRole("button", { name: "外围系统", exact: true }).click();
const panel = page.getByRole("complementary", { name: "外围系统" });
await panel.getByRole("button", { name: "投诉与爆管上报" }).click();
if (kind === "burst_report") {
await panel.getByRole("combobox", { name: "事件类型" }).click();
await page.getByRole("option", { name: label, exact: true }).click();
}
await panel.getByRole("textbox", { name: "事件位置" }).fill("供水小区");
await panel.getByRole("textbox", { name: "现场描述" }).fill("用户上报异常,待实际数据核查");
await panel.getByRole("button", { name: "创建事件工单" }).click();
await expect(panel.getByRole("heading", { name: `${label}:供水小区`, exact: true })).toBeVisible();
await expect(panel.getByText("工单已创建,调查任务待触发", { exact: true })).toBeVisible();
await expect(panel.getByText("合成工单环境", { exact: true })).toHaveCount(0);
expect(orders).toHaveLength(1);
});
}
test("创建水质投诉工单后进入结构化工况任务并由用户打开 Agent", async ({ page }, testInfo) => {
test.setTimeout(90_000);
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
@@ -601,7 +703,7 @@ test.describe("外围系统", () => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/context")) return route.fulfill({ json: {
protocol_version: "tjwater-peripheral-sample@1", origin: "synthetic",
protocol_version: "tjwater-peripheral-sample@1", origin: "system_data",
project_id: projectId, user_id: "50000000-0000-4000-8000-000000000001",
can_write: true, scenarios: ["normal"],
managed_resources: { work_orders: "available", test_data: "available" },
@@ -618,7 +720,7 @@ test.describe("外围系统", () => {
work_order_id: workOrderId,
project_id: projectId,
revision: 1,
origin: "synthetic",
origin: body.origin,
created_at: "2026-09-22T02:00:00Z",
updated_at: "2026-09-22T02:00:00Z",
created_by: "tester",
@@ -782,7 +884,8 @@ test.describe("外围系统", () => {
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
expect(workOrders[0]).toMatchObject({
work_order_type: "water_quality_complaint",
scenario_code: "WQ-SECONDARY-SUPPLY-001",
scenario_code: null,
origin: "system_data",
status: "open"
});
expect(agentRequests.filter((body) => "trigger_context" in body)).toHaveLength(0);
@@ -36,7 +36,7 @@ async function expectPushedOrder(page: Page, failFirstDetail = false) {
work_order_id: orderId, revision: 1, title, summary: "现场阀门复核", status: "open",
priority: "normal", assignee: "现场班组", scheduled_at: now, work_order_type: "general",
location: "测试阀门", scenario_code: null, feedback_requirements: [], activity: [],
origin: "synthetic", updated_at: now
origin: "system_data", updated_at: now
}
};
let snapshotRequests = 0;
+1 -1
View File
@@ -231,7 +231,7 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
await route.fulfill({
headers: corsHeaders,
json: {
schema_version: "frontend-action-registry@1",
schema_version: "frontend-action-registry@2",
actions: []
}
});
@@ -1,5 +1,5 @@
import type { Page } from "@playwright/test";
import type { OperationalTask } from "../../../src/features/workbench/api/operational-task-client";
import type { OperationalTask, OperationalTaskNotification } from "../../../src/features/workbench/api/operational-task-client";
import { mockAgentApi } from "./mock-agent-api";
import { mockWorkbenchContext } from "./mock-workbench-context";
@@ -35,7 +35,8 @@ export function investigationFixture(index: number, title: string): OperationalT
};
}
export async function mockInvestigationPresentation(page: Page, tasks: OperationalTask[]) {
export type SavedPresentationEvent = { task_id: string; event_seq: number; run_id: string; execution_generation: number; occurred_at: string; command: unknown };
export async function mockInvestigationPresentation(page: Page, tasks: OperationalTask[], savedEvents: SavedPresentationEvent[] = [], notifications: OperationalTaskNotification[] = []) {
await mockWorkbenchContext(page);
await mockAgentApi(page);
const mutations: string[] = [];
@@ -62,17 +63,28 @@ export async function mockInvestigationPresentation(page: Page, tasks: Operation
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: tasks.map((task) => Object.fromEntries(Object.entries(task).filter(([key]) =>
!["latest_plan", "latest_report", "latest_progress", "evidence", "actions", "access_requests", "work_order_snapshot", "permissions"].includes(key)))),
notifications: [] } });
!["latest_plan", "latest_report", "latest_progress", "evidence", "actions", "access_requests", "work_order_snapshot", "permissions", "questions"].includes(key)))),
notifications } });
});
await page.route("https://tjwater-api.invalid/api/v1/operational-tasks/**", (route) => {
const task = tasks.find((item) => route.request().url().includes(item.task_id));
if (!task) return route.fulfill({ status: 404 });
if (route.request().url().endsWith("/presentation-cursor")) return route.fulfill({ json: {
task_id: task.task_id, execution_generation: task.execution_generation, active_run: {
run_id: task.active_run!.run_id, generation: task.execution_generation, status: "running"
}, event_cursor: 10
task_id: task.task_id, execution_generation: task.execution_generation, active_run: task.active_run ? {
run_id: task.active_run.run_id, generation: task.execution_generation, status: "running"
} : null, event_cursor: sequence
} });
if (route.request().url().includes("/presentation-events")) {
const url = new URL(route.request().url());
const through = Number(url.searchParams.get("through") ?? sequence);
const after = Number(url.searchParams.get("after") ?? 0);
const limit = Number(url.searchParams.get("limit") ?? 100);
const events = savedEvents.filter((item) => item.task_id === task.task_id && item.event_seq > after && item.event_seq <= through).sort((a, b) => a.event_seq - b.event_seq);
return route.fulfill({ json: { task_id: task.task_id, event_cursor: through,
items: events.slice(0, limit).map(({ task_id: _, ...item }) => item),
next_after: events.length > limit ? events[limit - 1].event_seq : null
} });
}
if (route.request().url().includes("/reports")) return route.fulfill({ json: { items: [], next_offset: null } });
return route.fulfill({ json: task });
});
@@ -92,17 +104,20 @@ export async function mockInvestigationPresentation(page: Page, tasks: Operation
return new Response(body, { headers: { "Content-Type": "text/event-stream" } });
};
});
let sequence = 10;
let sequence = Math.max(10, ...savedEvents.map((item) => item.event_seq));
return {
mutations,
emit: async (task: OperationalTask, command: unknown) => page.evaluate((event) => {
emit: async (task: OperationalTask, command: unknown) => {
savedEvents.push({ task_id: task.task_id, run_id: task.active_run!.run_id, event_seq: sequence + 1, execution_generation: task.execution_generation, occurred_at: now, command });
return page.evaluate((event) => {
(window as unknown as { emitInvestigationTestEvent: (event: unknown) => void }).emitInvestigationTestEvent(event);
}, { schema: "workbench-event@1", event_seq: ++sequence,
event_id: `95000000-0000-4000-8000-${String(sequence).padStart(12, "0")}`, project_id: projectId,
topic: "operational.presentation.requested", aggregate_type: "operational_task", aggregate_id: task.task_id,
aggregate_revision: 1, occurred_at: now, metadata: {
task_id: task.task_id, run_id: task.active_run!.run_id, execution_generation: task.execution_generation, command
} })
} });
}
};
}
+8
View File
@@ -23,7 +23,11 @@ test("nested Resizable groups keep chat, map and analysis independent across bre
await page.evaluate(() => Object.assign(window, { originalResizableMap: window.__waterNetworkMap }));
const outerBox = (await outer.boundingBox())!;
await page.mouse.move(0, 0);
await expect(outer.locator(".resizable-grip")).toHaveCSS("opacity", "0");
await expect(inner.locator(".resizable-grip")).toHaveCSS("opacity", "0");
await page.mouse.move(outerBox.x + outerBox.width / 2, outerBox.y + outerBox.height / 2);
await expect(outer.locator(".resizable-grip")).toHaveCSS("opacity", "1");
await page.mouse.down();
await page.mouse.move(620, outerBox.y + outerBox.height / 2, { steps: 6 });
await page.mouse.up();
@@ -31,13 +35,17 @@ test("nested Resizable groups keep chat, map and analysis independent across bre
const mapWidth = (await map.boundingBox())!.width;
const innerBox = (await inner.boundingBox())!;
await page.mouse.move(innerBox.x + innerBox.width / 2, innerBox.y + innerBox.height / 2);
await expect(outer.locator(".resizable-grip")).toHaveCSS("opacity", "0");
await expect(inner.locator(".resizable-grip")).toHaveCSS("opacity", "1");
await page.mouse.down();
await page.mouse.move(innerBox.x + 40, innerBox.y + innerBox.height / 2, { steps: 6 });
await page.mouse.up();
await expect.poll(async () => (await map.boundingBox())!.width).toBeGreaterThan(mapWidth + 30);
await expect.poll(async () => Math.round((await agent.boundingBox())!.width)).toBe(620);
await inner.focus();
await page.mouse.move(0, 0);
await page.keyboard.press("Home");
await expect(inner.locator(".resizable-grip")).toHaveCSS("opacity", "1");
await expect.poll(async () => Math.round((await map.boundingBox())!.width)).toBe(600);
await page.keyboard.press("End");
await expect.poll(async () => Math.round((await page.locator("#artifact-workspace").boundingBox())!.width)).toBe(600);