feat: expand intelligent investigation workspace
This commit is contained in:
@@ -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`) });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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` });
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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("已完成");
|
||||
});
|
||||
}
|
||||
@@ -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)");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
} })
|
||||
} });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user