feat(agent): support Responses steering and project-aware tools
Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running turns active while users steer or queue follow-ups and edit pending delivery. Refresh the model catalog before generation and preserve approval mode selection. Integrate scoped investigation messaging/presentation, geographic place selection, current Agent composer styles and supporting workbench contracts. Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and production build. Preserve direct Server/Agent routing and project authentication.
@@ -42,7 +42,6 @@ test("desktop Agent docks beside the workspace with a solid reading surface", as
|
||||
const workspace = page.locator("#main-workspace");
|
||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||
const agentHeader = agentPanel.locator(".agent-panel-header");
|
||||
const agentConversation = agentPanel.getByRole("log");
|
||||
const agentConversationScroll = agentPanel.locator(".agent-conversation-scroll");
|
||||
const agentControls = agentPanel.locator(".agent-panel-band").last();
|
||||
|
||||
@@ -63,28 +62,24 @@ test("desktop Agent docks beside the workspace with a solid reading surface", as
|
||||
await expect(agentPanel).toHaveCSS("border-right-width", "1px");
|
||||
await expectThemeColor(agentPanel, "border-right-color", "--surface-divider");
|
||||
await expect(agentPanel).toHaveCSS("box-shadow", "none");
|
||||
await expect(agentControls).toHaveCSS("border-top-width", "1px");
|
||||
await expectThemeColor(agentControls, "background-color", "--agent-dock-band");
|
||||
await expectThemeColor(agentPanel.locator("form.agent-panel-control"), "background-color", "--surface-reading");
|
||||
const capabilities = agentPanel.getByRole("list", { name: "Agent 分析能力" });
|
||||
await expect(capabilities.getByRole("listitem")).toHaveCount(4);
|
||||
await expect(capabilities).toHaveCSS("box-shadow", "none");
|
||||
for (const item of await capabilities.getByRole("listitem").all()) {
|
||||
await expect(item).toHaveCSS("border-right-width", "0px");
|
||||
await expect(item).toHaveCSS("border-bottom-width", "0px");
|
||||
}
|
||||
await expect(
|
||||
agentPanel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
|
||||
).toHaveCSS("color", "rgb(51, 65, 85)");
|
||||
await expect(agentPanel.getByText("水力瓶颈诊断")).toHaveCSS("color", "rgb(30, 41, 59)");
|
||||
await expect(agentPanel.getByText("水力瓶颈诊断")).toHaveCSS("font-size", "14px");
|
||||
await expect(agentPanel).toHaveAttribute("data-agent-starting", "true");
|
||||
await expect(agentControls).toHaveCSS("border-top-width", "0px");
|
||||
await expect(agentControls).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expectAcrylicSurface(agentPanel.locator("form.agent-panel-control"));
|
||||
const inputBody = agentPanel.locator(".agent-panel-input-body");
|
||||
const inputTools = agentPanel.locator(".agent-panel-input-tools");
|
||||
await expectThemeColor(inputBody, "background-color", "--agent-dock-reading");
|
||||
await expectThemeColor(inputBody, "border-color", "--surface-border");
|
||||
await expect(inputBody).toHaveCSS("display", "block");
|
||||
await expect(inputTools).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
const startComposer = agentPanel.locator(".agent-panel-start-composer");
|
||||
const suggestions = agentPanel.getByRole("group", { name: "推荐问题" });
|
||||
await expect(suggestions.getByRole("button")).toHaveCount(3);
|
||||
await expect(agentPanel.getByRole("heading", { name: "我能帮你处理什么?" })).toBeVisible();
|
||||
await expect(agentPanel.locator(".agent-panel-main-content")).toBeHidden();
|
||||
|
||||
const panelBox = await agentPanel.boundingBox();
|
||||
const conversationBox = await agentConversation.boundingBox();
|
||||
const scrollBox = await agentConversationScroll.boundingBox();
|
||||
expect(panelBox).not.toBeNull();
|
||||
expect(conversationBox).not.toBeNull();
|
||||
expect(scrollBox).not.toBeNull();
|
||||
expect(panelBox!.x).toBe(0);
|
||||
const topBarBox = await topBar.boundingBox();
|
||||
expect(panelBox!.y).toBe(topBarBox!.y + topBarBox!.height);
|
||||
@@ -97,17 +92,27 @@ test("desktop Agent docks beside the workspace with a solid reading surface", as
|
||||
expect(headerBox!.y).toBe(topBarBox!.y + topBarBox!.height);
|
||||
expect(workspaceBox!.y).toBe(topBarBox!.y);
|
||||
await expect(topBar).toHaveCSS("box-shadow", "none");
|
||||
await expect(agentHeader).toHaveCSS("box-shadow", "none");
|
||||
await expectAcrylicSurface(agentHeader);
|
||||
await expect(agentHeader.locator(":scope > div").first()).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
expect(await topBar.evaluate((element) => getComputedStyle(element).borderBottomColor)).toBe(
|
||||
await agentPanel.evaluate((element) => getComputedStyle(element).borderRightColor)
|
||||
);
|
||||
expect(Math.abs(conversationBox!.x - panelBox!.x)).toBeLessThanOrEqual(1);
|
||||
await expect(agentConversationScroll).toHaveCSS("scrollbar-gutter", "stable both-edges");
|
||||
|
||||
const beforeFocus = await getSurfacePresentation(agentPanel);
|
||||
await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").focus();
|
||||
await page.getByPlaceholder("询问供水问题,或描述需要处理的任务").focus();
|
||||
await expectThemeColor(inputBody, "border-color", "--field-focus-border");
|
||||
await expect(inputBody).toHaveCSS("box-shadow", "none");
|
||||
await expect(agentPanel.locator("form.agent-panel-control > [data-slot=input-group]")).toHaveCSS("border-width", "0px");
|
||||
await expect.poll(() => getSurfacePresentation(agentPanel)).toEqual(beforeFocus);
|
||||
await agentPanel.getByRole("button", { name: "选择对话归属" }).click();
|
||||
const projectMenu = page.getByRole("menu");
|
||||
await expect(projectMenu.getByRole("menuitem", { name: "个人会话" })).toBeVisible();
|
||||
await expect(projectMenu.getByRole("menuitem", { name: "当前项目" })).toBeVisible();
|
||||
await expect(projectMenu.getByRole("menuitem", { name: "新建项目" })).toBeVisible();
|
||||
await expect(projectMenu.getByRole("menuitem", { name: "切换项目" })).toHaveCount(0);
|
||||
await page.screenshot({ path: "test-results/agent-history-ui/project-menu.png" });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.screenshot({ path: "test-results/agent-history-ui/desktop-dock.png" });
|
||||
|
||||
for (const viewport of [
|
||||
@@ -125,6 +130,27 @@ test("desktop Agent docks beside the workspace with a solid reading surface", as
|
||||
path: `test-results/agent-history-ui/desktop-dock-${viewport.width}.png`
|
||||
});
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 640 });
|
||||
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
||||
for (const width of [400, 640]) {
|
||||
const box = (await handle.boundingBox())!;
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(width, box.y + box.height / 2);
|
||||
await page.mouse.up();
|
||||
await page.mouse.move(1000, 450);
|
||||
await expect(agentPanel).toHaveCSS("width", `${width}px`);
|
||||
expect(await startComposer.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||
expect(await inputTools.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||
const textarea = agentPanel.locator("textarea");
|
||||
await textarea.fill("核对近期供水压力变化,说明监测证据和需要现场核验的事项。".repeat(20));
|
||||
const composerBox = (await startComposer.boundingBox())!;
|
||||
expect(composerBox.y).toBeGreaterThanOrEqual(topBarBox!.height * 2);
|
||||
expect(composerBox.y + composerBox.height).toBeLessThanOrEqual(640);
|
||||
await textarea.fill("");
|
||||
await agentPanel.screenshot({ path: `test-results/agent-history-ui/desktop-material-${width}.png` });
|
||||
}
|
||||
});
|
||||
|
||||
test("desktop Agent portal menus keep local material and respect reduced transparency", async ({ page }) => {
|
||||
@@ -137,11 +163,12 @@ test("desktop Agent portal menus keep local material and respect reduced transpa
|
||||
const trigger = panel.getByRole("button", { name });
|
||||
await trigger.click();
|
||||
const menu = page.getByRole("menu");
|
||||
await expect(menu).toBeVisible();
|
||||
await waitForAnimations(menu);
|
||||
await expect(menu).toHaveAttribute("data-agent-presentation", "desktop-dock");
|
||||
expect(await menu.evaluate((element) => element.closest("aside"))).toBeNull();
|
||||
await expectThemeColor(menu, "background-color", "--agent-dock-overlay-surface");
|
||||
await expect(menu).toHaveCSS("backdrop-filter", "blur(12px) saturate(1.03)");
|
||||
await expect(menu).toHaveCSS("background-image", "none");
|
||||
await expectAcrylicSurface(menu);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(trigger).toBeFocused();
|
||||
}
|
||||
@@ -158,7 +185,8 @@ test("desktop Agent portal menus keep local material and respect reduced transpa
|
||||
await page.keyboard.press("Escape");
|
||||
await panel.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
const history = panel.getByRole("region", { name: "Agent 历史记录" });
|
||||
await expectThemeColor(history, "background-color", "--palette-paper");
|
||||
await expectThemeColor(panel.locator(".agent-panel-header"), "background-color", "--palette-paper");
|
||||
await expect(panel.locator(".agent-panel-header")).toHaveCSS("backdrop-filter", "none");
|
||||
await expect(history).toHaveCSS("backdrop-filter", "none");
|
||||
});
|
||||
|
||||
@@ -178,8 +206,10 @@ test("desktop Agent history remains usable inside the docked navigation pane", a
|
||||
await waitForAnimations(history);
|
||||
await expect(historyButton).toHaveAttribute("aria-expanded", "true");
|
||||
await expectUnifiedHistoryStructure(history);
|
||||
await expectThemeColor(history, "background-color", "--agent-dock-overlay-surface");
|
||||
await expect(history).toHaveCSS("backdrop-filter", "blur(12px) saturate(1.03)");
|
||||
await expectThemeColor(agentPanel.locator(".agent-panel-header"), "background-color", "--agent-dock-overlay-surface");
|
||||
await expectAcrylicSurface(agentPanel.locator(".agent-panel-header"));
|
||||
await expect(history).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expect(history).toHaveCSS("backdrop-filter", "none");
|
||||
|
||||
await history.getByRole("button", { name: "重命名对话" }).first().click();
|
||||
const renameInput = history.locator("input").first();
|
||||
@@ -339,12 +369,12 @@ test("long history titles keep rename and explicit delete usable across viewport
|
||||
}
|
||||
})
|
||||
);
|
||||
await page.route("**/api/v1/agent/chat/session/**", async (route) => {
|
||||
await page.route("**/api/v1/session/**", async (route) => {
|
||||
const request = route.request();
|
||||
const method = request.method();
|
||||
if (method === "OPTIONS") return route.fallback();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (method === "PATCH" && path === `/api/v1/agent/chat/session/${sessionId}/title`) {
|
||||
if (method === "PATCH" && path === `/api/v1/session/${sessionId}/title`) {
|
||||
const body = request.postDataJSON();
|
||||
title = body.title;
|
||||
writes.push({ method, path, body });
|
||||
@@ -353,7 +383,7 @@ test("long history titles keep rename and explicit delete usable across viewport
|
||||
json: {}
|
||||
});
|
||||
}
|
||||
if (method === "DELETE" && path === `/api/v1/agent/chat/session/${sessionId}`) {
|
||||
if (method === "DELETE" && path === `/api/v1/session/${sessionId}`) {
|
||||
deleted = true;
|
||||
writes.push({ method, path, body: null });
|
||||
return route.fulfill({
|
||||
@@ -444,7 +474,7 @@ test("long history titles keep rename and explicit delete usable across viewport
|
||||
expect(writes).toEqual([
|
||||
{
|
||||
method: "PATCH",
|
||||
path: `/api/v1/agent/chat/session/${sessionId}/title`,
|
||||
path: `/api/v1/session/${sessionId}/title`,
|
||||
body: { title: "城南压力复核(已复核)", is_title_manually_edited: true }
|
||||
}
|
||||
]);
|
||||
@@ -459,7 +489,7 @@ test("long history titles keep rename and explicit delete usable across viewport
|
||||
expect(writes).toHaveLength(2);
|
||||
expect(writes[1]).toEqual({
|
||||
method: "DELETE",
|
||||
path: `/api/v1/agent/chat/session/${sessionId}`,
|
||||
path: `/api/v1/session/${sessionId}`,
|
||||
body: null
|
||||
});
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -15,7 +15,7 @@ test("real model publishes local components, restores them and receives form inp
|
||||
test.setTimeout(300_000);
|
||||
const target = new URL(realAgentUrl!);
|
||||
expect(["127.0.0.1", "localhost"]).toContain(target.hostname);
|
||||
const base = `${target.origin}/api/v1/agent/chat`;
|
||||
const base = `${target.origin}/api/v1`;
|
||||
const health = await request.get(`${target.origin}/health`);
|
||||
expect(health.ok()).toBe(true);
|
||||
expect(await health.json()).toMatchObject({ ready: true, warmed_up: true });
|
||||
@@ -26,7 +26,7 @@ test("real model publishes local components, restores them and receives form inp
|
||||
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
||||
TJWATER_AGENT_API_BASE_URL: target.origin,
|
||||
TJWATER_AGENT_URL: target.origin,
|
||||
TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
||||
TJWATER_ENABLE_MSW: "false"
|
||||
@@ -66,7 +66,7 @@ test("real model publishes local components, restores them and receives form inp
|
||||
await page.getByRole("menuitem", { name: new RegExp(requestedModelLabel) }).click();
|
||||
}
|
||||
const firstResponse = page.waitForResponse(
|
||||
(response) => response.url() === `${base}/stream` && response.request().method() === "POST"
|
||||
(response) => response.url() === `${base}/responses` && response.request().method() === "POST"
|
||||
);
|
||||
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
const stream = await firstResponse;
|
||||
@@ -103,18 +103,18 @@ test("real model publishes local components, restores them and receives form inp
|
||||
.getByLabel("现场位置(必填)")
|
||||
.fill("演练:东侧阀门旁,未实地核验。本轮仍为功能验收,请不要写业务证据或记忆,只确认收到。");
|
||||
const secondResponse = page.waitForResponse(
|
||||
(response) => response.url() === `${base}/stream` && response.request().method() === "POST"
|
||||
(response) => response.url() === `${base}/responses` && response.request().method() === "POST"
|
||||
);
|
||||
await panel.getByRole("button", { name: "发送补充资料" }).click();
|
||||
const submitted = await secondResponse;
|
||||
const submittedBody = submitted.request().postDataJSON() as {
|
||||
messages: Array<{ role: string; parts: Array<{ type: string; text?: string }> }>;
|
||||
input: Array<{ role: string; content: Array<{ type: string; text?: string }> }>;
|
||||
};
|
||||
expect(
|
||||
submittedBody.messages
|
||||
submittedBody.input
|
||||
.filter((message) => message.role === "user")
|
||||
.at(-1)
|
||||
?.parts.some((part) => part.text?.includes("现场位置:演练:东侧阀门旁"))
|
||||
?.content.some((part) => part.text?.includes("现场位置:演练:东侧阀门旁"))
|
||||
).toBe(true);
|
||||
expect(submitted.ok()).toBe(true);
|
||||
await expect(panel.getByRole("button", { name: "发送 Agent 指令" })).toBeVisible({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { responsesFixture } from "./support/responses-fixture";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
@@ -99,7 +100,7 @@ for (const width of [1440, 375, 320]) {
|
||||
data: { content: "仍需用户补充现场核验资料。", ui_block_ids: ["pressure"] }
|
||||
}
|
||||
];
|
||||
await page.route("**/api/v1/agent/chat/session/browser-session-1", async (route) => {
|
||||
await page.route("**/api/v1/session/browser-session-1", async (route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback();
|
||||
await route.fulfill({
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
@@ -121,16 +122,16 @@ for (const width of [1440, 375, 320]) {
|
||||
});
|
||||
});
|
||||
const prompts: string[] = [];
|
||||
await page.route("**/api/v1/agent/chat/stream", async (route) => {
|
||||
await page.route("**/api/v1/responses", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
const body = route.request().postDataJSON() as {
|
||||
messages: Array<{ role: string; parts: Array<{ type: string; text?: string }> }>;
|
||||
input: Array<{ role: string; content: Array<{ type: string; text?: string }> }>;
|
||||
};
|
||||
const text =
|
||||
body.messages
|
||||
body.input
|
||||
.filter((m) => m.role === "user")
|
||||
.at(-1)
|
||||
?.parts.filter((p) => p.type === "text")
|
||||
?.content.filter((p) => p.type === "input_text")
|
||||
.map((p) => p.text)
|
||||
.join("") ?? "";
|
||||
prompts.push(text);
|
||||
@@ -160,9 +161,9 @@ for (const width of [1440, 375, 320]) {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Content-Type": "text/event-stream",
|
||||
"X-Vercel-AI-UI-Message-Stream": "v1"
|
||||
"Cache-Control": "no-cache"
|
||||
},
|
||||
body: chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")
|
||||
body: responsesFixture(chunks)
|
||||
});
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { responsesFixture } from "./support/responses-fixture";
|
||||
import { expect, test } from "@playwright/test";
|
||||
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 }) => {
|
||||
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 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-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 } });
|
||||
});
|
||||
await page.route("**/api/v1/responses", async (route) => {
|
||||
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 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") },
|
||||
{ type: "data-final_answer", data: { session_id: "migrated-session", content: "最终答复:压力曲线已核实。" } },
|
||||
{ type: "finish", finishReason: "stop" },
|
||||
];
|
||||
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream", "Cache-Control": "no-cache" }, body: responsesFixture(chunks) });
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const prompt = page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).filter({ visible: true }).first();
|
||||
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
|
||||
await prompt.fill("查询历史并展示压力曲线");
|
||||
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();
|
||||
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);
|
||||
});
|
||||
@@ -43,7 +43,7 @@ test("dragging the Agent edge keeps the workspace attached and preserves the dra
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
const workspace = page.locator("#main-workspace");
|
||||
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
||||
const prompt = panel.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应");
|
||||
const prompt = panel.getByPlaceholder("询问供水问题,或描述需要处理的任务");
|
||||
await expect(panel.getByText("当前上下文", { exact: true })).toHaveCount(0);
|
||||
await prompt.fill("保留尚未发送的诊断问题");
|
||||
await expect(panel).toHaveCSS("width", "500px");
|
||||
@@ -231,7 +231,7 @@ test("the floating condition feed coordinates with the docked Agent", async ({ p
|
||||
.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0))
|
||||
.toBeGreaterThan(400);
|
||||
const expandedOffset = Math.round((await workspace.boundingBox())?.x ?? 0);
|
||||
const prompt = page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应");
|
||||
const prompt = page.getByPlaceholder("询问供水问题,或描述需要处理的任务");
|
||||
await prompt.fill("保留尚未发送的诊断问题");
|
||||
const conditionToggle = page
|
||||
.locator("header.acrylic-navigation")
|
||||
@@ -286,10 +286,33 @@ test.describe("mobile touch layout", () => {
|
||||
test("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
|
||||
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await mockWorkbenchContext(page, {
|
||||
availability: "ready",
|
||||
source: "none",
|
||||
scenario_availability: "not_configured",
|
||||
artifact_availability: "not_configured",
|
||||
schedule_availability: "not_configured",
|
||||
work_order_availability: "not_configured",
|
||||
alert_availability: "ready",
|
||||
generated_at: "2026-07-21T02:40:00Z",
|
||||
conditions: [],
|
||||
alerts: [{
|
||||
id: "test-alert",
|
||||
condition_id: null,
|
||||
source: { task_type: "other_task", task_id: "test-task", run_id: "test-run" },
|
||||
title: "供水压力告警",
|
||||
description: "待核对压力异常。",
|
||||
occurred_at: "2026-07-21T02:30:00Z",
|
||||
severity: "warning"
|
||||
}],
|
||||
scenarios: [],
|
||||
latest_analysis: null,
|
||||
work_orders: []
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.getByRole("button", { name: /查看异常处置面板/ }).click();
|
||||
await expect(page.getByText("来源:工况演示回退", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "查看待办与告警,当前 1 条待处理事项" }).click();
|
||||
await expect(page.getByText("业务告警来源:后端运行态", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "工况汇总" }).click();
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { TEST_PROJECT_ID } from "./support/mock-scada-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
|
||||
for (const { scope, width } of [
|
||||
{ scope: "project", width: 1440 },
|
||||
{ scope: "personal", width: 1440 },
|
||||
{ scope: "personal", width: 375 }
|
||||
] as const) {
|
||||
test(`authenticated ${scope} chat sends the matching context at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
if (scope === "personal") {
|
||||
await page.route("**/api/v1/projects?*", (route) => route.fulfill({
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
json: { items: [], total: 0, limit: 1000, offset: 0 }
|
||||
}));
|
||||
}
|
||||
await page.route("**/runtime-config.js", (route) => route.fulfill({
|
||||
contentType: "application/javascript",
|
||||
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
|
||||
TJWATER_AUTH_MODE: "required",
|
||||
TJWATER_KEYCLOAK_ISSUER: "https://authentication.invalid/realms/test",
|
||||
TJWATER_KEYCLOAK_CLIENT_ID: "browser-test",
|
||||
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_MSW: "false"
|
||||
})};`
|
||||
}));
|
||||
// Exercise the real authentication provider and chat transport without
|
||||
// credentials, external authentication, or model inference.
|
||||
await page.route("**/keycloak-js.js*", (route) => route.fulfill({
|
||||
contentType: "application/javascript",
|
||||
body: `export default class Keycloak {
|
||||
token = "BROWSER_CONTEXT_TEST_ONLY";
|
||||
idTokenParsed = { name: "Browser context test" };
|
||||
async init() { return true; }
|
||||
async updateToken() { return false; }
|
||||
async login() {}
|
||||
async logout() {}
|
||||
}`
|
||||
}));
|
||||
const streamHeaders: Array<Record<string, string>> = [];
|
||||
const expectedProject = scope === "project" ? TEST_PROJECT_ID : undefined;
|
||||
await page.route("**/api/v1/responses", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const headers = await route.request().allHeaders();
|
||||
streamHeaders.push(headers);
|
||||
if (headers.authorization !== "Bearer BROWSER_CONTEXT_TEST_ONLY" || headers["x-project-id"] !== expectedProject) {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
json: {
|
||||
code: "PRODUCT_CONTEXT_UNAVAILABLE",
|
||||
message: "valid Bearer authorization and X-Project-Id are required"
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true });
|
||||
if (scope === "personal") {
|
||||
await panel.getByRole("button", { name: "选择对话归属" }).click();
|
||||
await page.getByRole("menuitem", { name: "个人会话", exact: true }).click();
|
||||
await expect(panel.getByRole("button", { name: "选择对话归属" })).toHaveText("个人会话");
|
||||
}
|
||||
await expect(panel.getByRole("button", { name: "选择 Agent 模型" })).toContainText("测试模型");
|
||||
await panel.getByRole("textbox").fill("检查会话项目上下文");
|
||||
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
|
||||
await expect.poll(() => streamHeaders.length).toBe(1);
|
||||
expect(streamHeaders[0].authorization).toBe("Bearer BROWSER_CONTEXT_TEST_ONLY");
|
||||
expect(streamHeaders[0]["x-project-id"]).toBe(expectedProject);
|
||||
await expect(panel.getByText("已收到工况汇总请求。", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Agent 请求失败", { exact: true })).toHaveCount(0);
|
||||
const box = await panel.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { responsesFixture } from "./support/responses-fixture";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { mockScadaApi } from "./support/mock-scada-api";
|
||||
|
||||
@@ -10,7 +11,7 @@ test("submits a custom answer with the actionable id after a later tool placehol
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
|
||||
const prompt = page
|
||||
.getByPlaceholder(/输入调度问题/)
|
||||
.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" })
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await prompt.fill("测试 question 自定义回答");
|
||||
@@ -29,7 +30,7 @@ test("submits a custom answer with the actionable id after a later tool placehol
|
||||
.poll(() => replies)
|
||||
.toEqual([
|
||||
{
|
||||
pathname: "/api/v1/agent/chat/question/question-1/reply",
|
||||
pathname: "/api/v1/question/question-1/reply",
|
||||
body: {
|
||||
session_id: "question-session",
|
||||
answers: [["分析高峰时段压力"]]
|
||||
@@ -48,7 +49,7 @@ test("keeps the custom answer control within a 375px Agent sheet", async ({ page
|
||||
await page.getByRole("button", { name: /Agent 工作台抽屉高度/ }).click();
|
||||
|
||||
const panel = page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true });
|
||||
const prompt = panel.getByPlaceholder(/输入调度问题/);
|
||||
const prompt = panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
|
||||
await prompt.fill("测试窄屏 question");
|
||||
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
await panel.getByRole("radio", { name: "自定义回答" }).click();
|
||||
@@ -69,7 +70,7 @@ async function mockQuestionApi(page: Page, replies: Array<{ pathname: string; bo
|
||||
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
||||
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_MAP_URL: "https://question-map.invalid/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
||||
@@ -78,17 +79,23 @@ async function mockQuestionApi(page: Page, replies: Array<{ pathname: string; bo
|
||||
})
|
||||
);
|
||||
await page.route("https://question-map.invalid/**", (route) => route.fulfill({ status: 204 }));
|
||||
await page.route("**/api/v1/agent/chat/**", async (route) => {
|
||||
await page.route("http://127.0.0.1:8787/api/v1/**", async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (request.method() === "OPTIONS") {
|
||||
await route.fulfill({ status: 204, headers: {
|
||||
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS"
|
||||
} });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/stream")) {
|
||||
if (pathname.endsWith("/responses")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "text/event-stream",
|
||||
"X-Vercel-AI-UI-Message-Stream": "v1"
|
||||
"Content-Type": "text/event-stream"
|
||||
},
|
||||
body: createQuestionStream()
|
||||
});
|
||||
@@ -176,5 +183,5 @@ function createQuestionStream() {
|
||||
{ type: "finish", finishReason: "stop" }
|
||||
];
|
||||
|
||||
return parts.map((part) => `data: ${JSON.stringify(part)}\n\n`).join("");
|
||||
return responsesFixture(parts);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createServer, type ServerResponse } from "node:http";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { responseFixtureWriter } from "./support/responses-fixture";
|
||||
import { expectThemeColor } from "./support/theme-colors";
|
||||
|
||||
for (const width of [1440, 375, 320]) {
|
||||
test(`steers and queues without stopping, and edits unconsumed messages at ${width}px`, async ({ page }, testInfo) => {
|
||||
const pending = new Map<string, { id: string; text: string; delivery: string; status: string }>();
|
||||
const admitted: Array<{ id: string; text: string; delivery: string }> = [];
|
||||
let response: ServerResponse | undefined;
|
||||
let encode = responseFixtureWriter();
|
||||
const send = (chunk: Record<string, unknown>) => response!.write(encode(chunk));
|
||||
const server = createServer((_request, output) => {
|
||||
response = output;
|
||||
encode = responseFixtureWriter();
|
||||
output.writeHead(200, { "Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache", "Content-Type": "text/event-stream" });
|
||||
send({ type: "start", messageId: "input-answer" });
|
||||
send({ type: "data-progress", data: { session_id: "input-session", title: "正在核对压力", status: "running" } });
|
||||
send({ type: "text-start", id: "answer" });
|
||||
send({ type: "text-delta", id: "answer", delta: "已开始核对压力。" });
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("missing fixture address");
|
||||
try {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
let primaryRequests = 0;
|
||||
let abortRequests = 0;
|
||||
await page.route("http://127.0.0.1:8787/api/v1/**", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "OPTIONS") { await route.fallback(); return; }
|
||||
const path = new URL(request.url()).pathname;
|
||||
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||
if (path.endsWith("/responses")) {
|
||||
const body = request.postDataJSON();
|
||||
if (!body.tjwater?.delivery) {
|
||||
primaryRequests++;
|
||||
await route.continue({ url: `http://127.0.0.1:${address.port}/responses` });
|
||||
} else {
|
||||
const item = { id: body.tjwater.input_id, text: body.input, delivery: body.tjwater.delivery, status: "pending" };
|
||||
admitted.push(item); pending.set(item.id, item);
|
||||
await route.fulfill({ status: 202, headers, json: item });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/inputs")) { await route.fulfill({ headers, json: { items: [...pending.values()] } }); return; }
|
||||
if (path.includes("/inputs/") && request.method() === "PATCH") {
|
||||
const item = pending.get(path.split("/").at(-1)!);
|
||||
if (!item) { await route.fulfill({ status: 409, headers, json: { message: "Consumed" } }); return; }
|
||||
item.delivery = request.postDataJSON().delivery;
|
||||
await route.fulfill({ headers, json: item });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/abort")) {
|
||||
abortRequests++; pending.clear(); response?.end();
|
||||
await route.fulfill({ headers, json: { aborted: true } });
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
|
||||
const composer = page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
|
||||
await composer.fill("核对压力变化");
|
||||
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
const deliveryMode = page.getByRole("button", { name: "运行中消息发送方式" });
|
||||
await expect(deliveryMode).toHaveText("引导当前任务");
|
||||
await deliveryMode.focus();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(page.getByRole("menuitemradio", { name: "引导当前任务" })).toHaveAttribute("aria-checked", "true");
|
||||
const queueOption = page.getByRole("menuitemradio", { name: "排队处理" });
|
||||
await queueOption.hover();
|
||||
await expectThemeColor(queueOption, "background-color", "--action-selection-soft-hover");
|
||||
await expectThemeColor(queueOption, "border-color", "--action-border-hover");
|
||||
await page.getByRole("menu").screenshot({ path: testInfo.outputPath("delivery-menu.png"), animations: "disabled" });
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(deliveryMode).toBeFocused();
|
||||
await expect(deliveryMode).toHaveText("引导当前任务");
|
||||
await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toBeVisible();
|
||||
await composer.fill("补充当前任务");
|
||||
await page.getByRole("button", { name: "引导当前 Agent 任务" }).click();
|
||||
const pendingMode = page.getByRole("button", { name: "消息发送方式:补充当前任务" });
|
||||
await expect(pendingMode).toHaveText("引导当前任务");
|
||||
await pendingMode.click();
|
||||
await page.getByRole("menuitemradio", { name: "排队处理" }).click();
|
||||
await expect(pendingMode).toHaveText("排队处理");
|
||||
await pendingMode.click();
|
||||
await expect(page.getByRole("menuitemradio", { name: "排队处理" })).toHaveAttribute("aria-checked", "true");
|
||||
const steerOption = page.getByRole("menuitemradio", { name: "引导当前任务" });
|
||||
await steerOption.hover();
|
||||
await expectThemeColor(steerOption, "background-color", "--action-selection-soft-hover");
|
||||
await page.screenshot({ path: testInfo.outputPath("pending-delivery-menu.png"), animations: "disabled" });
|
||||
await page.keyboard.press("Home");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(pendingMode).toHaveText("引导当前任务");
|
||||
await expect(pendingMode).toBeFocused();
|
||||
await deliveryMode.click();
|
||||
await page.getByRole("menuitemradio", { name: "排队处理" }).click();
|
||||
await composer.fill("下一轮核对流量");
|
||||
await page.getByRole("button", { name: "将消息加入 Agent 队列" }).click();
|
||||
await expect(page.getByRole("button", { name: "消息发送方式:下一轮核对流量" })).toHaveText("排队处理");
|
||||
expect(admitted.map((item) => item.delivery)).toEqual(["steer", "queue"]);
|
||||
expect(primaryRequests).toBe(1);
|
||||
expect(abortRequests).toBe(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
for (const item of pending.values()) {
|
||||
send({ type: "data-user_input", data: { ...item, session_id: "input-session", status: "delivered" } });
|
||||
}
|
||||
pending.clear();
|
||||
await expect(page.getByRole("list", { name: "待处理消息" })).toHaveCount(0);
|
||||
send({ type: "text-delta", id: "answer", delta: "已结合补充信息完成核对。" });
|
||||
send({ type: "text-end", id: "answer" });
|
||||
send({ type: "finish" }); response!.end();
|
||||
await expect(page.getByRole("button", { name: "发送 Agent 指令" })).toBeVisible();
|
||||
await expect(page.getByText("补充当前任务", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("下一轮核对流量", { exact: true })).toBeVisible();
|
||||
await composer.fill("继续核对压力");
|
||||
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toBeVisible();
|
||||
await composer.fill("等待处理的后续问题");
|
||||
await page.getByRole("button", { name: "将消息加入 Agent 队列" }).click();
|
||||
await expect(page.getByRole("button", { name: "消息发送方式:等待处理的后续问题" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "停止 Agent 指令" }).click();
|
||||
await expect(page.getByRole("list", { name: "待处理消息" })).toHaveCount(0);
|
||||
expect(abortRequests).toBe(1);
|
||||
} finally {
|
||||
response?.end(); server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ test.beforeEach(async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
await page.route("**/api/v1/agent/chat/models", (route) => route.fulfill({
|
||||
await page.route("**/api/v1/models", (route) => route.fulfill({
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
json: {
|
||||
default_model: "test/fast",
|
||||
@@ -59,8 +59,8 @@ for (const width of [1440, 375]) {
|
||||
const trigger = page.locator('button[aria-label="权限批准模式"]');
|
||||
await trigger.click();
|
||||
await waitForMenuAnimations(page.getByRole("menu"));
|
||||
const request = page.getByRole("menuitem", { name: /工具权限逐次确认/ });
|
||||
const always = page.getByRole("menuitem", { name: /自动允许本轮权限请求/ });
|
||||
const request = page.getByRole("menuitem", { name: /需要审批的请求逐次确认/ });
|
||||
const always = page.getByRole("menuitem", { name: /自动批准本轮工具权限请求/ });
|
||||
for (const item of [request, always]) {
|
||||
await item.hover();
|
||||
await expectThemeColor(item, "background-color", "--action-selection-soft-hover");
|
||||
@@ -85,6 +85,81 @@ for (const width of [1440, 375]) {
|
||||
});
|
||||
}
|
||||
|
||||
test("sends the selected approval mode and can return to manual approval", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const trigger = page.getByRole("button", { name: "权限批准模式" });
|
||||
|
||||
for (const [label, mode] of [
|
||||
["自动批准本轮工具权限请求", "always"],
|
||||
["需要审批的请求逐次确认", "request"],
|
||||
] as const) {
|
||||
await expect(trigger).toBeEnabled();
|
||||
await trigger.click();
|
||||
await page.getByRole("menuitem", { name: new RegExp(label) }).click();
|
||||
await page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill(`验证权限模式 ${mode}`);
|
||||
const sent = page.waitForRequest((request) => request.method() === "POST" && request.url().endsWith("/api/v1/responses"));
|
||||
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
expect((await sent).postDataJSON().tjwater.approval_mode).toBe(mode);
|
||||
}
|
||||
await expect(trigger).toBeEnabled();
|
||||
});
|
||||
|
||||
test("replaces a removed model before sending and shows the model actually submitted", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const trigger = page.getByRole("button", { name: "选择 Agent 模型" });
|
||||
await expect(trigger).toHaveAccessibleDescription("当前模型:快速");
|
||||
await trigger.click();
|
||||
await page.getByRole("menuitem", { name: /深入分析和复杂任务处理/ }).click();
|
||||
await expect(trigger).toHaveAccessibleDescription("当前模型:专家");
|
||||
|
||||
// Simulate an Agent restart with a new catalog while this page stays open.
|
||||
await page.route("**/api/v1/models", (route) => route.fulfill({
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
json: {
|
||||
default_model: "waternetwork/gpt-6-luna",
|
||||
models: [{ id: "waternetwork/gpt-6-luna", label: "快速", description: "当前可用模型", icon: "bolt" }]
|
||||
}
|
||||
}));
|
||||
await page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("检查更新后的快速模型");
|
||||
const sent = page.waitForRequest((request) => request.method() === "POST" && request.url().endsWith("/api/v1/responses"));
|
||||
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
expect((await sent).postDataJSON().model).toBe("waternetwork/gpt-6-luna");
|
||||
await expect(trigger).toHaveAccessibleDescription("当前模型:快速");
|
||||
});
|
||||
|
||||
test("keeps an explicitly selected model when it remains available", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const trigger = page.getByRole("button", { name: "选择 Agent 模型" });
|
||||
await expect(trigger).toHaveAccessibleDescription("当前模型:快速");
|
||||
await trigger.click();
|
||||
await page.getByRole("menuitem", { name: /深入分析和复杂任务处理/ }).click();
|
||||
await page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("保留专家模型选择");
|
||||
const sent = page.waitForRequest((request) => request.method() === "POST" && request.url().endsWith("/api/v1/responses"));
|
||||
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
expect((await sent).postDataJSON().model).toBe("test/expert");
|
||||
await expect(trigger).toHaveAccessibleDescription("当前模型:专家");
|
||||
});
|
||||
|
||||
test("does not send a generation request when the refreshed catalog is empty", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
await expect(page.getByRole("button", { name: "选择 Agent 模型" })).toHaveAccessibleDescription("当前模型:快速");
|
||||
await page.route("**/api/v1/models", (route) => route.fulfill({
|
||||
headers: { "Access-Control-Allow-Origin": "*" }, json: { default_model: "test/fast", models: [] }
|
||||
}));
|
||||
const requests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (request.method() === "POST" && request.url().endsWith("/api/v1/responses")) requests.push(request.url());
|
||||
});
|
||||
await page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("检查空模型目录");
|
||||
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
await expect(page.getByText("Agent 后端当前没有可用模型,请检查模型配置。").first()).toBeVisible();
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
async function waitForMenuAnimations(menu: Locator) {
|
||||
await expect(menu).toBeVisible();
|
||||
await menu.evaluate((element) => Promise.all(
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page, { sessions: [{
|
||||
session_id: "recent-pressure", title: "城南供水压力复核", created_at: Date.now() - 3_600_000, updated_at: Date.now() - 60_000,
|
||||
}] });
|
||||
});
|
||||
|
||||
test("empty conversation centers one composer and directly sends an Agent-generated recent activity question", async ({ page }) => {
|
||||
let requests = 0;
|
||||
await page.route("**/api/v1/suggestions", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
requests += 1;
|
||||
const body = route.request().postDataJSON() as { model: string; activities: Array<{ id: string; title: string; kind: string }> };
|
||||
expect(body.model).toBe("test/model");
|
||||
const recent = body.activities.find((item) => item.title === "城南供水压力复核");
|
||||
expect(recent).toBeDefined();
|
||||
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { suggestions: [{
|
||||
question: "重新核对城南供水压力复核涉及的监测证据", activity_id: recent!.id,
|
||||
}] } });
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await expect(panel).toHaveAttribute("data-agent-starting", "true");
|
||||
await expect(panel.getByRole("heading", { name: "我能帮你处理什么?" })).toBeVisible();
|
||||
const question = panel.getByRole("button", { name: /重新核对城南供水压力复核涉及的监测证据/ });
|
||||
await expect(question).toBeVisible();
|
||||
await expect(question).toContainText("近期会话");
|
||||
const composer = (await panel.locator(".agent-panel-start-composer").boundingBox())!;
|
||||
const panelBox = (await panel.boundingBox())!;
|
||||
expect(Math.abs(composer.y + composer.height / 2 - (panelBox.y + 56 + (panelBox.height - 56) / 2))).toBeLessThan(1);
|
||||
await expect(panel.locator("form")).toHaveCount(1);
|
||||
await panel.screenshot({ path: "test-results/agent-history-ui/agent-start-generated.png" });
|
||||
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
||||
for (const width of [400, 640]) {
|
||||
const box = (await handle.boundingBox())!;
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(width, box.y + box.height / 2);
|
||||
await page.mouse.up();
|
||||
await expect(panel).toHaveCSS("width", `${width}px`);
|
||||
}
|
||||
expect(requests).toBe(1);
|
||||
await question.click();
|
||||
await expect(panel).toHaveAttribute("data-agent-starting", "false");
|
||||
await expect(panel.locator(".agent-panel-composer-motion")).toHaveCSS("transform", "none");
|
||||
await expect(panel.locator("textarea")).toHaveCSS("min-height", "96px");
|
||||
expect((await panel.locator(".agent-panel-band").last().boundingBox())!.height).toBeLessThanOrEqual(160);
|
||||
await expect(panel.locator(".agent-panel-user-message")).toHaveText("重新核对城南供水压力复核涉及的监测证据");
|
||||
await expect(panel.locator("form")).toHaveCount(1);
|
||||
await expect.poll(async () => {
|
||||
const form = (await panel.locator("form").boundingBox())!;
|
||||
const box = (await panel.boundingBox())!;
|
||||
return Math.abs(form.y + form.height - (box.y + box.height - 8));
|
||||
}).toBeLessThan(1);
|
||||
await panel.screenshot({ path: "test-results/agent-history-ui/agent-conversation-compact.png" });
|
||||
|
||||
const prompt = panel.locator("textarea");
|
||||
await prompt.focus();
|
||||
await page.keyboard.type("核对监测点");
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
await page.keyboard.type("补充现场记录");
|
||||
await expect(prompt).toHaveValue("核对监测点\n补充现场记录");
|
||||
const initialHeight = (await prompt.boundingBox())!.height;
|
||||
expect(initialHeight).toBe(96);
|
||||
await prompt.fill("监测点、异常时间及现场核验记录。\n".repeat(30));
|
||||
for (const height of [900, 640]) {
|
||||
await page.setViewportSize({ width: 1280, height });
|
||||
const promptBox = (await prompt.boundingBox())!;
|
||||
expect(promptBox.height).toBeGreaterThan(initialHeight);
|
||||
expect(promptBox.height).toBeLessThanOrEqual(Math.min(height * 0.24, 192));
|
||||
expect(promptBox.x).toBeGreaterThanOrEqual(0);
|
||||
await expect.poll(() => prompt.evaluate((element) => {
|
||||
const input = element.getBoundingClientRect();
|
||||
const container = element.closest("aside")!.getBoundingClientRect();
|
||||
return input.left >= container.left && input.right <= container.right;
|
||||
})).toBe(true);
|
||||
expect(await prompt.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
|
||||
const send = panel.getByRole("button", { name: "发送 Agent 指令" });
|
||||
await expect(send).toBeVisible();
|
||||
const sendBox = (await send.boundingBox())!;
|
||||
expect(sendBox.y + sendBox.height).toBeLessThanOrEqual(height);
|
||||
expect((await panel.getByRole("log").boundingBox())!.height).toBeGreaterThan(100);
|
||||
}
|
||||
await prompt.fill("");
|
||||
await expect(prompt).toHaveCSS("height", "96px");
|
||||
await panel.screenshot({ path: "test-results/agent-history-ui/agent-conversation-short-viewport.png" });
|
||||
});
|
||||
|
||||
test("failed recommendation generation leaves the centered input ready for a normal conversation", async ({ page }) => {
|
||||
await page.route("**/api/v1/suggestions", (route) => route.request().method() === "OPTIONS" ? route.fallback() : route.fulfill({
|
||||
status: 503, headers: { "Access-Control-Allow-Origin": "*" }, json: { message: "unavailable" },
|
||||
}));
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await expect(panel.getByText("推荐问题暂不可用,可直接输入任务。")).toBeVisible();
|
||||
const prompt = panel.getByPlaceholder("询问供水问题,或描述需要处理的任务");
|
||||
await prompt.fill("核对供水运行情况");
|
||||
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
await expect(panel.locator(".agent-panel-user-message")).toHaveText("核对供水运行情况");
|
||||
await expect(panel).toHaveAttribute("data-agent-starting", "false");
|
||||
});
|
||||
|
||||
test("first send smoothly moves the existing composer to the bottom", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await expect(panel.getByRole("group", { name: "推荐问题" }).getByRole("button")).toHaveCount(3);
|
||||
await panel.evaluate(async (element) => {
|
||||
await Promise.all(element.getAnimations({ subtree: true })
|
||||
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity)
|
||||
.map((animation) => animation.finished.catch(() => undefined)));
|
||||
});
|
||||
const form = panel.locator("form");
|
||||
await form.locator("textarea").fill("核对近期供水压力变化");
|
||||
const beforeTop = (await form.boundingBox())!.y;
|
||||
await page.evaluate(() => {
|
||||
const panel = document.querySelector('aside[aria-label="Agent 命令面板"]')!;
|
||||
const form = panel.querySelector("form")!;
|
||||
const state = { form, positions: [] as Array<{ active: boolean; y: number }>, done: false };
|
||||
Object.assign(window, { __agentComposerTransition: state });
|
||||
const start = performance.now();
|
||||
const sample = () => {
|
||||
state.positions.push({ active: panel.getAttribute("data-agent-starting") === "false", y: form.getBoundingClientRect().y });
|
||||
if (performance.now() - start < 1000) requestAnimationFrame(sample);
|
||||
else state.done = true;
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
await expect(panel).toHaveAttribute("data-agent-starting", "false");
|
||||
await expect.poll(() => page.evaluate(() => (window as typeof window & {
|
||||
__agentComposerTransition: { done: boolean }
|
||||
}).__agentComposerTransition.done)).toBe(true);
|
||||
const afterTop = (await form.boundingBox())!.y;
|
||||
const result = await page.evaluate(() => {
|
||||
const state = (window as typeof window & { __agentComposerTransition: {
|
||||
form: HTMLFormElement; positions: Array<{ active: boolean; y: number }>
|
||||
} }).__agentComposerTransition;
|
||||
return { sameForm: state.form === document.querySelector('aside[aria-label="Agent 命令面板"] form'), positions: state.positions };
|
||||
});
|
||||
expect(result.sameForm).toBe(true);
|
||||
expect(afterTop).toBeGreaterThan(beforeTop);
|
||||
expect(result.positions.some((position) => position.active && position.y > beforeTop + 4 && position.y < afterTop - 4)).toBe(true);
|
||||
await expect(panel.locator("form")).toHaveCount(1);
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { responseFixtureWriter } from "./support/responses-fixture";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockScadaApi } from "./support/mock-scada-api";
|
||||
import { expectThemeColor } from "./support/theme-colors";
|
||||
|
||||
const STREAM_CHUNK_SIZE = 18;
|
||||
const STREAM_CHUNK_DELAY_MS = 20;
|
||||
@@ -17,16 +19,14 @@ test.beforeAll(async () => {
|
||||
streamServer = createServer((_request, response) => {
|
||||
response.writeHead(200, {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "text/event-stream",
|
||||
"X-Vercel-AI-UI-Message-Stream": "v1"
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Content-Type": "text/event-stream"
|
||||
});
|
||||
|
||||
const content = createLongMarkdownResponse();
|
||||
const chunks = splitIntoCodePointChunks(content, STREAM_CHUNK_SIZE);
|
||||
const send = (chunk: Record<string, unknown>) => {
|
||||
response.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
};
|
||||
const encode = responseFixtureWriter();
|
||||
const send = (chunk: Record<string, unknown>) => { response.write(encode(chunk)); };
|
||||
|
||||
send({ type: "start", messageId: "assistant-stream-stability" });
|
||||
send({
|
||||
@@ -186,7 +186,7 @@ test("stream completion and the plan card remain positionally stable", async ({
|
||||
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
||||
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_MAP_URL: "https://stream-map.invalid/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
||||
@@ -195,7 +195,7 @@ test("stream completion and the plan card remain positionally stable", async ({
|
||||
})
|
||||
);
|
||||
await page.route("https://stream-map.invalid/**", (route) => route.fulfill({ status: 204 }));
|
||||
await page.route("**/api/v1/agent/chat/**", async (route) => {
|
||||
await page.route("http://127.0.0.1:8787/api/v1/**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
@@ -210,7 +210,7 @@ test("stream completion and the plan card remain positionally stable", async ({
|
||||
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
|
||||
if (path.endsWith("/stream")) {
|
||||
if (path.endsWith("/responses")) {
|
||||
await route.continue({ url: streamServerUrl });
|
||||
return;
|
||||
}
|
||||
@@ -244,7 +244,7 @@ test("stream completion and the plan card remain positionally stable", async ({
|
||||
page.getByRole("button", { name: "选择 Agent 模型" }).filter({ visible: true }).first()
|
||||
).toContainText("测试模型", { timeout: 10_000 });
|
||||
const prompt = page
|
||||
.getByPlaceholder(/输入调度问题/)
|
||||
.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" })
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(prompt).toBeVisible();
|
||||
@@ -311,7 +311,7 @@ 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();
|
||||
await expect(userMessage).toHaveCSS("background-color", "oklch(0.965 0.018 255)");
|
||||
await expectThemeColor(userMessage, "background-color", "--agent-user-message-surface");
|
||||
expect(
|
||||
await userMessage.evaluate((element) => getComputedStyle(element).backgroundColor)
|
||||
).not.toBe(
|
||||
@@ -323,26 +323,10 @@ test("stream completion and the plan card remain positionally stable", async ({
|
||||
await expect
|
||||
.poll(() => assistantMessage.evaluate((element) => getComputedStyle(element).boxShadow))
|
||||
.toBe(await userMessage.evaluate((element) => getComputedStyle(element).boxShadow));
|
||||
const operationalBrief = page.locator(".agent-panel-control").filter({
|
||||
has: page.getByRole("button", { name: "预览影响", exact: true })
|
||||
});
|
||||
await expect(operationalBrief).toBeVisible();
|
||||
await expect(operationalBrief).toContainText("Agent 分析中");
|
||||
await expect(operationalBrief.getByRole("button", { name: "预览影响" })).toBeDisabled();
|
||||
await expect(operationalBrief.getByRole("button", { name: "检查证据" })).toBeDisabled();
|
||||
await expect(operationalBrief).toHaveCSS("position", "static");
|
||||
await expect(operationalBrief).toHaveCSS("border-radius", "12px");
|
||||
// Desktop uses an inline brief above the conversation, not the legacy floating overlay.
|
||||
await expect(page.locator(".agent-panel-operational-brief")).toHaveCount(0);
|
||||
await expectThemeColor(page.locator("[data-agent-conversation-state=active]"), "background-color", "--agent-dock-reading");
|
||||
await expect(page.locator(".agent-panel-shell")).toHaveClass(/surface-dock/);
|
||||
await expect(page.locator(".agent-panel-operational-float")).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() =>
|
||||
operationalBrief.evaluate((element) => {
|
||||
const conversation = document.querySelector(".agent-panel-conversation")!;
|
||||
return conversation.getBoundingClientRect().top - element.getBoundingClientRect().bottom;
|
||||
})
|
||||
)
|
||||
.toBeGreaterThanOrEqual(0);
|
||||
|
||||
const liveProgress = page.getByRole("list", { name: "最近执行进度" });
|
||||
await expect(liveProgress.locator("li")).toHaveCount(3);
|
||||
@@ -470,9 +454,9 @@ 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 expect(expandedLiveProgress.locator('[data-progress-status="running"]').first()).toHaveCSS(
|
||||
"color",
|
||||
"oklch(0.546 0.245 262.881)"
|
||||
await expectThemeColor(
|
||||
expandedLiveProgress.locator('[data-progress-status="running"]').first(),
|
||||
"color", "--action-blue-hover"
|
||||
);
|
||||
await expect(expandedLiveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
@@ -494,10 +478,8 @@ test("stream completion and the plan card remain positionally stable", async ({
|
||||
resumeStream();
|
||||
|
||||
await expect(
|
||||
page.getByText("分析完成", { exact: true }).filter({ visible: true }).first()
|
||||
).toBeVisible({
|
||||
timeout: 10_000
|
||||
});
|
||||
page.getByRole("button", { name: "发送 Agent 指令" }).filter({ visible: true }).first()
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(progressToggle.locator("../..")).toContainText("步骤4/4");
|
||||
await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0);
|
||||
await expect(
|
||||
@@ -618,7 +600,7 @@ test("stream completion and the plan card remain positionally stable", async ({
|
||||
expect(pressedScrollButtonBox.y).toBe(scrollButtonBox.y + 1);
|
||||
await page.mouse.move(scrollButtonBox.x - 20, scrollButtonBox.y);
|
||||
await page.mouse.up();
|
||||
await expect(scrollToLatestButton).toHaveCSS("background-color", "rgba(255, 255, 255, 0.94)");
|
||||
await expectThemeColor(scrollToLatestButton, "background-color", "--agent-dock-overlay-surface");
|
||||
await expect
|
||||
.poll(() =>
|
||||
scrollToLatestButton.evaluate((element) => getComputedStyle(element).backdropFilter)
|
||||
|
||||
@@ -97,7 +97,7 @@ test("speech recognition appends every final transcript in one result batch", as
|
||||
|
||||
await page.getByRole("button", { name: "语音输入" }).click();
|
||||
|
||||
await expect(page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应")).toHaveValue("检查城南泵站 水位");
|
||||
await expect(page.getByPlaceholder("询问供水问题,或描述需要处理的任务")).toHaveValue("检查城南泵站 水位");
|
||||
});
|
||||
|
||||
test("speech recognition pulse stays transparent across its loop boundary", async ({ page }) => {
|
||||
|
||||
@@ -217,7 +217,7 @@ async function prepareWorkbench(page: Page) {
|
||||
TJWATER_MAP_URL: "https://button-system.invalid/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
||||
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
||||
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_ENABLE_DEV_PANEL: "false",
|
||||
TJWATER_ENABLE_MSW: "false"
|
||||
};`
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { responsesFixture } from "./support/responses-fixture";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import type { OperationalTask, OperationalTaskNotification, OperationalTaskSummary } from "../../src/features/workbench/api/operational-task-client";
|
||||
@@ -89,8 +90,8 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
|
||||
const firstReport = task.latest_report!;
|
||||
let notifications: OperationalTaskNotification[] = [];
|
||||
const summary = (item: OperationalTask): OperationalTaskSummary => {
|
||||
const { latest_plan, latest_report, evidence, actions, access_requests, permissions, work_order_snapshot, ...value } = item;
|
||||
void latest_plan; void latest_report; void evidence; void actions; void access_requests; void permissions; void work_order_snapshot;
|
||||
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;
|
||||
return value;
|
||||
};
|
||||
await page.route("http://server.test/api/v1/workbench/sync-snapshot", (route) => route.fulfill({ json: {
|
||||
@@ -194,7 +195,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
|
||||
};
|
||||
const inlineControls = await captureDiagramControls(diagram);
|
||||
const assertControlGeometry = (controls: typeof inlineControls) => {
|
||||
expect(controls).toHaveLength(4);
|
||||
expect(controls).toHaveLength(3);
|
||||
for (const control of controls) {
|
||||
expect({ width: control.width, height: control.height, radius: control.radius,
|
||||
iconWidth: control.iconWidth, iconHeight: control.iconHeight }).toEqual({
|
||||
@@ -205,27 +206,20 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
|
||||
const assertInlineControlRail = async (surface: typeof diagram) => {
|
||||
const mermaidBlock = surface.locator('[data-streamdown="mermaid-block"]');
|
||||
const graph = surface.locator('[data-streamdown="mermaid"]');
|
||||
const fullscreenButton = surface.getByRole("button", { name: "全屏查看", exact: true });
|
||||
const fullscreenButton = surface.getByRole("button", { name: "放大查看", exact: true });
|
||||
const zoomInButton = surface.getByRole("button", { name: "Zoom in", exact: true });
|
||||
const fullscreenGroup = surface.locator('[data-streamdown="mermaid-block-actions"]');
|
||||
const zoomGroup = zoomInButton.locator("..");
|
||||
const [blockBox, graphBox, fullscreenBox, zoomInBox, fullscreenGroupBox, zoomGroupBox] = await Promise.all([
|
||||
const [blockBox, graphBox, fullscreenBox, zoomInBox, zoomGroupBox] = await Promise.all([
|
||||
mermaidBlock.boundingBox(), graph.boundingBox(), fullscreenButton.boundingBox(), zoomInButton.boundingBox(),
|
||||
fullscreenGroup.boundingBox(), zoomGroup.boundingBox()
|
||||
zoomGroup.boundingBox()
|
||||
]);
|
||||
expect(blockBox).not.toBeNull();
|
||||
expect(graphBox).not.toBeNull();
|
||||
expect(fullscreenBox).not.toBeNull();
|
||||
expect(zoomInBox).not.toBeNull();
|
||||
expect(fullscreenGroupBox).not.toBeNull();
|
||||
expect(zoomGroupBox).not.toBeNull();
|
||||
expect(Math.round(graphBox!.y - blockBox!.y)).toBeLessThanOrEqual(10);
|
||||
expect(Math.round(fullscreenBox!.x)).toBe(Math.round(zoomInBox!.x));
|
||||
expect(Math.round(zoomInBox!.y - fullscreenBox!.y - fullscreenBox!.height)).toBe(18);
|
||||
expect(Math.round(fullscreenGroupBox!.x)).toBe(Math.round(zoomGroupBox!.x));
|
||||
expect(Math.round(fullscreenGroupBox!.width)).toBe(Math.round(zoomGroupBox!.width));
|
||||
expect(Math.round(zoomGroupBox!.y - fullscreenGroupBox!.y - fullscreenGroupBox!.height)).toBe(8);
|
||||
expect(Math.round(blockBox!.x + blockBox!.width - fullscreenGroupBox!.x - fullscreenGroupBox!.width)).toBe(18);
|
||||
expect(fullscreenBox!.y + fullscreenBox!.height).toBeLessThanOrEqual(blockBox!.y);
|
||||
expect(Math.round(graphBox!.x + graphBox!.width - zoomGroupBox!.x - zoomGroupBox!.width)).toBe(8);
|
||||
};
|
||||
assertControlGeometry(inlineControls);
|
||||
@@ -259,31 +253,30 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
|
||||
await expectThemeColor(zoomIn, "outline-color", "--action-focus-ring");
|
||||
await zoomIn.locator("..").screenshot({ path: testInfo.outputPath("diagram-controls-keyboard-focus.png") });
|
||||
await testInfo.attach("styled-investigation-diagram", { body: await diagram.screenshot({ path: testInfo.outputPath("styled-investigation-diagram.png") }), contentType: "image/png" });
|
||||
await diagram.getByRole("button", { name: "全屏查看", exact: true }).click();
|
||||
const fullscreen = page.locator('body > div[role="button"]').filter({ has: page.getByRole("button", { name: "退出全屏", exact: true }) });
|
||||
await diagram.getByRole("button", { name: "放大查看", exact: true }).click();
|
||||
const fullscreen = page.getByRole("dialog", { name: "调查脉络", exact: true });
|
||||
await expect(fullscreen.locator("svg[id]")).toHaveCount(1);
|
||||
const fullscreenControls = await captureDiagramControls(fullscreen);
|
||||
assertControlGeometry(fullscreenControls);
|
||||
const assertFullscreenCloseFrame = async () => {
|
||||
const close = fullscreen.getByRole("button", { name: "退出全屏", exact: true });
|
||||
const close = fullscreen.getByRole("button", { name: "关闭详情", exact: true });
|
||||
const frame = await close.evaluate((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
const panel = button.closest('[role="dialog"]')!.getBoundingClientRect();
|
||||
return {
|
||||
top: rect.top,
|
||||
right: window.innerWidth - rect.right,
|
||||
boxShadow: getComputedStyle(button).boxShadow
|
||||
top: rect.top - panel.top,
|
||||
right: panel.right - rect.right
|
||||
};
|
||||
});
|
||||
expect(Math.round(frame.top)).toBe(21);
|
||||
expect(Math.round(frame.right)).toBe(21);
|
||||
expect(frame.boxShadow).not.toBe("none");
|
||||
expect(frame.top).toBeGreaterThanOrEqual(12);
|
||||
expect(frame.right).toBeGreaterThanOrEqual(12);
|
||||
};
|
||||
await assertFullscreenCloseFrame();
|
||||
await writeFile(testInfo.outputPath("diagram-control-geometry.json"), JSON.stringify({ viewport: page.viewportSize(), inline: inlineControls, fullscreen: fullscreenControls }, null, 2));
|
||||
await testInfo.attach("styled-investigation-fullscreen", { body: await page.screenshot({ path: testInfo.outputPath("styled-investigation-fullscreen.png") }), contentType: "image/png" });
|
||||
await fullscreen.getByRole("button", { name: "退出全屏", exact: true }).click();
|
||||
await fullscreen.getByRole("button", { name: "关闭详情", exact: true }).click();
|
||||
await expect(fullscreen).toHaveCount(0);
|
||||
await diagram.getByRole("button", { name: "全屏查看", exact: true }).click();
|
||||
await diagram.getByRole("button", { name: "放大查看", exact: true }).click();
|
||||
await expect(fullscreen).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(fullscreen).toHaveCount(0);
|
||||
@@ -347,14 +340,18 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
|
||||
report_reference: { execution_generation: 1, revision: 2 }, resolved_at: null, resolution_reason: null,
|
||||
status: "unread", version: 1, required_permission: null, created_at: now, read_at: null, read_by: null }];
|
||||
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
|
||||
await expect(panel.getByRole("button", { name: "查看新报告" })).toBeVisible();
|
||||
await expect(panel.getByText("新报告", { exact: true })).toBeVisible();
|
||||
expect(notifications[0].status).toBe("unread");
|
||||
await expect(panel.getByLabel("任务报告", { exact: true })
|
||||
.getByText("上游记录未见同期异常,需现场核查用户端。", { exact: true })).toBeVisible();
|
||||
await panel.getByRole("button", { name: "查看新报告" }).click();
|
||||
// Live is the default; only explicitly choosing a historical version freezes it.
|
||||
await expect(panel.getByRole("button", { name: "查看新报告" })).toHaveCount(0);
|
||||
await expect.poll(() => notifications[0].status).toBe("read");
|
||||
await expect(panel.getByText("本轮已结束 · 无法形成结论", { exact: true })).toBeVisible();
|
||||
// Existing investigations may only have a same-round saved plan, not an embedded diagram.
|
||||
await expect(diagram).toBeAttached();
|
||||
await diagram.scrollIntoViewIfNeeded();
|
||||
await expect(diagram.locator("svg[id]")).toHaveCount(1);
|
||||
await expect(diagram).toContainText("多源数据核对");
|
||||
await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(0);
|
||||
await testInfo.attach("legacy-investigation-diagram", { body: await diagram.screenshot({
|
||||
path: testInfo.outputPath("legacy-investigation-diagram.png") }), contentType: "image/png" });
|
||||
await expect(panel.getByLabel("数据缺口")).toContainText("无法区分管网与用户端异常");
|
||||
await expect(panel.getByText("已结束 · 证据不足", { exact: true }).first()).toHaveAttribute("data-status-tone", "warning");
|
||||
await expect(panel.getByRole("button", { name: "确认并创建预约工单" })).toHaveCount(0);
|
||||
@@ -364,12 +361,12 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
|
||||
await panel.getByRole("heading", { name: "当前结果", exact: true }).scrollIntoViewIfNeeded();
|
||||
await testInfo.attach("insufficient-report-desktop", { body: await panel.screenshot({
|
||||
path: testInfo.outputPath("insufficient-report-desktop.png") }), contentType: "image/png" });
|
||||
await panel.getByText("任务时间线", { exact: true }).click();
|
||||
const timeline = panel.getByLabel("任务时间线记录");
|
||||
await panel.getByRole("button", { name: "展开任务时间线" }).click();
|
||||
const timeline = page.getByRole("dialog", { name: "任务时间线" }).getByLabel("任务时间线记录");
|
||||
await expect(timeline).toContainText("采样工单已创建");
|
||||
await testInfo.attach("investigation-timeline-desktop", { body: await timeline.screenshot({
|
||||
path: testInfo.outputPath("investigation-timeline-desktop.png") }), contentType: "image/png" });
|
||||
await panel.getByText("任务时间线", { exact: true }).click();
|
||||
await page.getByRole("dialog", { name: "任务时间线" }).getByRole("button", { name: "关闭详情" }).click();
|
||||
|
||||
for (const width of [375, 320]) {
|
||||
await page.setViewportSize({ width, height: 720 });
|
||||
@@ -389,12 +386,12 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
|
||||
assertControlGeometry(await captureDiagramControls(mobileDiagram));
|
||||
await assertInlineControlRail(mobileDiagram);
|
||||
if (width === 320) {
|
||||
await mobileDiagram.getByRole("button", { name: "全屏查看", exact: true }).click();
|
||||
await mobileDiagram.getByRole("button", { name: "放大查看", exact: true }).click();
|
||||
await expect(fullscreen.locator("svg[id]")).toHaveCount(1);
|
||||
assertControlGeometry(await captureDiagramControls(fullscreen));
|
||||
await assertFullscreenCloseFrame();
|
||||
await testInfo.attach("diagram-fullscreen-320", { body: await page.screenshot({ path: testInfo.outputPath("diagram-fullscreen-320.png") }), contentType: "image/png" });
|
||||
await fullscreen.getByRole("button", { name: "退出全屏", exact: true }).evaluate((button) => button.click());
|
||||
await fullscreen.getByRole("button", { name: "关闭详情", exact: true }).evaluate((button) => button.click());
|
||||
await expect(fullscreen).toHaveCount(0);
|
||||
}
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
|
||||
@@ -405,21 +402,26 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
|
||||
});
|
||||
}
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await panel.getByText("报告版本", { exact: true }).click();
|
||||
await expect(panel.getByRole("button", { name: /报告第 2 版 · 最新版/ })).toBeVisible();
|
||||
await panel.getByRole("button", { name: /第 1 轮 · 报告第 1 版/ }).click();
|
||||
await panel.getByRole("button", { name: "展开报告版本" }).click();
|
||||
const versions = page.getByRole("dialog", { name: "报告版本" });
|
||||
await expect(versions.getByRole("button", { name: /报告第 2 版 · 最新版/ })).toBeVisible();
|
||||
await versions.getByRole("button", { name: /第 1 轮 · 报告第 1 版/ }).click();
|
||||
await expect(panel.getByLabel("任务报告", { exact: true })
|
||||
.getByText("上游记录未见同期异常,需现场核查用户端。", { exact: true })).toBeVisible();
|
||||
await page.route("**/api/v1/agent/chat/stream", async (route) => {
|
||||
await page.route("**/api/v1/responses", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback();
|
||||
expect(route.request().postDataJSON().task_context).toEqual({ report_revision: 1, evidence_id: "monitor-1" });
|
||||
expect(route.request().postDataJSON().tjwater.task_context).toEqual({ report_revision: 1, evidence_id: "monitor-1" });
|
||||
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream", "X-Vercel-AI-UI-Message-Stream": "v1" },
|
||||
body: [{ type: "start", messageId: "selected-report-answer" },
|
||||
body: responsesFixture([{ type: "start", messageId: "selected-report-answer" },
|
||||
{ type: "data-task_context", data: { task_id: taskId, execution_generation: 1, report_revision: 1 } },
|
||||
{ type: "text-start", id: "answer" }, { type: "text-delta", id: "answer", delta: "这条依据仅覆盖上游监测范围。" },
|
||||
{ type: "text-end", id: "answer" }, { type: "finish", finishReason: "stop" }].map((part) => `data: ${JSON.stringify(part)}\n\n`).join("") });
|
||||
{ type: "text-end", id: "answer" }, { type: "finish", finishReason: "stop" }]) });
|
||||
});
|
||||
await panel.getByLabel("主要依据").getByRole("button", { name: "围绕此证据提问" }).click();
|
||||
await expect(agentPanel.getByRole("group", { name: "对话引用" })).toContainText("报告第 1 版");
|
||||
await expect(agentPanel.locator("textarea")).toBeFocused();
|
||||
await expect(agentPanel.locator("textarea")).toHaveValue(/请解释.*这条证据的范围、来源和判断影响/);
|
||||
await agentPanel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
await expect(agentPanel).toContainText("依据:第 1 轮 · 报告第 1 版");
|
||||
task = { ...task, revision: 9, execution_generation: 2, current_report_revision: 3,
|
||||
latest_report: { ...firstReport, revision: 3, execution_generation: 2, report: {
|
||||
@@ -430,7 +432,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
|
||||
await page.getByRole("button", { name: "折叠 Agent 面板", exact: true }).click();
|
||||
const reopen = panel.getByRole("button", { name: "展开工况任务" });
|
||||
if (await reopen.isVisible()) await reopen.click();
|
||||
const viewNew = panel.getByRole("button", { name: "查看新报告" });
|
||||
const viewNew = panel.getByRole("button", { name: "返回实时内容" });
|
||||
if (await viewNew.isVisible()) await viewNew.click();
|
||||
await panel.getByLabel("调查脉络", { exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(panel.getByText("图示暂不可用,仍可查看结论、证据和待审核动作。", { exact: true })).toBeVisible();
|
||||
|
||||
@@ -119,7 +119,7 @@ async function mockFeaturePanelRuntime(page: Page) {
|
||||
TJWATER_MAP_URL: "https://feature-panel-map.invalid/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
||||
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
||||
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_WORKBENCH_RUNTIME_MODE: "demo",
|
||||
TJWATER_ENABLE_DEV_PANEL: "false",
|
||||
TJWATER_ENABLE_MSW: "false"
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { TEST_PROJECT_ID } from "./support/mock-scada-api";
|
||||
import { expectThemeColor } from "./support/theme-colors";
|
||||
|
||||
const first = { id: "place-nanqiao-north", name: "南桥路", address: "上海市奉贤区南桥路1号", longitude: 121.458891, latitude: 30.916188, category: "道路名", district: "奉贤区" };
|
||||
const second = { id: "place-nanqiao-south", name: "南桥路", address: "上海市奉贤区南桥路100号", longitude: 121.459301, latitude: 30.914195, category: "道路名", district: "奉贤区" };
|
||||
const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Project-Id", "Access-Control-Allow-Methods": "POST, OPTIONS" };
|
||||
|
||||
async function setup(page: Page) {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
}
|
||||
|
||||
async function openSearch(page: Page) {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const trigger = page.getByRole("button", { name: "地址定位:查询地址并定位到地图" });
|
||||
await expect(trigger).toBeEnabled({ timeout: 20_000 });
|
||||
await trigger.click();
|
||||
return page.getByRole("region", { name: "地址定位", exact: true });
|
||||
}
|
||||
|
||||
for (const width of [1280, 1023, 375, 320]) {
|
||||
test(`place search selects and switches candidates at ${width}px`, async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await setup(page);
|
||||
let calls = 0;
|
||||
await page.route("**/api/v1/place-search-requests", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers }); return; }
|
||||
expect(route.request().postDataJSON()).toEqual({ keyword: "南桥路" });
|
||||
expect(route.request().headers()["x-project-id"]).toBe(TEST_PROJECT_ID);
|
||||
calls += 1;
|
||||
await route.fulfill({ headers, json: { items: [first, second], total: 2, truncated: false, region: "奉贤区" } });
|
||||
});
|
||||
const panel = await openSearch(page);
|
||||
await expect(panel).toContainText("奉贤区 · 天地图地名搜索");
|
||||
const input = panel.getByLabel("查询地址");
|
||||
const query = panel.getByRole("button", { name: "搜索地名" });
|
||||
await expectThemeColor(input, "background-color", "--surface-reading");
|
||||
const [inputBox, queryBox] = await Promise.all([input.boundingBox(), query.boundingBox()]);
|
||||
expect(inputBox!.height).toBe(40);
|
||||
expect(queryBox!.height).toBe(40);
|
||||
expect(inputBox!.y).toBe(queryBox!.y);
|
||||
await expect(query).toBeDisabled();
|
||||
await input.fill(" 南桥路 ");
|
||||
await input.press("Enter");
|
||||
const candidates = panel.getByRole("region", { name: "搜索结果" });
|
||||
await expect(candidates.getByRole("button")).toHaveCount(2);
|
||||
const marker = page.locator('[data-geocoding-marker="true"]');
|
||||
await expect(marker).toHaveCount(0);
|
||||
await expect(panel.getByRole("status")).toContainText("选择一个候选位置");
|
||||
const resultHeader = candidates.locator(":scope > div").first();
|
||||
const measureFrame = () => Promise.all([panel.boundingBox(), input.boundingBox(), resultHeader.boundingBox()]);
|
||||
const listFrame = await measureFrame();
|
||||
const listViewport = await candidates.locator("ul").boundingBox();
|
||||
const listName = candidates.locator("span.font-semibold").first();
|
||||
const nameFontSize = await listName.evaluate((element) => getComputedStyle(element).fontSize);
|
||||
await page.screenshot({ path: test.info().outputPath(`place-candidates-${width}.png`) });
|
||||
await candidates.getByRole("button", { name: "定位 南桥路 上海市奉贤区南桥路1号", exact: true }).click();
|
||||
const selected = panel.getByRole("region", { name: "定位结果" });
|
||||
await expect(selected).toContainText("上海市奉贤区南桥路1号");
|
||||
await expect(selected.getByLabel("经纬度坐标").locator("dd")).toHaveText("121.458891, 30.916188");
|
||||
await expect(marker).toHaveCount(1);
|
||||
await expect(candidates.getByRole("button", { name: "更换位置" })).toBeVisible();
|
||||
await expect(candidates.locator("ul")).toHaveCount(0);
|
||||
const detailFrame = await measureFrame();
|
||||
const detailViewport = await selected.boundingBox();
|
||||
for (let index = 0; index < listFrame.length; index += 1) {
|
||||
expect(detailFrame[index]).toEqual(listFrame[index]);
|
||||
}
|
||||
expect(detailViewport).toEqual(listViewport);
|
||||
await expect(selected.locator("span.font-semibold")).toHaveCSS("font-size", nameFontSize);
|
||||
await candidates.getByRole("button", { name: "更换位置" }).click();
|
||||
await expect(selected).toHaveCount(0);
|
||||
expect(await measureFrame()).toEqual(listFrame);
|
||||
await test.info().attach("list-detail-frame", { body: JSON.stringify({ width, listFrame, detailFrame, listViewport, detailViewport }), contentType: "application/json" });
|
||||
await candidates.getByRole("button", { name: "定位 南桥路 上海市奉贤区南桥路100号", exact: true }).click();
|
||||
await expect(selected).toContainText("上海市奉贤区南桥路100号");
|
||||
await expect(selected.getByLabel("经纬度坐标").locator("dd")).toHaveText("121.459301, 30.914195");
|
||||
await expect(marker).toHaveCount(1);
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const center = window.__waterNetworkMap?.getCenter();
|
||||
return center ? [Number(center.lng.toFixed(6)), Number(center.lat.toFixed(6))] : null;
|
||||
})).toEqual([121.459301, 30.914195]);
|
||||
await selected.getByRole("button", { name: "重新定位" }).scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: test.info().outputPath(`place-selected-${width}.png`) });
|
||||
await selected.getByRole("button", { name: "重新定位" }).click();
|
||||
expect(calls).toBe(1);
|
||||
await selected.getByRole("button", { name: "清除结果" }).click();
|
||||
await expect(marker).toHaveCount(0);
|
||||
await expect(candidates).toHaveCount(0);
|
||||
await expect(input).toHaveValue("");
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [1280, 320]) {
|
||||
test(`ten candidates have no paging or checkmark and preserve keyboard focus at ${width}px`, async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await page.setViewportSize({ width, height: width === 320 ? 812 : 900 });
|
||||
await setup(page);
|
||||
const names = ["南桥饭店", "南桥饭店南楼", "南桥饭店东苑"];
|
||||
const items = Array.from({ length: 10 }, (_, index) => ({
|
||||
...first, id: `hotel-test-${index}`, name: names[index] ?? `南桥饭店测试入口${index + 1}`,
|
||||
address: "南桥路111号", district: "奉贤区", category: "宾馆"
|
||||
}));
|
||||
await page.route("**/api/v1/place-search-requests", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers }); return; }
|
||||
await route.fulfill({ headers, json: { items, total: 10, truncated: true, region: "奉贤区" } });
|
||||
});
|
||||
const panel = await openSearch(page);
|
||||
await panel.getByLabel("查询地址").fill("南桥饭店");
|
||||
await panel.getByLabel("查询地址").press("Enter");
|
||||
const results = panel.getByRole("region", { name: "搜索结果" });
|
||||
await expect(results).toContainText("前 10 个结果");
|
||||
await expect(results).toContainText("最多显示 10 个位置,请细化关键词");
|
||||
await expect(results.getByLabel("搜索结果分页")).toHaveCount(0);
|
||||
await expect(results.getByRole("button", { name: "上一页" })).toHaveCount(0);
|
||||
await expect(results.getByRole("button", { name: "下一页" })).toHaveCount(0);
|
||||
const list = results.locator("ul");
|
||||
await expect(list.locator("li")).toHaveCount(10);
|
||||
await expect(list).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expect(list).toHaveCSS("border-left-width", "0px");
|
||||
await expect(list).toHaveCSS("border-right-width", "0px");
|
||||
const geometry = await list.evaluate((element) => {
|
||||
const viewport = element.getBoundingClientRect();
|
||||
const rows = Array.from(element.querySelectorAll("li")).map((row) => row.getBoundingClientRect());
|
||||
return { visibleRows: rows.filter((row) => row.top >= viewport.top && row.bottom <= viewport.bottom).length,
|
||||
rowHeight: rows[0].height, height: viewport.height, scrollable: element.scrollHeight > element.clientHeight };
|
||||
});
|
||||
expect(geometry.visibleRows).toBeGreaterThanOrEqual(width === 320 ? 2 : 4);
|
||||
expect(geometry.rowHeight).toBeGreaterThanOrEqual(64);
|
||||
expect(geometry.scrollable).toBe(true);
|
||||
const [panelBox, listBox] = await Promise.all([panel.boundingBox(), list.boundingBox()]);
|
||||
expect(listBox!.y + listBox!.height).toBeLessThanOrEqual(panelBox!.y + panelBox!.height - 10);
|
||||
await test.info().attach("candidate-geometry", { body: JSON.stringify(geometry), contentType: "application/json" });
|
||||
await page.screenshot({ path: test.info().outputPath(`place-ten-candidates-${width}.png`) });
|
||||
const target = results.getByRole("button", { name: "定位 南桥饭店 南桥路111号", exact: true });
|
||||
await target.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const change = results.getByRole("button", { name: "更换位置" });
|
||||
await expect(change).toBeFocused();
|
||||
await expect(panel.getByRole("region", { name: "定位结果" })).toContainText("121.458891, 30.916188");
|
||||
const detail = panel.getByRole("region", { name: "定位结果" });
|
||||
await expectThemeColor(detail.locator("span.font-semibold"), "color", "--ink");
|
||||
await expect(detail.locator("span.font-semibold").locator("xpath=../../.."))
|
||||
.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(target).toBeFocused();
|
||||
await expect(target).toHaveAttribute("aria-pressed", "true");
|
||||
await expectThemeColor(target, "background-color", "--action-selection-soft");
|
||||
await expectThemeColor(target.locator("span.font-semibold"), "color", "--action-blue-hover");
|
||||
await expect(target.locator("svg")).toHaveCount(0);
|
||||
await page.screenshot({ path: test.info().outputPath(`place-color-selected-${width}.png`) });
|
||||
const eighth = results.getByRole("button", { name: `定位 ${items[7].name} ${items[7].address}`, exact: true });
|
||||
await eighth.scrollIntoViewIfNeeded();
|
||||
await eighth.click();
|
||||
await expect(detail).toContainText(items[7].name);
|
||||
await expect(detail.getByLabel("经纬度坐标")).toBeInViewport({ ratio: 1 });
|
||||
const detailBox = await detail.boundingBox();
|
||||
const actionBox = await detail.getByRole("button", { name: "重新定位" }).boundingBox();
|
||||
expect(actionBox!.y + actionBox!.height).toBeLessThanOrEqual(detailBox!.y + detailBox!.height);
|
||||
await expect(detail.getByText("08", { exact: true })).toHaveCount(0);
|
||||
await expectThemeColor(detail.locator("span.font-semibold"), "color", "--ink");
|
||||
await page.screenshot({ path: test.info().outputPath(`place-neutral-detail-${width}.png`) });
|
||||
await change.click();
|
||||
await expect(eighth).toHaveAttribute("aria-pressed", "true");
|
||||
const isSelectedVisible = () => eighth.evaluate((element) => {
|
||||
const viewport = element.closest("ul")!.getBoundingClientRect();
|
||||
const row = element.getBoundingClientRect();
|
||||
return row.top >= viewport.top && row.bottom <= viewport.bottom;
|
||||
});
|
||||
await expect.poll(isSelectedVisible).toBe(true);
|
||||
expect(await list.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
|
||||
await eighth.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(change).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(eighth).toBeFocused();
|
||||
await expect.poll(isSelectedVisible).toBe(true);
|
||||
await page.screenshot({ path: test.info().outputPath(`place-return-to-selection-${width}.png`) });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
|
||||
});
|
||||
}
|
||||
|
||||
test("place search reports errors and distinguishes an empty Fengxian search", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await page.setViewportSize({ width: 320, height: 700 });
|
||||
await setup(page);
|
||||
let fail = true;
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||||
const queries: string[] = [];
|
||||
await page.route("**/api/v1/place-search-requests", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers }); return; }
|
||||
const body = route.request().postDataJSON() as { keyword: string };
|
||||
queries.push(body.keyword);
|
||||
if (fail) {
|
||||
await pending;
|
||||
await route.fulfill({ status: 503, headers, json: { detail: "地名搜索服务暂不可用" } });
|
||||
return;
|
||||
}
|
||||
const empty = body.keyword === "未知地名";
|
||||
await route.fulfill({ headers, json: {
|
||||
items: empty ? [] : [first], total: empty ? 0 : 1, truncated: false, region: "奉贤区"
|
||||
} });
|
||||
});
|
||||
const panel = await openSearch(page);
|
||||
const input = panel.getByLabel("查询地址");
|
||||
const query = panel.getByRole("button", { name: "搜索地名" });
|
||||
await input.fill("南桥路");
|
||||
await query.click();
|
||||
await expect(panel.getByRole("status")).toContainText("正在搜索地名");
|
||||
await expect(query).toBeDisabled();
|
||||
release();
|
||||
await expect(panel.getByRole("alert")).toContainText("地名搜索服务暂不可用");
|
||||
fail = false;
|
||||
await query.click();
|
||||
await expect(panel.getByRole("region", { name: "搜索结果" }).locator("li")).toHaveCount(1);
|
||||
expect(queries).toEqual(["南桥路", "南桥路"]);
|
||||
await input.fill("未知地名");
|
||||
await query.click();
|
||||
await expect(panel.getByRole("status")).toContainText("奉贤区内未找到匹配位置");
|
||||
await expect(panel.getByRole("region", { name: "搜索结果" })).toHaveCount(0);
|
||||
await expect(page.locator('[data-geocoding-marker="true"]')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(320);
|
||||
await page.screenshot({ path: test.info().outputPath("place-empty-320.png") });
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
|
||||
test("调查 SVG 兼容 HTML 换行并保留节点交互与错误兜底", async ({ page }, testInfo) => {
|
||||
await page.route("http://127.0.0.1:8787/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
|
||||
await mockAgentApi(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.setContent('<!doctype html><html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="diagrams" style="max-width:640px;margin:auto;padding:16px"></main></body></html>');
|
||||
await page.evaluate(async () => {
|
||||
// Reuse Vite's exact module identities, including its per-port cache and
|
||||
// version query; importing a second React breaks the component's hooks.
|
||||
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 { investigationDiagramPlugins } = await import("/src/features/workbench/utils/investigation-diagram-style.ts");
|
||||
const getMermaid = investigationDiagramPlugins.mermaid.getMermaid.bind(investigationDiagramPlugins.mermaid);
|
||||
Object.assign(window, { diagramRenderCount: 0 });
|
||||
investigationDiagramPlugins.mermaid.getMermaid = (config: unknown) => {
|
||||
const renderer = getMermaid(config);
|
||||
return { ...renderer, render(id: string, source: string) {
|
||||
if (source.includes("读取任务上下文")) (window as unknown as { diagramRenderCount: number }).diagramRenderCount++;
|
||||
return renderer.render(id, source);
|
||||
} };
|
||||
};
|
||||
let diagram = {
|
||||
title: "换行回归检查",
|
||||
mermaid: 'flowchart TD\nsubgraph Evidence["证据核对"]\nA["读取任务上下文"] --> B["上游监测<br>读取SCADA"]\nB --> C["形成边界<br/>结论"]\nend',
|
||||
node_refs: [{ node_id: "B", label: "上游监测", evidence_refs: ["e1"], action_refs: [], status: "running" }]
|
||||
};
|
||||
const root = createRoot(document.getElementById("diagrams"));
|
||||
const render = () => root.render(React.createElement(React.Fragment, null,
|
||||
React.createElement(InvestigationDiagramView, { diagram, running: true,
|
||||
renderDetails: () => React.createElement("p", null, "已保存的 SCADA 引用依据") }),
|
||||
React.createElement(InvestigationDiagramView, { diagram: { title: "错误兜底检查", mermaid: "flowchart TD\nA[未闭合", node_refs: [] },
|
||||
renderDetails: () => null })
|
||||
));
|
||||
Object.assign(window, { updateDiagram: () => { diagram = { ...diagram, mermaid: `${diagram.mermaid}\nC --> D[补齐现场资料]` }; render(); } });
|
||||
render();
|
||||
});
|
||||
const graph = page.getByRole("region", { name: "调查脉络", exact: true }).first();
|
||||
await expect(graph.locator("svg .node")).toHaveCount(3);
|
||||
await expect(graph.locator("svg foreignObject br")).toHaveCount(2);
|
||||
await expect(graph.locator("svg .cluster-label")).toContainText("证据核对");
|
||||
await expect(graph.locator("svg .tjRunning")).toHaveCount(1);
|
||||
await expect(graph).not.toContainText("This page contains the following errors");
|
||||
await graph.locator(".node").filter({ hasText: "读取SCADA" }).click();
|
||||
await expect(graph.getByLabel("节点详情 上游监测")).toContainText("已保存的 SCADA 引用依据");
|
||||
await graph.getByRole("button", { name: "Zoom in", exact: true }).click();
|
||||
await graph.getByRole("button", { name: "Reset zoom and pan", exact: true }).click();
|
||||
await graph.locator("svg.flowchart").evaluate((element) => { Object.assign(window, { originalDiagram: element }); });
|
||||
await graph.getByRole("button", { name: "放大查看", exact: true }).click();
|
||||
const detail = page.getByRole("dialog", { name: "换行回归检查", exact: true });
|
||||
await expect(detail.locator("svg .node")).toHaveCount(3);
|
||||
expect(await page.evaluate(() => (window as unknown as { diagramRenderCount: number }).diagramRenderCount)).toBe(1);
|
||||
expect(await detail.locator("svg.flowchart").evaluate((element) => element === (window as unknown as { originalDiagram: Element }).originalDiagram)).toBe(true);
|
||||
await expect(detail.getByLabel("节点详情 上游监测")).toContainText("已保存的 SCADA 引用依据");
|
||||
await detail.getByRole("button", { name: "Zoom in", exact: true }).click();
|
||||
const box = (await detail.boundingBox())!;
|
||||
expect(box.width).toBeLessThan(1440 - 32);
|
||||
expect(box.height).toBeLessThan(900);
|
||||
expect(box.x).toBeGreaterThan(16);
|
||||
expect(await page.evaluate(() => document.fullscreenElement === null)).toBe(true);
|
||||
const close = page.getByRole("button", { name: "关闭详情", exact: true });
|
||||
await expect(close).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(close).toHaveCount(0);
|
||||
await expect(graph.getByRole("button", { name: "放大查看", exact: true })).toBeFocused();
|
||||
await expect(graph.locator('[role="application"]')).toHaveCSS("transform", "matrix(1.1, 0, 0, 1.1, 0, 0)");
|
||||
// Streamdown renders diagrams lazily when their block enters the viewport.
|
||||
await page.getByRole("region", { name: "调查脉络", exact: true }).nth(1).scrollIntoViewIfNeeded();
|
||||
await expect(page.getByText("图示暂不可用,仍可查看结论、证据和待审核动作。", { exact: true })).toBeVisible();
|
||||
await graph.screenshot({ path: testInfo.outputPath("line-breaks-desktop.png") });
|
||||
await page.setViewportSize({ width: 375, height: 800 });
|
||||
await expect(graph.locator("svg .node")).toHaveCount(3);
|
||||
await graph.getByRole("button", { name: "放大查看", exact: true }).click();
|
||||
await expect(detail.locator("svg .node")).toHaveCount(3);
|
||||
expect(await page.evaluate(() => (window as unknown as { diagramRenderCount: number }).diagramRenderCount)).toBe(1);
|
||||
const narrowBox = (await detail.boundingBox())!;
|
||||
expect(narrowBox.width).toBeLessThanOrEqual(343);
|
||||
expect(narrowBox.height).toBeLessThanOrEqual(688);
|
||||
expect(narrowBox.x).toBeGreaterThanOrEqual(15);
|
||||
await detail.screenshot({ path: testInfo.outputPath("diagram-expanded-narrow.png") });
|
||||
await page.evaluate(() => (window as unknown as { updateDiagram: () => void }).updateDiagram());
|
||||
await expect(detail.locator("svg .node")).toHaveCount(4);
|
||||
expect(await page.evaluate(() => (window as unknown as { diagramRenderCount: number }).diagramRenderCount)).toBe(2);
|
||||
await close.click();
|
||||
await expect(detail).toHaveCount(0);
|
||||
await expect(graph.locator("svg .node")).toHaveCount(4);
|
||||
await graph.getByRole("button", { name: "放大查看", exact: true }).click();
|
||||
await expect(detail.locator("svg .node")).toHaveCount(4);
|
||||
expect(await page.evaluate(() => (window as unknown as { diagramRenderCount: number }).diagramRenderCount)).toBe(2);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(detail).toHaveCount(0);
|
||||
await graph.screenshot({ path: testInfo.outputPath("line-breaks-mobile.png") });
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
|
||||
test("调查图样式对照保留中文标题、分工和真实 SVG 关系", async ({ page }, testInfo) => {
|
||||
await page.route("**/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
|
||||
await page.route("http://127.0.0.1:8787/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
|
||||
await mockAgentApi(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.setViewportSize({ width: 1536, height: 1250 });
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { OperationalTask } from "../../src/features/workbench/api/operational-task-client";
|
||||
|
||||
const now = "2026-09-23T09:00:00Z";
|
||||
const sources = ["peripheral_test_data", "scada_devices", "workbench_runtime", "scada_readings", "work_orders", "other_saved_data"];
|
||||
const sourceRef = (source: string) => `${source}:sha256:${"7d5852b69d8e3d4e127800882683c3319e58".repeat(2)}`;
|
||||
const snapshotKey = "query_scope_with_a_long_unbroken_field_name";
|
||||
|
||||
const task: OperationalTask = {
|
||||
project_id: "11111111-1111-4111-8111-111111111111",
|
||||
task_id: "22222222-2222-4222-8222-222222222222",
|
||||
task_type: "event_investigation", workflow: "water_quality_complaint",
|
||||
lifecycle_status: "completed", active_run: null, status: "completed", priority: "normal",
|
||||
revision: 1, execution_generation: 1, execution_request_id: null,
|
||||
title: "水质投诉调查", summary: "居民反映异味",
|
||||
source: { kind: "operator", external_id: null, external_revision: null, event_kind: "water_quality_complaint" },
|
||||
trigger: { kind: "manual", occurred_at: now, detail: "演示小区" }, scheduled_for: null,
|
||||
agent_session_id: null, agent_lease_expires_at: null, current_plan_revision: null, current_report_revision: 1,
|
||||
created_at: now, updated_at: now, latest_plan: null,
|
||||
latest_report: {
|
||||
revision: 1, execution_generation: 1, source_session_id: "evidence-layout-session", published_at: now, published_by: "agent",
|
||||
report: {
|
||||
title: "调查报告", current_conclusion: "需现场核查用户端。", conclusion_boundary: "仅覆盖已有记录。",
|
||||
evidence_refs: sources.slice(0, 5), recommendations: [], pending_items: [],
|
||||
stage: "final", outcome: "concluded", stop_reason: null, data_gaps: []
|
||||
}
|
||||
},
|
||||
evidence: sources.map((source) => ({
|
||||
evidence_id: source, kind: "system_data", summary: `${source} 数据快照`, source_label: source,
|
||||
source_ref: sourceRef(source), observed_at: now, recorded_at: now, recorded_by: "agent", has_snapshot: true
|
||||
})),
|
||||
actions: [], access_requests: [], work_order_snapshot: null,
|
||||
permissions: {
|
||||
can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false,
|
||||
can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false,
|
||||
can_execute_commands: false, can_decide_access: false
|
||||
}
|
||||
};
|
||||
|
||||
test("调查证据与数据快照的长引用在桌面和窄屏完整换行", async ({ page }, testInfo) => {
|
||||
await page.route("http://127.0.0.1:8787/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.setContent('<html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="report" style="max-width:480px;margin:auto;padding:16px"></main></body></html>');
|
||||
await page.evaluate(async ({ task, snapshotKey }) => {
|
||||
const reactModule = await import("/node_modules/.vite/deps/react.js");
|
||||
const React = reactModule.default ?? reactModule;
|
||||
const clientModule = await import("/node_modules/.vite/deps/react-dom_client.js");
|
||||
const { createRoot } = clientModule.default ?? clientModule;
|
||||
const { AgentExecutionPlanPanel } = await import("/src/features/workbench/components/agent-execution-plan-panel.tsx");
|
||||
const noop = async () => undefined;
|
||||
createRoot(document.getElementById("report")).render(React.createElement(AgentExecutionPlanPanel, {
|
||||
task, onContinue: noop, onDecision: noop, onExecute: noop, onReconcile: noop, onRefresh: noop,
|
||||
onLoadEvidenceSnapshot: async (evidenceId: string) => ({
|
||||
source: "workbench_runtime", origin: "system_data",
|
||||
source_ref: task.evidence.find((item) => item.evidence_id === evidenceId)!.source_ref,
|
||||
queried_at: task.updated_at, truncated: false, data: { [snapshotKey]: "long_snapshot_value_".repeat(8) }
|
||||
})
|
||||
}));
|
||||
}, { task, snapshotKey });
|
||||
const evidence = page.getByRole("region", { name: "主要依据", exact: true });
|
||||
await expect(evidence).toBeVisible();
|
||||
for (const name of ["更多引用依据 · 2 条", "其他已保存资料"]) {
|
||||
const summary = evidence.locator("summary").filter({ hasText: name });
|
||||
await summary.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(summary.locator("..")).toHaveAttribute("open", "");
|
||||
}
|
||||
for (const source of sources) {
|
||||
const card = evidence.locator("details").filter({ has: page.locator("summary", { hasText: `${source} 数据快照` }) }).last();
|
||||
await card.locator("summary").first().click();
|
||||
await expect(card.locator("dd.font-mono")).toHaveText(sourceRef(source));
|
||||
await card.getByRole("button", { name: "查看数据快照", exact: true }).click();
|
||||
await expect(card.getByRole("region", { name: "数据快照" })).toContainText(snapshotKey);
|
||||
}
|
||||
for (const width of [1440, 705, 375, 320]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const overflow = await evidence.evaluate((section) => {
|
||||
return [...section.querySelectorAll("dd, dt, [aria-label='数据快照'] p")].flatMap((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const card = element.closest("details")!.getBoundingClientRect();
|
||||
return element.scrollWidth > element.clientWidth + 1 || rect.right > card.right + 1 || rect.left < card.left - 1
|
||||
? [{ text: element.textContent, width: element.clientWidth, scrollWidth: element.scrollWidth, right: rect.right, cardRight: card.right }]
|
||||
: [];
|
||||
});
|
||||
});
|
||||
expect(overflow, `证据字段在 ${width}px 下不应撑出卡片`).toEqual([]);
|
||||
await evidence.screenshot({ path: testInfo.outputPath(`evidence-${width}.png`) });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { OperationalTask } from "../../src/features/workbench/api/operational-task-client";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
|
||||
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
|
||||
const taskId = "92000000-0000-4000-8000-000000000001";
|
||||
const runId = "93000000-0000-4000-8000-000000000001";
|
||||
const requestId = "94000000-0000-4000-8000-000000000001";
|
||||
const now = "2026-10-10T08:00:00Z";
|
||||
|
||||
test("调查进度替换整图,跟随只展示新指令并在关闭时清理覆盖层", async ({ page }, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
await page.route("https://tjwater-api.invalid/api/v1/scada-devices**", (route) => route.fulfill({ json: {
|
||||
items: ["P1", "P2"].map((device_id, index) => ({ device_id, device_type: "pressure", node_id: null,
|
||||
link_id: null, api_query_id: null, measurement_unit: "MPa", transmission_mode: "test",
|
||||
transmission_frequency: "1min", reliability: 1, x: null, y: null,
|
||||
longitude: 121.48 + index * 0.001, latitude: 31.1 + index * 0.001 })),
|
||||
total: 2, limit: 1000, offset: 0
|
||||
} }));
|
||||
await page.route("**/runtime-config.js", (route) => route.fulfill({
|
||||
contentType: "application/javascript",
|
||||
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
|
||||
TJWATER_AUTH_MODE: "disabled", TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
||||
TJWATER_AGENT_URL: "http://127.0.0.1:8787", TJWATER_ENABLE_DEV_PANEL: "true",
|
||||
TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver", TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: "", TJWATER_TIANDITU_TOKEN: "", TJWATER_ENABLE_MSW: "false"
|
||||
})};`
|
||||
}));
|
||||
// Keep the stream genuinely open: a fulfilled static SSE body is a disconnect.
|
||||
await page.addInitScript(() => {
|
||||
const animate = Element.prototype.animate;
|
||||
const reportAnimations: string[] = [];
|
||||
Object.assign(window, { reportAnimations });
|
||||
Element.prototype.animate = function (...args) {
|
||||
const label = this.getAttribute("aria-label");
|
||||
if (label === "当前调查进展" || label === "任务报告") reportAnimations.push(label);
|
||||
return animate.apply(this, args);
|
||||
};
|
||||
const original = window.fetch.bind(window);
|
||||
const streams = new Set<ReadableStreamDefaultController<Uint8Array>>();
|
||||
Object.assign(window, {
|
||||
emitInvestigationEvent: (event: { event_seq: number }) => {
|
||||
const bytes = new TextEncoder().encode(`id: ${event.event_seq}\nevent: workbench_event\ndata: ${JSON.stringify(event)}\n\n`);
|
||||
for (const stream of streams) stream.enqueue(bytes);
|
||||
}
|
||||
});
|
||||
window.fetch = async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
if (!url.includes("/api/v1/workbench/events")) return original(input, init);
|
||||
let controller: ReadableStreamDefaultController<Uint8Array>;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(value) { controller = value; streams.add(value); },
|
||||
cancel() { streams.delete(controller); }
|
||||
});
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
if (streams.delete(controller)) controller.close();
|
||||
}, { once: true });
|
||||
return new Response(body, { headers: { "Content-Type": "text/event-stream" } });
|
||||
};
|
||||
});
|
||||
let task: OperationalTask = {
|
||||
project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "water_quality_complaint",
|
||||
lifecycle_status: "open", status: "running", priority: "normal", revision: 1, execution_generation: 1,
|
||||
execution_request_id: requestId, title: "合成投诉联动调查", summary: "浏览器验证用合成投诉,不是真实工单",
|
||||
source: { kind: "operator", external_id: null, external_revision: null, event_kind: "water_quality_complaint" },
|
||||
trigger: { kind: "manual", occurred_at: now, detail: "合成数据" }, scheduled_for: null,
|
||||
agent_session_id: "investigation-test", agent_lease_expires_at: now, current_plan_revision: null,
|
||||
current_report_revision: null, created_at: now, updated_at: now,
|
||||
active_run: { run_id: runId, task_id: taskId, generation: 1, execution_request_id: requestId,
|
||||
status: "running", revision: 1, agent_session_id: "investigation-test", lease_expires_at: now,
|
||||
heartbeat_at: now, started_at: now, finished_at: null, failure_code: null, failure_detail: null,
|
||||
dispatch_attempts: 1, next_dispatch_at: now, created_at: now, updated_at: now },
|
||||
latest_plan: null, latest_report: null,
|
||||
latest_progress: { revision: 1, run_id: runId, execution_generation: 1, published_at: now,
|
||||
source_session_id: "investigation-test", progress: { summary: "正在核对投诉点位",
|
||||
diagram: { title: "实时调查脉络", mermaid: 'flowchart TD\nA["投诉点位核对"] --> B["人工采样"]',
|
||||
node_refs: [
|
||||
{ node_id: "A", label: "投诉点位核对", status: "running", evidence_refs: [], action_refs: [] },
|
||||
{ node_id: "B", label: "人工采样", status: "waiting_human", evidence_refs: [], action_refs: [] }
|
||||
] } } },
|
||||
evidence: [], actions: [], access_requests: [], work_order_snapshot: null,
|
||||
permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false,
|
||||
can_decide_work_orders: true, can_execute_work_orders: true, can_decide_commands: false,
|
||||
can_execute_commands: false, can_decide_access: false }
|
||||
};
|
||||
let eventCursor = 10;
|
||||
let cursorReads = 0;
|
||||
await page.route("https://tjwater-api.invalid/api/v1/workbench/sync-snapshot", (route) => {
|
||||
const { latest_plan, latest_report, latest_progress, evidence, actions, access_requests, work_order_snapshot, permissions, ...summary } = task;
|
||||
void latest_plan; void latest_report; void latest_progress; void evidence; void actions;
|
||||
void access_requests; void work_order_snapshot; void permissions;
|
||||
return route.fulfill({ json: {
|
||||
schema: "workbench-sync-snapshot@1", event_cursor: eventCursor, generated_at: now,
|
||||
runtime: { availability: "not_configured", source: "none", scenario_availability: "not_configured",
|
||||
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: [summary], notifications: []
|
||||
} });
|
||||
});
|
||||
await page.route(`https://tjwater-api.invalid/api/v1/operational-tasks/${taskId}**`, (route) => {
|
||||
if (route.request().url().endsWith("/presentation-cursor")) {
|
||||
cursorReads += 1;
|
||||
return route.fulfill({ json: { task_id: taskId, execution_generation: 1,
|
||||
active_run: { run_id: runId, generation: 1, status: "running" }, event_cursor: eventCursor } });
|
||||
}
|
||||
if (route.request().url().includes("/reports")) return route.fulfill({ json: { items: [], next_offset: null } });
|
||||
return route.fulfill({ json: task });
|
||||
});
|
||||
const emit = async (command: Record<string, unknown>, seq = ++eventCursor) => {
|
||||
await page.evaluate((event) => {
|
||||
(window as unknown as { emitInvestigationEvent: (value: unknown) => void }).emitInvestigationEvent(event);
|
||||
}, { schema: "workbench-event@1", event_seq: seq,
|
||||
event_id: `95000000-0000-4000-8000-${String(seq).padStart(12, "0")}`, project_id: projectId,
|
||||
topic: "operational.presentation.requested", aggregate_type: "operational_task", aggregate_id: taskId,
|
||||
aggregate_revision: 1, occurred_at: now,
|
||||
metadata: { task_id: taskId, run_id: runId, execution_generation: 1, command } });
|
||||
};
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
const expand = panel.getByRole("button", { name: "展开工况任务" });
|
||||
if (await expand.isVisible()) await expand.click();
|
||||
await panel.getByRole("button", { name: /调查任务/ }).click();
|
||||
await panel.getByText("合成投诉联动调查", { exact: true }).first().click();
|
||||
await expect(panel.getByText("正在核对投诉点位", { exact: true }).first()).toBeVisible();
|
||||
const diagram = panel.getByLabel("调查脉络", { exact: true });
|
||||
await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(1);
|
||||
const animations = () => page.evaluate(() => (window as unknown as { reportAnimations: string[] }).reportAnimations);
|
||||
expect(await animations()).toEqual([]);
|
||||
await diagram.evaluate((element) => { element.setAttribute("data-retained-reader", "true"); });
|
||||
task = { ...task, revision: 2, latest_progress: { ...task.latest_progress!, revision: 2,
|
||||
progress: { summary: "新增支路复核,等待现场反馈", diagram: { title: "重规划后的调查",
|
||||
mermaid: 'flowchart TD\nC["支路复核"] --> D["现场反馈"]', node_refs: [
|
||||
{ node_id: "C", label: "支路复核", status: "completed", evidence_refs: [], action_refs: [] },
|
||||
{ node_id: "D", label: "现场反馈", status: "waiting_human", evidence_refs: [], action_refs: [] }
|
||||
] } } } };
|
||||
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
|
||||
await expect(diagram).toContainText("支路复核");
|
||||
await expect(diagram).not.toContainText("投诉点位核对");
|
||||
await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(0);
|
||||
await expect(diagram).toHaveAttribute("data-retained-reader", "true");
|
||||
await expect.poll(animations).toEqual(["当前调查进展"]);
|
||||
|
||||
task = { ...task, revision: 3, latest_report: { revision: 1, execution_generation: 1,
|
||||
source_session_id: "investigation-test", published_at: now, published_by: "agent", report: {
|
||||
title: "合成调查报告", current_conclusion: "合成结论:等待现场核查", conclusion_boundary: "浏览器动画测试",
|
||||
evidence_refs: [], recommendations: ["核对现场记录"], pending_items: [], stage: "interim",
|
||||
outcome: "concluded", stop_reason: null, data_gaps: [], diagram: null
|
||||
} } };
|
||||
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
|
||||
const report = panel.getByRole("region", { name: "任务报告", exact: true });
|
||||
await expect(report).toContainText("合成结论:等待现场核查");
|
||||
await expect.poll(animations).toEqual(["当前调查进展", "任务报告"]);
|
||||
await report.getByText("判断说明与建议", { exact: true }).click();
|
||||
await report.locator("details").evaluate((element) => element.setAttribute("data-retained-reader", "true"));
|
||||
task = { ...task, revision: 4, latest_report: { ...task.latest_report!, revision: 2,
|
||||
report: { ...task.latest_report!.report, current_conclusion: "合成结论:现场核查已更新" } } };
|
||||
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
|
||||
await expect(report).toContainText("合成结论:现场核查已更新");
|
||||
await expect(report.locator("details")).toHaveAttribute("open", "");
|
||||
await expect(report.locator("details")).toHaveAttribute("data-retained-reader", "true");
|
||||
await expect.poll(animations).toEqual(["当前调查进展", "任务报告", "任务报告"]);
|
||||
await testInfo.attach("report-update", { body: await report.screenshot(), contentType: "image/png" });
|
||||
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
task = { ...task, revision: 5, latest_report: { ...task.latest_report!, revision: 3,
|
||||
report: { ...task.latest_report!.report, current_conclusion: "合成结论:减少动态效果下更新" } } };
|
||||
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
|
||||
await expect(report).toContainText("合成结论:减少动态效果下更新");
|
||||
expect(await animations()).toEqual(["当前调查进展", "任务报告", "任务报告"]);
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
||||
const follow = page.getByRole("checkbox", { name: "跟随所选调查" });
|
||||
await expect(follow).toBeChecked();
|
||||
const checkboxAlignment = await follow.evaluate((input) => {
|
||||
const box = input.getBoundingClientRect();
|
||||
const mark = input.parentElement!.querySelector("svg")!.getBoundingClientRect();
|
||||
return { width: box.width, height: box.height,
|
||||
offsetX: mark.x + mark.width / 2 - box.x - box.width / 2,
|
||||
offsetY: mark.y + mark.height / 2 - box.y - box.height / 2 };
|
||||
});
|
||||
expect(checkboxAlignment).toEqual({ width: 20, height: 20, offsetX: 0, offsetY: 0 });
|
||||
await expect.poll(() => cursorReads).toBeGreaterThan(0);
|
||||
const dev = page.getByTestId("map-dev-panel");
|
||||
const sections = dev.locator("details");
|
||||
await expect(sections).toHaveCount(5);
|
||||
await expect(sections.filter({ hasText: "调查联动" })).toHaveAttribute("open", "");
|
||||
await expect(sections.filter({ hasText: "要素定位" })).toHaveAttribute("open", "");
|
||||
await expect(sections.filter({ hasText: "水流调试" })).not.toHaveAttribute("open", "");
|
||||
await testInfo.attach("dev-panel-default", { body: await page.screenshot({ path: testInfo.outputPath("dev-panel-default.png") }), contentType: "image/png" });
|
||||
const flowSection = sections.filter({ hasText: "水流调试" });
|
||||
const flowToggle = flowSection.locator("summary");
|
||||
await flowToggle.click();
|
||||
await dev.getByLabel("管道 ID", { exact: true }).fill("P-9");
|
||||
await flowToggle.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(flowSection).not.toHaveAttribute("open", "");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(dev.getByLabel("管道 ID", { exact: true })).toHaveValue("P-9");
|
||||
await flowToggle.click();
|
||||
await sections.filter({ hasText: "要素定位" }).locator("summary").click();
|
||||
await sections.filter({ hasText: "图层样式" }).locator("summary").click();
|
||||
await sections.filter({ hasText: "数值标注" }).locator("summary").click();
|
||||
await page.setViewportSize({ width: 1024, height: 640 });
|
||||
await dev.getByRole("button", { name: "应用标注", exact: true }).scrollIntoViewIfNeeded();
|
||||
for (const name of ["数值", "设施 ID"]) {
|
||||
const control = dev.getByRole("checkbox", { name, exact: true });
|
||||
await expect(control).toHaveCSS("width", "20px");
|
||||
expect(await control.evaluate((input) => {
|
||||
const box = input.getBoundingClientRect();
|
||||
const mark = input.parentElement!.querySelector("svg")!.getBoundingClientRect();
|
||||
return [mark.x + mark.width / 2 - box.x - box.width / 2,
|
||||
mark.y + mark.height / 2 - box.y - box.height / 2];
|
||||
})).toEqual([0, 0]);
|
||||
}
|
||||
expect(await dev.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await expect(dev.getByRole("button", { name: "重置地图调试效果" })).toBeVisible();
|
||||
await testInfo.attach("dev-panel-narrow", { body: await page.screenshot({ path: testInfo.outputPath("dev-panel-narrow.png") }), contentType: "image/png" });
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
||||
await expect(page.getByLabel("调查实时展示")).toBeVisible();
|
||||
await emit({ action: "highlight_features", layer: "scada", feature_ids: ["P1", "P2"], effect: "pulse" });
|
||||
const highlightedCount = () => page.evaluate(async () => {
|
||||
const map = (window as unknown as { __waterNetworkMap?: {
|
||||
getSource: (id: string) => { getData: () => Promise<{ features: unknown[] }> } | undefined
|
||||
} }).__waterNetworkMap;
|
||||
return (await map?.getSource("investigation-presentation-highlights")?.getData())?.features.length ?? 0;
|
||||
});
|
||||
await expect.poll(highlightedCount).toBe(2);
|
||||
await emit({ action: "clear_highlights" });
|
||||
await expect.poll(highlightedCount).toBe(0);
|
||||
const chart = { chart_id: "pressure", chart: { title: "合成压力趋势", chart_type: "line",
|
||||
x_data: ["08:00", "08:05"], series: [{ name: "压力", data: [0.3, 0.28] }] }, evidence_refs: ["pressure-1"] };
|
||||
await emit({ action: "open_chart", ...chart }, 9);
|
||||
await expect(page.locator('[data-investigation-chart="pressure"]')).toHaveCount(0);
|
||||
await emit({ action: "open_chart", ...chart });
|
||||
await expect(page.locator('[data-investigation-chart="pressure"]')).toBeVisible();
|
||||
await emit({ action: "close_chart", chart_id: "pressure" });
|
||||
await expect(page.locator('[data-investigation-chart="pressure"]')).toHaveCount(0);
|
||||
const data = { type: "FeatureCollection", features: [
|
||||
{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [121.48, 31.10] } },
|
||||
{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [121.481, 31.101] } }
|
||||
] };
|
||||
await emit({ action: "set_overlay", overlay_id: "complaints", title: "合成投诉点热力", mode: "heatmap", evidence_id: "spatial-1", data });
|
||||
const layerType = () => page.evaluate(() => {
|
||||
const map = (window as unknown as { __waterNetworkMap?: { getStyle: () => { layers?: { id: string; type: string }[] } } }).__waterNetworkMap;
|
||||
return map?.getStyle().layers?.find((layer) => layer.id === "investigation-presentation-overlay-heatmap")?.type;
|
||||
});
|
||||
await expect.poll(layerType).toBe("heatmap");
|
||||
await expect(page.getByLabel("调查实时展示")).toContainText("合成投诉点热力");
|
||||
await page.mouse.move(300, 180);
|
||||
await page.mouse.wheel(0, 120);
|
||||
await expect(page.getByLabel("调查实时展示")).toContainText("镜头跟随已暂停");
|
||||
await page.getByRole("button", { name: "恢复跟随", exact: true }).click();
|
||||
await expect(page.getByLabel("调查实时展示")).not.toContainText("镜头跟随已暂停");
|
||||
await testInfo.attach("live-investigation-map", { body: await page.screenshot({ path: testInfo.outputPath("live-investigation-map.png") }), contentType: "image/png" });
|
||||
await page.setViewportSize({ width: 375, height: 720 });
|
||||
await expect(page.getByLabel("调查实时展示")).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
|
||||
await testInfo.attach("live-investigation-mobile", { body: await page.screenshot({ path: testInfo.outputPath("live-investigation-mobile.png") }), contentType: "image/png" });
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
||||
await follow.uncheck();
|
||||
await expect(follow).not.toBeChecked();
|
||||
await follow.focus();
|
||||
await expect(follow).toBeFocused();
|
||||
await testInfo.attach("dev-panel-checkbox-off", { body: await dev.screenshot({ path: testInfo.outputPath("dev-panel-checkbox-off.png") }), contentType: "image/png" });
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const map = (window as unknown as { __waterNetworkMap?: { getLayoutProperty: (id: string, key: string) => unknown } }).__waterNetworkMap;
|
||||
return map?.getLayoutProperty("investigation-presentation-overlay-heatmap", "visibility");
|
||||
})).toBe("none");
|
||||
await expect(page.locator('[data-investigation-chart]')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { responsesFixture } from "./support/responses-fixture";
|
||||
import { expect, test, type Locator } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
|
||||
// Product display stays UTC+8 even when the browser itself is configured for UTC.
|
||||
test.use({ timezoneId: "UTC" });
|
||||
|
||||
const taskId = "22222222-2222-4222-8222-222222222222";
|
||||
const runId = "33333333-3333-4333-8333-333333333333";
|
||||
const projectId = "11111111-1111-4111-8111-111111111111";
|
||||
const now = "2026-10-13T08:00:00Z";
|
||||
const run = { run_id: runId, task_id: taskId, generation: 2, execution_request_id: runId, status: "succeeded", revision: 1,
|
||||
agent_session_id: "investigation-run", lease_expires_at: null, heartbeat_at: null, started_at: now, finished_at: now,
|
||||
failure_code: null, failure_detail: null, dispatch_attempts: 1, next_dispatch_at: now, created_at: now, updated_at: now };
|
||||
const task = { project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "water_quality_complaint",
|
||||
lifecycle_status: "completed", active_run: null, latest_run: run, status: "completed", priority: "normal", revision: 1,
|
||||
execution_generation: 2, execution_request_id: runId, title: "居民异味投诉调查", summary: "现场记录待复核",
|
||||
source: { kind: "operator", external_id: null, external_revision: null, event_kind: "water_quality_complaint" },
|
||||
trigger: { kind: "manual", occurred_at: now, detail: "居民异味投诉" }, scheduled_for: null, agent_session_id: "investigation-run",
|
||||
agent_lease_expires_at: null, current_plan_revision: null, current_report_revision: null, created_at: now, updated_at: now,
|
||||
latest_plan: null, latest_report: {
|
||||
revision: 3, execution_generation: 1, source_session_id: "investigation-run", published_at: now, published_by: "agent",
|
||||
report: { title: "居民异味投诉调查报告", current_conclusion: "现场记录待复核", conclusion_boundary: "仅覆盖已有监测记录",
|
||||
evidence_refs: ["monitor-1"], recommendations: [], pending_items: [], stage: "final", outcome: "concluded",
|
||||
stop_reason: null, data_gaps: [], diagram: null }
|
||||
}, latest_progress: null,
|
||||
evidence: [{ evidence_id: "monitor-1", kind: "system_data", summary: "上游监测数据质量已核对", source_label: "SCADA 水质监测",
|
||||
source_ref: "WQ-01", observed_at: now, recorded_at: now, recorded_by: "agent", has_snapshot: false }],
|
||||
actions: [], access_requests: [], work_order_snapshot: null,
|
||||
permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false,
|
||||
can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false, can_execute_commands: false, can_decide_access: false } };
|
||||
|
||||
test("证据提问在预览、展开及更多依据中使用统一实体按钮", async ({ page }, testInfo) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.route("http://127.0.0.1:8787/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.setContent('<html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="report" style="max-width:640px;margin:auto;padding:16px"></main></body></html>');
|
||||
await page.evaluate(async ({ fixture, cachePath }) => {
|
||||
const reactModule = await import(`${cachePath}/react.js`);
|
||||
const React = reactModule.default ?? reactModule;
|
||||
const clientModule = await import(`${cachePath}/react-dom_client.js`);
|
||||
const { createRoot } = clientModule.default ?? clientModule;
|
||||
const { InvestigationTaskReport } = await import("/src/features/workbench/components/investigation-task-report.tsx");
|
||||
const evidence = Array.from({ length: 4 }, (_, index) => ({ ...fixture.evidence[0], evidence_id: `monitor-${index + 1}` }));
|
||||
createRoot(document.getElementById("report")).render(React.createElement(InvestigationTaskReport, {
|
||||
task: { ...fixture, evidence, latest_report: { ...fixture.latest_report, report: { ...fixture.latest_report.report, evidence_refs: evidence.map((item) => item.evidence_id) } } },
|
||||
onContinue: () => undefined, renderAction: () => null,
|
||||
renderEvidence: (item: { summary: string }) => React.createElement("p", null, item.summary)
|
||||
}));
|
||||
}, { fixture: task, cachePath: `/node_modules/.vite-playwright-${process.env.PLAYWRIGHT_PORT || "5191"}/deps` });
|
||||
const preview = page.getByRole("region", { name: "主要依据", exact: true });
|
||||
await expect(page.getByText("任务更新于 2026/10/13 16:00:00", { exact: true })).toBeVisible();
|
||||
await expect(preview).toContainText("2026/10/13 16:00:00");
|
||||
for (const width of [1440, 320]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const buttons = preview.getByRole("button", { name: "围绕此证据提问", exact: true });
|
||||
await expect(buttons).toHaveCount(3);
|
||||
for (const button of await buttons.all()) {
|
||||
await expect(button).toHaveAttribute("data-slot", "button");
|
||||
await expect(button).toHaveCSS("border-top-width", "1px");
|
||||
await expect(button).toHaveCSS("height", "32px");
|
||||
}
|
||||
await preview.screenshot({ path: testInfo.outputPath(`evidence-buttons-${width}.png`) });
|
||||
await preview.getByRole("button", { name: "展开主要依据" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByText("更多引用依据 · 1 条", { exact: true }).click();
|
||||
const expandedButtons = dialog.getByRole("button", { name: "围绕此证据提问", exact: true });
|
||||
await expect(expandedButtons).toHaveCount(4);
|
||||
for (const button of await expandedButtons.all()) {
|
||||
await expect(button).toBeVisible();
|
||||
await expect(button).toHaveAttribute("data-slot", "button");
|
||||
await expect(button).toHaveCSS("border-top-width", "1px");
|
||||
expect(await button.evaluate((element) => element.getBoundingClientRect().right <= innerWidth)).toBe(true);
|
||||
}
|
||||
await dialog.screenshot({ path: testInfo.outputPath(`evidence-buttons-expanded-${width}.png`) });
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
async function expectCenteredReference(panel: Locator) {
|
||||
await expect(panel).toHaveAttribute("data-agent-starting", "true");
|
||||
await expect(panel.getByRole("group", { name: "对话引用" })).toBeVisible();
|
||||
await expect(panel.getByRole("group", { name: "推荐问题" })).toHaveCount(0);
|
||||
await expect(panel.locator("form")).toHaveCount(1);
|
||||
await expect.poll(async () => {
|
||||
const panelBox = (await panel.boundingBox())!;
|
||||
const header = (await panel.locator("header").boundingBox())!;
|
||||
const composer = (await panel.locator("form").boundingBox())!;
|
||||
// The mobile header extends above the panel to cover the drawer handle.
|
||||
const center = (header.y + header.height + panelBox.y + panelBox.height) / 2;
|
||||
return Math.abs(composer.y + composer.height / 2 - center);
|
||||
}).toBeLessThan(2);
|
||||
}
|
||||
|
||||
test("单一协作入口展示任务引用,保留草稿且仅手动发送,并适配窄屏", async ({ page }, testInfo) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page, { sessions: [{ session_id: "ordinary-main", title: "供水核查主聊天", purpose: "ordinary_chat", created_at: now, updated_at: now }] });
|
||||
let submitted = 0;
|
||||
let inboxReads = 0;
|
||||
let incoming = false;
|
||||
page.on("request", (request) => {
|
||||
if (request.method() === "POST" && (new URL(request.url()).pathname.endsWith("/responses") || new URL(request.url()).pathname.endsWith("/investigations"))) submitted += 1;
|
||||
});
|
||||
const { latest_plan, latest_report, latest_progress, evidence, actions, access_requests, work_order_snapshot, permissions, ...summary } = task;
|
||||
void latest_plan; void latest_report; void latest_progress; void evidence; void actions; void access_requests; void work_order_snapshot; void permissions;
|
||||
await page.route("**/workbench/sync-snapshot", (route) => route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: {
|
||||
schema: "workbench-sync-snapshot@1", event_cursor: 0, runtime: { availability: "not_configured", source: "none", scenario_availability: "not_configured",
|
||||
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: [summary], notifications: [], generated_at: now
|
||||
} }));
|
||||
await page.route("**/operational-tasks/**", (route) => route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: task }));
|
||||
await page.route("**/agent-messages?**", (route) => {
|
||||
inboxReads += 1;
|
||||
return route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { items: incoming ? [{ message_id: "msg1", task_id: taskId, run_id: runId,
|
||||
source_session_id: "investigation-run", target_session_id: "ordinary-main", direction: "to_chat", text: "已核对上游监测,请补充现场采样记录。",
|
||||
status: "pending", in_reply_to: null, created_at: now, failure_detail: null }] : [], next_cursor: null } });
|
||||
});
|
||||
await page.route("**/agent-messages/stream", (route) => route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, contentType: "text/event-stream", body: 'event: agent_message\ndata: {"session_id":"ordinary-main"}\n\n' }));
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const agent = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await expect(agent.getByRole("heading", { name: "我已就绪,请描述任务" })).toHaveCount(0);
|
||||
await agent.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
await agent.getByRole("button", { name: /供水核查主聊天/ }).click();
|
||||
const prompt = agent.locator("textarea");
|
||||
await prompt.fill("保留的现场补充草稿");
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
const feed = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
const expand = feed.getByRole("button", { name: "展开工况任务" });
|
||||
if (await expand.isVisible()) await expand.click();
|
||||
await feed.getByRole("button", { name: /调查任务/ }).click();
|
||||
await feed.getByText("居民异味投诉调查", { exact: true }).first().click();
|
||||
await expect(feed.getByText("16:00", { exact: true })).toBeVisible();
|
||||
await expect(feed.getByRole("region", { name: "调查任务标识" })).toHaveCount(0);
|
||||
await expect(feed.getByRole("button", { name: "在主聊天中编辑" })).toHaveCount(0);
|
||||
await expect(feed).not.toContainText(taskId);
|
||||
await expect(feed).not.toContainText(runId);
|
||||
await expect(feed.getByRole("button", { name: "Agent 协作", exact: true })).toHaveCount(1);
|
||||
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();
|
||||
await expect(agent.getByText("任务协作问答", { exact: true }).first()).toBeVisible();
|
||||
const reference = agent.getByRole("group", { name: "对话引用" });
|
||||
await expect(reference).toContainText("居民异味投诉调查");
|
||||
await expect(reference).toContainText("第 1 轮 · 报告第 3 版");
|
||||
await expect(reference).not.toContainText(/当前|第 2 轮|已结束|调查完成|正在调查/);
|
||||
await expect(prompt).toHaveAttribute("placeholder", "可询问调查进展、讨论报告或补充信息");
|
||||
await expect(prompt).toHaveValue("保留的现场补充草稿");
|
||||
await expect(prompt).toBeFocused();
|
||||
expect(submitted).toBe(0);
|
||||
await feed.getByRole("button", { name: "Agent 协作", exact: true }).click();
|
||||
await expect(prompt).toBeFocused();
|
||||
await expect(prompt).toHaveValue("保留的现场补充草稿");
|
||||
await feed.getByLabel("主要依据").getByRole("button", { name: "围绕此证据提问" }).click();
|
||||
await expect(prompt).toHaveValue("保留的现场补充草稿\n\n请解释“上游监测数据质量已核对”这条证据的范围、来源和判断影响。");
|
||||
await expect(prompt).toBeFocused();
|
||||
await expect(reference).toContainText("已引用证据");
|
||||
await expectCenteredReference(agent);
|
||||
expect(submitted).toBe(0);
|
||||
await agent.screenshot({ path: testInfo.outputPath("investigation-reference-desktop.png") });
|
||||
await feed.getByRole("button", { name: /定时任务/ }).click();
|
||||
await page.setViewportSize({ width: 375, height: 900 });
|
||||
await page.getByRole("button", { name: "打开 Agent 面板", exact: true }).click();
|
||||
const mobileAgent = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await expect(mobileAgent.getByRole("heading", { name: "我已就绪,请描述任务" })).toHaveCount(0);
|
||||
await expect(mobileAgent.getByRole("group", { name: "对话引用" })).toContainText("居民异味投诉调查");
|
||||
await expectCenteredReference(mobileAgent);
|
||||
await mobileAgent.screenshot({ path: testInfo.outputPath("investigation-reference-narrow.png") });
|
||||
await page.setViewportSize({ width: 320, height: 900 });
|
||||
await expectCenteredReference(mobileAgent);
|
||||
await mobileAgent.screenshot({ path: testInfo.outputPath("investigation-reference-320.png") });
|
||||
expect(await mobileAgent.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe(true);
|
||||
await page.setViewportSize({ width: 375, height: 900 });
|
||||
let sentContext: unknown;
|
||||
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().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" }]) });
|
||||
});
|
||||
expect(submitted).toBe(0);
|
||||
await mobileAgent.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
await expect.poll(() => sentContext).toEqual({ report_revision: 3, evidence_id: "monitor-1" });
|
||||
expect(submitted).toBe(1);
|
||||
await expect(mobileAgent).toHaveAttribute("data-agent-starting", "false");
|
||||
await expect(mobileAgent.locator(".agent-panel-user-message")).toContainText("保留的现场补充草稿");
|
||||
await expect.poll(async () => {
|
||||
const panelBox = (await mobileAgent.boundingBox())!;
|
||||
const composer = (await mobileAgent.locator("form").boundingBox())!;
|
||||
return panelBox.y + panelBox.height - composer.y - composer.height;
|
||||
}).toBeLessThan(32);
|
||||
await expect(mobileAgent.getByRole("button", { name: "停止 Agent 分析" })).toHaveCount(0);
|
||||
await mobileAgent.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
await mobileAgent.getByRole("button", { name: /供水核查主聊天/ }).click();
|
||||
await expect(mobileAgent.getByRole("group", { name: "对话引用" })).toHaveCount(0);
|
||||
incoming = true;
|
||||
await expect(mobileAgent.getByText("已核对上游监测,请补充现场采样记录。")).toBeVisible();
|
||||
await expect(mobileAgent.getByText("待接收", { exact: true })).toBeVisible();
|
||||
await expect(mobileAgent.getByRole("button", { name: "停止 Agent 分析" })).toHaveCount(0);
|
||||
expect(inboxReads).toBeGreaterThan(1);
|
||||
await mobileAgent.screenshot({ path: testInfo.outputPath("investigation-inbox-narrow.png") });
|
||||
expect(await mobileAgent.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe(true);
|
||||
expect(submitted).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
|
||||
test("调查图加载与尺寸变化保持阅读位置,刷新后仍可缩放和查看节点", async ({ page }, testInfo) => {
|
||||
await page.route("http://127.0.0.1:8787/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
|
||||
await mockAgentApi(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.setContent('<!doctype html><html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="reader" style="max-width:640px;margin:auto;padding:16px;height:100vh;overflow:auto"></main></body></html>');
|
||||
await page.evaluate(async () => {
|
||||
const entry = await (await fetch("/src/main.tsx")).text();
|
||||
const reactUrl = entry.match(/from "([^"\n]*\/react\.js[^"\n]*)"/)?.[1];
|
||||
const clientUrl = entry.match(/from "([^"\n]*\/react-dom_client\.js[^"\n]*)"/)?.[1];
|
||||
if (!reactUrl || !clientUrl) throw new Error("Vite React module paths unavailable");
|
||||
const reactModule = await import(reactUrl);
|
||||
const React = reactModule.default ?? reactModule;
|
||||
const clientModule = await import(clientUrl);
|
||||
const { createRoot } = clientModule.default ?? clientModule;
|
||||
const { InvestigationDiagramView } = await import("/src/features/workbench/components/investigation-diagram-view.tsx");
|
||||
const { investigationDiagramPlugins } = await import("/src/features/workbench/utils/investigation-diagram-style.ts");
|
||||
const renderer = investigationDiagramPlugins.mermaid.getMermaid.bind(investigationDiagramPlugins.mermaid);
|
||||
let release: (() => void) | undefined;
|
||||
let renderPending = false;
|
||||
investigationDiagramPlugins.mermaid.getMermaid = (config: unknown) => {
|
||||
const instance = renderer(config);
|
||||
return { ...instance, async render(id: string, source: string) {
|
||||
renderPending = true;
|
||||
await new Promise<void>((resolve) => { release = resolve; });
|
||||
renderPending = false;
|
||||
return instance.render(id, source);
|
||||
} };
|
||||
};
|
||||
const root = createRoot(document.getElementById("reader"));
|
||||
const documentHeights: number[] = [];
|
||||
new MutationObserver(() => {
|
||||
documentHeights.push(document.documentElement.scrollHeight);
|
||||
}).observe(document.body, { childList: true, subtree: true, attributes: true });
|
||||
let source = "flowchart TD\nA[读取工单] --> B[核对监测]\nB --> C[现场采样]\nC --> D[关联证据]\nD --> E[发布报告]";
|
||||
const render = () => root.render(React.createElement(React.Fragment, null,
|
||||
React.createElement(InvestigationDiagramView, {
|
||||
diagram: { title: "调查刷新检查", mermaid: source,
|
||||
node_refs: [{ node_id: "B", label: "核对监测", evidence_refs: ["e1"], action_refs: [] }] },
|
||||
renderDetails: () => React.createElement("p", null, "监测记录的引用依据")
|
||||
}),
|
||||
React.createElement("section", { id: "reading-anchor", style: { height: 1200 } }, "当前正在阅读的证据")
|
||||
));
|
||||
Object.assign(window, { investigationFixture: {
|
||||
release: () => release?.(), pending: () => renderPending,
|
||||
update: (next: string) => { source = next; render(); }
|
||||
}, documentHeights });
|
||||
render();
|
||||
});
|
||||
const fixture = (action: "release" | "pending") => page.evaluate((key) => {
|
||||
const api = (window as unknown as { investigationFixture: { release: () => void; pending: () => boolean } }).investigationFixture;
|
||||
return api[key]();
|
||||
}, action);
|
||||
const diagram = page.getByRole("region", { name: "调查脉络", exact: true });
|
||||
const viewport = diagram.locator(".investigation-diagram");
|
||||
await expect.poll(() => fixture("pending")).toBe(true);
|
||||
const loadingHeight = (await viewport.boundingBox())!.height;
|
||||
await fixture("release");
|
||||
await expect(diagram.locator("svg .node")).toHaveCount(5);
|
||||
const renderHeights = await page.evaluate(() => (window as unknown as { documentHeights: number[] }).documentHeights);
|
||||
expect(Math.max(...renderHeights) - Math.min(...renderHeights)).toBeLessThanOrEqual(1);
|
||||
const loadedHeight = (await viewport.boundingBox())!.height;
|
||||
testInfo.annotations.push({ type: "geometry", description: `loading=${loadingHeight}, loaded=${loadedHeight}` });
|
||||
expect(loadedHeight).toBeGreaterThan(loadingHeight);
|
||||
// Read below the graph: native scroll anchoring must preserve that content,
|
||||
// while the graph is free to take its real (different) height.
|
||||
await page.locator("#reading-anchor").evaluate((element) => element.scrollIntoView({ block: "start" }));
|
||||
const anchorTop = await page.locator("#reading-anchor").evaluate((element) => element.getBoundingClientRect().top);
|
||||
await page.evaluate(() => (window as unknown as { investigationFixture: { update: (source: string) => void } }).investigationFixture
|
||||
.update("flowchart LR\nA[读取工单] --> B[核对监测]"));
|
||||
await expect.poll(() => fixture("pending")).toBe(true);
|
||||
await expect(diagram.locator("svg .node")).toHaveCount(5);
|
||||
await fixture("release");
|
||||
await expect(diagram.locator("svg .node")).toHaveCount(2);
|
||||
expect((await viewport.boundingBox())!.height).toBeLessThan(loadedHeight - 100);
|
||||
expect(Math.abs(await page.locator("#reading-anchor").evaluate((element) => element.getBoundingClientRect().top) - anchorTop)).toBeLessThanOrEqual(1);
|
||||
await page.locator("#reader").evaluate((element) => { element.scrollTop = 0; });
|
||||
await diagram.locator(".node").filter({ hasText: "核对监测" }).click();
|
||||
await expect(diagram.getByLabel("节点详情 核对监测")).toContainText("监测记录的引用依据");
|
||||
await diagram.getByRole("button", { name: "Zoom in", exact: true }).click();
|
||||
await expect(diagram.locator('[role="application"]')).toHaveCSS("transform", "matrix(1.1, 0, 0, 1.1, 0, 0)");
|
||||
await diagram.getByRole("button", { name: "Reset zoom and pan", exact: true }).click();
|
||||
const checkCollapse = async (keyboard: boolean) => {
|
||||
const before = await page.locator("#reading-anchor").evaluate((element) => element.getBoundingClientRect().top);
|
||||
await diagram.getByRole("button", { name: "放大查看", exact: true }).click();
|
||||
const detail = page.getByRole("dialog", { name: "调查刷新检查", exact: true });
|
||||
await expect(detail.locator("svg .node")).toHaveCount(2);
|
||||
await page.evaluate(() => {
|
||||
const reader = document.getElementById("reader")!;
|
||||
const anchor = document.getElementById("reading-anchor")!;
|
||||
const frames: { top: number; scroll: number; height: number }[] = [];
|
||||
Object.assign(window, { collapseFrames: frames, collapseDone: false });
|
||||
const start = performance.now();
|
||||
const sample = () => {
|
||||
frames.push({ top: anchor.getBoundingClientRect().top, scroll: reader.scrollTop, height: reader.scrollHeight });
|
||||
if (performance.now() - start < 800) requestAnimationFrame(sample);
|
||||
else Object.assign(window, { collapseDone: true });
|
||||
};
|
||||
sample();
|
||||
});
|
||||
if (keyboard) await page.keyboard.press("Escape");
|
||||
else await detail.getByRole("button", { name: "关闭详情", exact: true }).click();
|
||||
await expect(detail).toHaveCount(0);
|
||||
await page.waitForFunction(() => (window as unknown as { collapseDone: boolean }).collapseDone);
|
||||
const frames = await page.evaluate(() => (window as unknown as { collapseFrames: { top: number; scroll: number; height: number }[] }).collapseFrames);
|
||||
const range = (key: "top" | "scroll" | "height") => Math.max(...frames.map((frame) => frame[key])) - Math.min(...frames.map((frame) => frame[key]));
|
||||
testInfo.annotations.push({ type: "collapse-geometry", description: `keyboard=${keyboard}, anchorShift=${range("top")}, scrollShift=${range("scroll")}, heightShift=${range("height")}` });
|
||||
expect(Math.abs(frames[0].top - before)).toBeLessThanOrEqual(1);
|
||||
expect(range("top")).toBeLessThanOrEqual(1);
|
||||
expect(range("scroll")).toBeLessThanOrEqual(1);
|
||||
expect(range("height")).toBeLessThanOrEqual(1);
|
||||
expect(await diagram.locator(":scope > .flow-root").evaluate((element) => (element as HTMLElement).style.height)).toBe("");
|
||||
};
|
||||
await checkCollapse(false);
|
||||
const allRenderHeights = await page.evaluate(() => (window as unknown as { documentHeights: number[] }).documentHeights);
|
||||
expect(Math.max(...allRenderHeights) - Math.min(...allRenderHeights)).toBeLessThanOrEqual(1);
|
||||
testInfo.annotations.push({ type: "page-height", description: `min=${Math.min(...allRenderHeights)}, max=${Math.max(...allRenderHeights)}` });
|
||||
await diagram.screenshot({ path: testInfo.outputPath("diagram-stable-desktop.png") });
|
||||
await page.setViewportSize({ width: 375, height: 800 });
|
||||
await expect(diagram.locator("svg .node")).toHaveCount(2);
|
||||
await expect(diagram.getByRole("button", { name: "放大查看", exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
|
||||
await diagram.screenshot({ path: testInfo.outputPath("diagram-stable-mobile.png") });
|
||||
await checkCollapse(true);
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await expect(diagram.locator('[role="img"] > svg')).toHaveCSS("animation-name", "none");
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route("http://127.0.0.1:8787/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
|
||||
await mockAgentApi(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.setContent('<!doctype html><html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="reader" class="scheduled-feed-scroll" style="max-width:680px;margin:auto;padding:16px;height:100vh;overflow:auto"></main></body></html>');
|
||||
await page.evaluate(async () => {
|
||||
const entry = await (await fetch("/src/main.tsx")).text();
|
||||
const reactUrl = entry.match(/from "([^"\n]*\/react\.js[^"\n]*)"/)?.[1];
|
||||
const clientUrl = entry.match(/from "([^"\n]*\/react-dom_client\.js[^"\n]*)"/)?.[1];
|
||||
if (!reactUrl || !clientUrl) throw new Error("Vite React module paths unavailable");
|
||||
const reactModule = await import(reactUrl);
|
||||
const React = reactModule.default ?? reactModule;
|
||||
const clientModule = await import(clientUrl);
|
||||
const { createRoot } = clientModule.default ?? clientModule;
|
||||
const { InvestigationTaskReport } = await import("/src/features/workbench/components/investigation-task-report.tsx");
|
||||
const now = "2026-10-13T08:00:00Z";
|
||||
const report = { revision: 1, execution_generation: 1, published_at: now, report: {
|
||||
title: "居民异味投诉调查报告", current_conclusion: "上游监测未见明显异常,仍需现场核查。",
|
||||
conclusion_boundary: "仅覆盖现有上游监测,不代表末梢水质。", stage: "interim", outcome: "concluded",
|
||||
recommendations: ["建议安排现场采样。"], pending_items: ["核实楼栋二次供水记录。"], evidence_refs: ["e1"],
|
||||
data_gaps: [{ gap_id: "g1", missing_data: "缺少末梢采样记录", impact: "尚不能确定异常来源", scope: "投诉时段",
|
||||
required_data: "末梢采样结果", evidence_refs: [], blocking: false }],
|
||||
diagram: { title: "现场核查路线", mermaid: "flowchart LR\nA[核对监测] --> B[现场采样]", node_refs: [] }
|
||||
} };
|
||||
let task = { task_id: "task-cards", title: "居民异味投诉调查", summary: "上游监测已核对,等待现场资料",
|
||||
execution_generation: 1, lifecycle_status: "open", status: "running", updated_at: now,
|
||||
latest_report: report, latest_progress: null, latest_plan: null,
|
||||
actions: [{ action_id: "a1", kind: "create_work_order", payload: { title: "安排现场采样" }, reason: "补齐现场资料",
|
||||
approval_status: "pending", execution_status: "not_submitted", proposed_at: now }],
|
||||
evidence: [{ evidence_id: "e1", kind: "system_data", summary: "上游监测数据已核对", source_label: "SCADA 水质监测",
|
||||
observed_at: now, recorded_at: now }] };
|
||||
const root = createRoot(document.getElementById("reader"));
|
||||
const render = () => root.render(React.createElement(InvestigationTaskReport, {
|
||||
task, onContinue: () => undefined,
|
||||
renderAction: () => React.createElement("p", null, "人工审核后才会创建工单"),
|
||||
renderEvidence: () => React.createElement("p", null, "原始监测记录与来源详情"),
|
||||
onLoadReports: async () => ({ items: [report], next_offset: null })
|
||||
}));
|
||||
Object.assign(window, { updateInvestigationReport: () => {
|
||||
task = { ...task, latest_report: { ...report, revision: task.latest_report.revision + 1,
|
||||
report: { ...report.report, current_conclusion: "新增现场记录已接收,等待复核。" } } };
|
||||
render();
|
||||
}, showHistoricalUtcReport: () => {
|
||||
task = { ...task, latest_report: { ...report, revision: 2, report: { ...report.report,
|
||||
current_conclusion: "上报时间2026-10-08T04:00:00Z,查询04:00–05:00Z。",
|
||||
conclusion_boundary: "覆盖2026-12-31 15:30–17:00Z。",
|
||||
recommendations: ["复核2026-10-08T05:00:00Z的数据。"],
|
||||
data_gaps: [{ ...report.report.data_gaps[0], scope: "2026-10-08 04:00–05:00Z" }]
|
||||
} } };
|
||||
Object.assign(window, { storedReportConclusion: task.latest_report.report.current_conclusion });
|
||||
render();
|
||||
}, startNextInvestigationRound: () => {
|
||||
task = { ...task, execution_generation: 2, status: "running", lifecycle_status: "open" };
|
||||
Object.assign(task, {
|
||||
active_run: { run_id: "run-2", status: "running" },
|
||||
latest_progress: { revision: 1, run_id: "run-2", execution_generation: 2, published_at: now,
|
||||
progress: { summary: "正在复核本轮现场反馈", diagram: {
|
||||
title: "本轮调查进展图", mermaid: "flowchart LR\nC[现场反馈复核] --> D[复测记录]",
|
||||
node_refs: [{ node_id: "C", label: "现场反馈复核", status: "running", evidence_refs: [], action_refs: [] }]
|
||||
} }
|
||||
}
|
||||
});
|
||||
render();
|
||||
}, finishInvestigationRound: (clearProgressDiagram: boolean) => {
|
||||
task = { ...task, status: "completed", lifecycle_status: "completed", latest_report: {
|
||||
...report, revision: 2, execution_generation: 2, report: { ...report.report, stage: "final",
|
||||
current_conclusion: "本轮现场反馈已复核,形成新的调查结论。",
|
||||
diagram: { title: "本轮最终报告图", mermaid: "flowchart LR\nE[复核结果] --> F[恢复条件]", node_refs: [] }
|
||||
}
|
||||
} };
|
||||
Object.assign(task, { active_run: null });
|
||||
if (clearProgressDiagram) Object.assign(task, {
|
||||
latest_progress: { revision: 2, run_id: "run-2", execution_generation: 2, published_at: now,
|
||||
progress: { summary: "调查完成", diagram: null } }
|
||||
});
|
||||
render();
|
||||
} });
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
for (const clearProgressDiagram of [false, true]) {
|
||||
test(`新一轮跟随最新进展,最终报告保留自己的图(过程图${clearProgressDiagram ? "为空" : "仍存在"})`, async ({ page }, testInfo) => {
|
||||
const report = page.getByRole("region", { name: "任务报告", exact: true });
|
||||
await page.evaluate(() => (window as unknown as { startNextInvestigationRound: () => void }).startNextInvestigationRound());
|
||||
await expect(report).toContainText("上一轮报告,本轮分析中");
|
||||
await expect(page.getByRole("region", { name: "当前调查进展", exact: true })).toContainText("正在复核本轮现场反馈");
|
||||
const diagram = page.getByRole("region", { name: "调查脉络", exact: true });
|
||||
await expect(diagram.locator(".investigation-diagram svg:has(g.node)")).toContainText("现场反馈复核");
|
||||
await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(1);
|
||||
await page.evaluate((clear) => (window as unknown as { finishInvestigationRound: (value: boolean) => void }).finishInvestigationRound(clear), clearProgressDiagram);
|
||||
await expect(report).toContainText("本轮现场反馈已复核,形成新的调查结论。");
|
||||
await expect(diagram.locator(".investigation-diagram svg:has(g.node)")).toContainText("恢复条件");
|
||||
await expect(diagram.locator(".investigation-diagram svg:has(g.node)")).not.toContainText("现场反馈复核");
|
||||
await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(0);
|
||||
await page.getByRole("article", { name: "调查任务报告" }).evaluate(async (element) => {
|
||||
await Promise.allSettled(element.getAnimations({ subtree: true })
|
||||
.filter((animation) => Number.isFinite(Number(animation.effect?.getComputedTiming().endTime)))
|
||||
.map((animation) => animation.finished));
|
||||
});
|
||||
await testInfo.attach("final-report-diagram", { body: await diagram.screenshot({ path: testInfo.outputPath("final-report-diagram.png"), animations: "disabled" }), contentType: "image/png" });
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("报告正文时间展示", () => {
|
||||
test.use({ timezoneId: "UTC" });
|
||||
|
||||
test("历史 UTC 正文和查询范围按 UTC+8 展示并保留原始报告", async ({ page }) => {
|
||||
await page.evaluate(() => (window as unknown as { showHistoricalUtcReport: () => void }).showHistoricalUtcReport());
|
||||
const report = page.getByRole("region", { name: "任务报告", exact: true });
|
||||
await expect(report).toContainText("上报时间2026/10/8 12:00:00(UTC+8),查询12:00–13:00(UTC+8)。");
|
||||
await expect(report).toContainText("覆盖2026/12/31 23:30–2027/1/1 01:00(UTC+8)。");
|
||||
await expect(report).not.toContainText("04:00:00Z");
|
||||
await page.getByRole("button", { name: "展开当前结果", exact: true }).click();
|
||||
const detail = page.getByRole("dialog", { name: "当前结果", exact: true });
|
||||
await expect(detail).toContainText("复核2026/10/8 13:00:00(UTC+8)的数据。");
|
||||
expect(await page.evaluate(() => (window as unknown as { storedReportConclusion: string }).storedReportConclusion))
|
||||
.toBe("上报时间2026-10-08T04:00:00Z,查询04:00–05:00Z。");
|
||||
});
|
||||
});
|
||||
|
||||
test("报告摘要与有界详情分层,保留人工事项、阅读版本和返回位置", async ({ page }, testInfo) => {
|
||||
const progress = page.getByRole("region", { name: "当前调查进展", exact: true });
|
||||
await expect(progress).toContainText("尚未发布本轮调查进展。");
|
||||
await expect(progress.getByRole("button")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "展开任务时间线", exact: true }).click();
|
||||
const timeline = page.getByRole("dialog", { name: "任务时间线", exact: true });
|
||||
await expect(timeline.getByLabel("任务时间线记录").locator("li")).toHaveCount(3);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(timeline).toHaveCount(0);
|
||||
await expect(page.getByLabel("当前需处理", { exact: true })).toContainText("安排现场采样");
|
||||
await expect(page.getByLabel("数据缺口", { exact: true })).toContainText("缺少末梢采样记录");
|
||||
await expect(page.getByText("建议安排现场采样。", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("原始监测记录与来源详情", { exact: true })).toHaveCount(0);
|
||||
await expect(page.locator(".investigation-diagram svg .node")).toHaveCount(2);
|
||||
await page.locator("#reader").evaluate((element) => { element.scrollTop = 0; });
|
||||
await page.locator("#reader").screenshot({ path: testInfo.outputPath("report-cards-desktop.png") });
|
||||
const trigger = page.getByRole("button", { name: "展开当前结果", exact: true });
|
||||
await trigger.click();
|
||||
const detail = page.getByRole("dialog", { name: "当前结果", exact: true });
|
||||
await expect(detail.getByText("建议安排现场采样。", { exact: true })).toBeVisible();
|
||||
await expect.poll(async () => Math.round((await detail.boundingBox())!.width)).toBe(704);
|
||||
await detail.screenshot({ path: testInfo.outputPath("report-expanded-desktop.png") });
|
||||
await page.evaluate(() => (window as unknown as { updateInvestigationReport: () => void }).updateInvestigationReport());
|
||||
await expect(detail).toContainText("上游监测未见明显异常,仍需现场核查。");
|
||||
await expect(detail).not.toContainText("新增现场记录已接收");
|
||||
await expect(detail.getByRole("status")).toContainText("已有更新");
|
||||
await detail.getByRole("button", { name: "查看最新内容" }).click();
|
||||
await expect(detail).toContainText("新增现场记录已接收");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(detail).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
const historyButton = page.getByRole("button", { name: "展开报告版本" });
|
||||
await historyButton.click();
|
||||
await page.getByRole("dialog", { name: "报告版本" }).getByRole("button", { name: /报告第 1 版/ }).click();
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(page.getByRole("region", { name: "任务报告" })).toContainText("上游监测未见明显异常");
|
||||
await expect(page.getByRole("status")).toContainText("正在阅读历史报告");
|
||||
await page.getByRole("button", { name: "返回实时内容" }).click();
|
||||
await page.setViewportSize({ width: 375, height: 800 });
|
||||
await page.getByRole("button", { name: "展开主要依据" }).click();
|
||||
const evidence = page.getByRole("dialog", { name: "主要依据", exact: true });
|
||||
await expect(evidence).toContainText("原始监测记录与来源详情");
|
||||
await expect.poll(async () => Math.round((await evidence.boundingBox())!.width)).toBe(343);
|
||||
expect((await evidence.boundingBox())!.height).toBeLessThanOrEqual(688);
|
||||
await evidence.screenshot({ path: testInfo.outputPath("report-expanded-narrow.png") });
|
||||
await evidence.getByRole("button", { name: "关闭详情" }).click();
|
||||
await expect(evidence).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
|
||||
});
|
||||
|
||||
test("数据刷新按整份报告错峰揭示,保留图表与滚动位置并支持减少动态效果", async ({ page }, testInfo) => {
|
||||
const report = page.getByRole("article", { name: "调查任务报告" });
|
||||
const diagram = page.getByRole("region", { name: "调查脉络", exact: true });
|
||||
await expect(diagram.locator("svg .node")).toHaveCount(2);
|
||||
const before = await report.evaluate((element) => {
|
||||
const graph = element.querySelector(".investigation-diagram svg");
|
||||
const reader = document.getElementById("reader")!;
|
||||
Object.assign(window, { originalReportGraph: graph });
|
||||
return { height: element.clientHeight, scrollHeight: reader.scrollHeight };
|
||||
});
|
||||
const update = () => page.evaluate(() => (window as unknown as { updateInvestigationReport: () => void }).updateInvestigationReport());
|
||||
await update();
|
||||
await expect.poll(() => report.evaluate((element) => element.getAnimations({ subtree: true })
|
||||
.filter((animation) => (animation.effect as KeyframeEffect).getKeyframes().some((frame) => "translate" in frame)).length)).toBeGreaterThan(3);
|
||||
const sequence = await report.evaluate((element) => {
|
||||
const animations = element.getAnimations({ subtree: true }).filter((animation) =>
|
||||
(animation.effect as KeyframeEffect).getKeyframes().some((frame) => "translate" in frame));
|
||||
// Hold the actual browser animation at a common time to inspect the stagger.
|
||||
element.getAnimations({ subtree: true }).forEach((animation) => { animation.pause(); animation.currentTime = 160; });
|
||||
return animations.map((animation) => ({
|
||||
label: ((animation.effect as KeyframeEffect).target as HTMLElement).getAttribute("aria-label"),
|
||||
delay: animation.effect!.getTiming().delay,
|
||||
frames: (animation.effect as KeyframeEffect).getKeyframes().map((frame) => frame.translate)
|
||||
}));
|
||||
});
|
||||
expect(sequence.map((item) => item.label)).toEqual(expect.arrayContaining(["任务报告", "当前调查进展", "调查脉络", "主要依据", "任务时间线"]));
|
||||
expect(sequence[0].delay).toBe(0);
|
||||
expect(sequence[1].delay).toBe(45);
|
||||
expect(sequence.every((item) => item.frames[0] === "0px 18px")).toBe(true);
|
||||
await page.screenshot({ path: testInfo.outputPath("report-refresh-during.png") });
|
||||
await report.evaluate((element) => element.getAnimations({ subtree: true }).forEach((animation) => animation.finish()));
|
||||
await expect(report.getByText("新增现场记录已接收,等待复核。", { exact: true })).toBeVisible();
|
||||
const after = await report.evaluate((element) => ({
|
||||
sameGraph: element.querySelector(".investigation-diagram svg") === (window as unknown as { originalReportGraph: Element }).originalReportGraph,
|
||||
height: element.clientHeight, scrollHeight: document.getElementById("reader")!.scrollHeight
|
||||
}));
|
||||
expect(after).toEqual({ ...before, sameGraph: true });
|
||||
await diagram.getByRole("button", { name: "Zoom in", exact: true }).click();
|
||||
await expect(diagram.locator('[role="application"]')).toHaveCSS("transform", "matrix(1.1, 0, 0, 1.1, 0, 0)");
|
||||
const scrollTop = await page.locator("#reader").evaluate((element) => element.scrollTop);
|
||||
await update();
|
||||
await expect(report).toContainText("报告第 3 版");
|
||||
expect(await page.locator("#reader").evaluate((element) => element.scrollTop)).toBe(scrollTop);
|
||||
await expect(diagram.locator('[role="application"]')).toHaveCSS("transform", "matrix(1.1, 0, 0, 1.1, 0, 0)");
|
||||
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await update();
|
||||
await expect(report).toContainText("报告第 4 版");
|
||||
expect(await report.evaluate((element) => element.getAnimations({ subtree: true }).length)).toBe(0);
|
||||
await page.setViewportSize({ width: 375, height: 800 });
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.locator("#reader").evaluate((element) => { element.scrollTop = 0; });
|
||||
await update();
|
||||
await expect(report).toContainText("报告第 5 版");
|
||||
// Pointer interaction must end the refresh before shared-layout expansion starts.
|
||||
await page.getByRole("button", { name: "展开当前结果", exact: true }).dispatchEvent("pointerdown");
|
||||
await page.getByRole("button", { name: "展开当前结果", exact: true }).click();
|
||||
const detail = page.getByRole("dialog", { name: "当前结果", exact: true });
|
||||
await expect(detail).toBeVisible();
|
||||
await expect.poll(async () => Math.round((await detail.boundingBox())!.width)).toBe(343);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
|
||||
await page.screenshot({ path: testInfo.outputPath("report-refresh-narrow-detail.png") });
|
||||
});
|
||||
@@ -23,8 +23,9 @@ test("Dev Panel opens as a floating desktop panel and closes with Escape", async
|
||||
expect(panelBox!.y + panelBox!.height).toBeLessThanOrEqual(884);
|
||||
await expect(page.getByRole("navigation", { name: "地图工具" })).toBeHidden();
|
||||
await expect(panel.getByRole("button", { name: "定位并高亮" })).toBeVisible();
|
||||
await expect(panel.getByRole("button", { name: "重置全部" })).toBeVisible();
|
||||
await expect(panel.getByRole("button", { name: "重置地图调试效果" })).toBeVisible();
|
||||
|
||||
await panel.locator("summary").filter({ hasText: "水流调试" }).click();
|
||||
await panel.getByLabel("管道 ID").fill("P-9");
|
||||
await panel.getByLabel("有符号流速").fill("-2.75");
|
||||
await panel.getByRole("button", { name: "更新该管道方向" }).click();
|
||||
@@ -61,9 +62,10 @@ 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.locator("summary").filter({ hasText: "数值标注" }).click();
|
||||
const apply = panel.getByRole("button", { name: "应用标注", exact: true });
|
||||
await apply.click();
|
||||
await panel.getByRole("combobox", { name: "业务图层组" }).click();
|
||||
await panel.getByRole("combobox", { name: "标注图层组" }).click();
|
||||
await page.getByRole("option", { name: "节点", exact: true }).click();
|
||||
await apply.click();
|
||||
|
||||
|
||||
@@ -173,6 +173,7 @@ test("assigned desktop panels avoid native form controls and horizontal overflow
|
||||
const devPanel = page.getByRole("complementary", { name: "地图开发面板" });
|
||||
await expectContained(devPanel, 1280);
|
||||
await expect(devPanel.locator("select")).toHaveCount(0);
|
||||
await devPanel.locator("summary").filter({ hasText: "图层样式" }).click();
|
||||
const layerGroup = devPanel.getByRole("combobox", { name: "业务图层组" });
|
||||
await layerGroup.focus();
|
||||
await layerGroup.press("Enter");
|
||||
@@ -199,18 +200,9 @@ for (const width of [375, 320]) {
|
||||
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||
const agentSheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
|
||||
await expectContained(agentSheet, width);
|
||||
const welcomeTitle = agentSheet.getByRole("heading", { name: "我已就绪,请描述任务" });
|
||||
await expect(welcomeTitle).toBeVisible();
|
||||
const [welcomeBox, conversationBox] = await Promise.all([
|
||||
welcomeTitle.boundingBox(),
|
||||
agentSheet.getByRole("log").boundingBox()
|
||||
]);
|
||||
expect(welcomeBox).not.toBeNull();
|
||||
expect(conversationBox).not.toBeNull();
|
||||
expect(welcomeBox!.y).toBeGreaterThanOrEqual(conversationBox!.y);
|
||||
expect(welcomeBox!.y + welcomeBox!.height).toBeLessThanOrEqual(
|
||||
conversationBox!.y + conversationBox!.height
|
||||
);
|
||||
await expect(agentSheet.getByRole("heading", { name: "我已就绪,请描述任务" })).toHaveCount(0);
|
||||
await expect(agentSheet.getByRole("log")).toBeVisible();
|
||||
await expect(agentSheet.getByRole("textbox")).toBeVisible();
|
||||
await expect(page.locator("[data-sonner-toast]")).toHaveCount(0, { timeout: 15_000 });
|
||||
await page.screenshot({
|
||||
path: `test-results/non-map-panels/${width}-agent.png`,
|
||||
@@ -364,7 +356,7 @@ async function prepareWorkbench(page: Page) {
|
||||
TJWATER_MAP_URL: "https://non-map-panels.invalid/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
||||
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
||||
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_ENABLE_DEV_PANEL: "true",
|
||||
TJWATER_ENABLE_MSW: "false"
|
||||
})}`
|
||||
|
||||
@@ -266,12 +266,12 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
|
||||
|
||||
await page.goto("/");
|
||||
const alertTrigger = page.getByRole("button", {
|
||||
name: "查看异常处置面板,当前 0 条待复核工况"
|
||||
name: "查看待办与告警,当前 0 条待处理事项"
|
||||
}).first();
|
||||
await expect(alertTrigger).toBeVisible();
|
||||
await alertTrigger.click();
|
||||
await expect(page.getByText("当前无待复核工况", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("来源:未配置告警源", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("当前无待处理事项", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("业务告警来源:未配置", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
const reconnectNotice = page.locator("[data-sonner-toast]").filter({
|
||||
hasText: "业务同步正在重连"
|
||||
|
||||
@@ -25,11 +25,9 @@ test("gem-blue actions and semantic labels remain readable on the new surfaces",
|
||||
expect(tint[2]).toBeGreaterThan(tint[1]);
|
||||
expect(tint[2]).toBeGreaterThan(tint[0]);
|
||||
await expectThemeColor(panel.locator("form.agent-panel-control"), "background-color", "--surface-reading");
|
||||
const body = panel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。");
|
||||
expect(await readRenderedContrast(body)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(await readRenderedContrast(panel.getByText("对话主题"))).toBeGreaterThanOrEqual(4.5);
|
||||
|
||||
await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").fill("检查压力变化");
|
||||
await page.getByPlaceholder("询问供水问题,或描述需要处理的任务").fill("检查压力变化");
|
||||
const send = panel.getByRole("button", { name: "发送 Agent 指令", exact: true });
|
||||
await expect(send).toBeEnabled();
|
||||
await expectThemeColor(send, "background-color", "--action-blue");
|
||||
|
||||
@@ -102,16 +102,28 @@ test.describe("外围工单测试", () => {
|
||||
await expect(panel.getByText("工单接口测试", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("历史监测回放", { exact: true })).toHaveCount(0);
|
||||
await expect(panel.getByText("测试数据", { exact: true })).toHaveCount(0);
|
||||
const workOrderList = page.getByLabel("工单列表");
|
||||
await expect(workOrderList).toContainText("合成通信核查");
|
||||
await expect(workOrderList).toContainText("合成压力偏差复核");
|
||||
await expect(workOrderList).toContainText("合成零流量样本确认");
|
||||
await expect(workOrderList).toContainText("待处理");
|
||||
await expect(workOrderList).toContainText("处理中");
|
||||
await expect(workOrderList).toContainText("已完成");
|
||||
await expect(workOrderList.getByText("工单报告", { exact: true })).toHaveCount(3);
|
||||
await expect(workOrderList.getByText("暂无现场反馈", { exact: true })).toHaveCount(3);
|
||||
await expect(workOrderList.getByText("活动记录", { exact: true })).toHaveCount(0);
|
||||
const appointmentList = panel.getByLabel("预约工单列表");
|
||||
const complaintList = panel.getByLabel("投诉工单列表");
|
||||
const burstList = panel.getByLabel("爆管上报工单列表");
|
||||
await expect(appointmentList).toContainText("合成通信核查");
|
||||
await expect(appointmentList).toContainText("合成压力偏差复核");
|
||||
await expect(appointmentList).toContainText("合成零流量样本确认");
|
||||
await expect(appointmentList).toContainText("待处理");
|
||||
await expect(appointmentList).toContainText("处理中");
|
||||
await expect(appointmentList).toContainText("已完成");
|
||||
await expect(appointmentList.getByText("工单报告", { exact: true })).toHaveCount(3);
|
||||
await expect(appointmentList.getByText("暂无现场反馈", { exact: true })).toHaveCount(3);
|
||||
await expect(complaintList).toContainText("水质投诉:演示小区");
|
||||
await expect(complaintList.getByRole("region", { name: "投诉工单信息" })).toContainText("发生时间");
|
||||
await expect(complaintList.getByText("工单报告", { exact: true })).toHaveCount(0);
|
||||
await expect(burstList).toContainText("爆管上报:演示路口");
|
||||
await expect(burstList.getByRole("region", { name: "爆管上报工单信息" })).toContainText("发生时间");
|
||||
await expect(complaintList).toHaveAttribute("open", "");
|
||||
await complaintList.locator("summary").click();
|
||||
await expect(complaintList).not.toHaveAttribute("open", "");
|
||||
await expect(complaintList.getByText("水质投诉:演示小区")).toBeHidden();
|
||||
await complaintList.locator("summary").click();
|
||||
await expect(complaintList.getByText("水质投诉:演示小区")).toBeVisible();
|
||||
expect(requestedPaths).toEqual(expect.arrayContaining([
|
||||
"/api/sample/v1/context",
|
||||
"/api/sample/v1/work-orders"
|
||||
@@ -257,7 +269,12 @@ test.describe("外围工单测试", () => {
|
||||
if (path.endsWith("/work-orders") && request.method() === "POST") {
|
||||
const body = request.postDataJSON();
|
||||
writes.push(body);
|
||||
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: "预约工单已创建。" }] };
|
||||
const createdDetail = body.work_order_type === "water_quality_complaint"
|
||||
? "投诉工单已创建。"
|
||||
: 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 }] };
|
||||
delete item.request_id; workOrders = [item]; return route.fulfill({ status: 201, json: item });
|
||||
}
|
||||
if (path.includes("/work-orders/") && request.method() === "PATCH") {
|
||||
@@ -280,7 +297,8 @@ test.describe("外围工单测试", () => {
|
||||
await page.getByRole("button", { name: "外围样板管理" }).click();
|
||||
const panel = page.getByRole("complementary", { name: "外围工单测试" });
|
||||
await expect(panel.getByText("工单接口测试", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByLabel("工单列表")).toContainText("暂无测试工单");
|
||||
await expect(panel.getByLabel("预约工单列表")).toContainText("暂无预约工单");
|
||||
await expect(panel.getByLabel("投诉工单列表")).toContainText("暂无投诉工单");
|
||||
await expect(panel.getByRole("textbox", { name: "标题" })).toBeHidden();
|
||||
await panel.getByRole("button", { name: "新增预约工单" }).click();
|
||||
let editor = panel.getByRole("region", { name: "新增预约工单" });
|
||||
@@ -302,7 +320,7 @@ test.describe("外围工单测试", () => {
|
||||
|
||||
await panel.getByRole("button", { name: "编辑工单 合成预约维修" }).click();
|
||||
editor = panel.getByRole("region", { name: "编辑工单 合成预约维修" });
|
||||
await expect(panel.getByLabel("工单列表").getByRole("region", { name: "编辑工单 合成预约维修" })).toBeVisible();
|
||||
await expect(panel.getByLabel("预约工单列表").getByRole("region", { name: "编辑工单 合成预约维修" })).toBeVisible();
|
||||
await expect(editor.locator("xpath=ancestor::article")).toContainText("合成预约维修");
|
||||
await expect(editor.getByRole("textbox", { name: "标题" })).toBeFocused();
|
||||
await expect(panel.getByRole("button", { name: "编辑工单 合成预约维修" })).toHaveCount(0);
|
||||
@@ -387,7 +405,9 @@ test.describe("外围工单测试", () => {
|
||||
await panel.getByRole("textbox", { name: "事件位置" }).fill("演示路与样板路交叉口");
|
||||
await panel.getByRole("textbox", { name: "现场描述" }).fill("路面积水并伴随压力突降");
|
||||
await panel.getByRole("button", { name: "创建事件工单" }).click();
|
||||
await expect(panel.getByText("爆管上报", { exact: true }).last()).toBeVisible();
|
||||
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",
|
||||
@@ -439,7 +459,7 @@ test.describe("外围工单测试", () => {
|
||||
page.on("request", (request) => {
|
||||
if (
|
||||
request.method() === "POST" &&
|
||||
request.url().includes("/api/v1/agent/chat/") &&
|
||||
request.url().includes("/api/v1/") &&
|
||||
request.postData()
|
||||
) {
|
||||
agentRequests.push(request.postDataJSON() as Record<string, unknown>);
|
||||
@@ -450,7 +470,7 @@ test.describe("外围工单测试", () => {
|
||||
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_SERVER_API_BASE_URL: "http://server.test",
|
||||
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "http://peripheral.test"
|
||||
})}`
|
||||
}));
|
||||
@@ -643,7 +663,7 @@ test.describe("外围工单测试", () => {
|
||||
status: "open"
|
||||
});
|
||||
expect(agentRequests.filter((body) => "trigger_context" in body)).toHaveLength(0);
|
||||
await page.route("**/api/v1/agent/chat/sessions", (route) => route.fulfill({ json: { sessions: [{
|
||||
await page.route("**/api/v1/sessions", (route) => route.fulfill({ json: { sessions: [{
|
||||
id: "browser-session-1", title: "水质投诉调查", created_at: "2026-09-23T01:00:00Z", updated_at: "2026-09-23T01:00:00Z",
|
||||
trigger: { subject_id: workOrderId, workflow: "water_quality_complaint", occurred_at: "2026-09-23T01:00:00Z" }
|
||||
}] } }));
|
||||
@@ -735,7 +755,7 @@ function createDemoDataset(projectId: string) {
|
||||
}
|
||||
|
||||
function createSourceWorkOrders(projectId: string) {
|
||||
return [
|
||||
const appointments = [
|
||||
["合成通信核查", "核查监测点通信和数据上送状态", "open", "high"],
|
||||
["合成压力偏差复核", "复核压力偏差与历史回放证据", "in_progress", "urgent"],
|
||||
["合成零流量样本确认", "确认零流量样本及质量标记", "completed", "normal"]
|
||||
@@ -760,4 +780,48 @@ function createSourceWorkOrders(projectId: string) {
|
||||
created_by: "synthetic-generator",
|
||||
updated_by: "synthetic-generator"
|
||||
}));
|
||||
const events = [
|
||||
{
|
||||
work_order_id: "50000000-0000-4000-8000-000000000004",
|
||||
title: "水质投诉:演示小区",
|
||||
summary: "居民反映晨间用水有异味",
|
||||
priority: "high",
|
||||
work_order_type: "water_quality_complaint",
|
||||
location: "演示小区",
|
||||
scenario_code: "WQ-SECONDARY-SUPPLY-001",
|
||||
activity_detail: "投诉工单已创建。"
|
||||
},
|
||||
{
|
||||
work_order_id: "50000000-0000-4000-8000-000000000005",
|
||||
title: "爆管上报:演示路口",
|
||||
summary: "路面积水并伴随压力突降",
|
||||
priority: "urgent",
|
||||
work_order_type: "burst_report",
|
||||
location: "演示路口",
|
||||
scenario_code: "BURST-REPORT-001",
|
||||
activity_detail: "爆管上报工单已创建。"
|
||||
}
|
||||
].map(({ activity_detail, ...event }) => ({
|
||||
...event,
|
||||
project_id: projectId,
|
||||
revision: 1,
|
||||
status: "open",
|
||||
assignee: null,
|
||||
scheduled_at: "2026-09-17T08:30:00Z",
|
||||
feedback_requirements: [],
|
||||
activity: [{
|
||||
sequence: 1,
|
||||
occurred_at: "2026-09-17T08:30:00Z",
|
||||
actor: "synthetic-generator",
|
||||
kind: "created",
|
||||
title: "工单创建",
|
||||
detail: activity_detail
|
||||
}],
|
||||
origin: "synthetic",
|
||||
created_at: "2026-09-17T08:30:00Z",
|
||||
updated_at: "2026-09-17T08:30:00Z",
|
||||
created_by: "synthetic-generator",
|
||||
updated_by: "synthetic-generator"
|
||||
}));
|
||||
return [...appointments, ...events];
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ async function mockRuntimeAndMap(
|
||||
TJWATER_MAP_URL: "https://scada-map.invalid/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
||||
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
||||
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_ENABLE_DEV_PANEL: "false",
|
||||
TJWATER_ENABLE_MSW: "false"
|
||||
};`
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
import { mockFlatWorkOrder } from "./support/mock-hierarchical-work-order";
|
||||
|
||||
test("keeps the open scheduled report when the rolling history refreshes", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2026-09-24T09:20:53Z") });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await openConditionFeed(page);
|
||||
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
const rows = panel.getByTestId("scheduled-condition-timeline").locator("button[aria-pressed]");
|
||||
const oldestReport = rows.last();
|
||||
const reportTitle = await oldestReport.textContent();
|
||||
expect(reportTitle).toContain("11:20SCADA 数据诊断");
|
||||
await oldestReport.click();
|
||||
await expect(oldestReport).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
await page.clock.runFor(12_000);
|
||||
|
||||
await expect(rows.filter({ hasText: reportTitle ?? "" })).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(panel.getByTestId("scheduled-condition-detail-scroll"))
|
||||
.toContainText("11:20 的SCADA 数据诊断");
|
||||
|
||||
await panel.getByRole("button", { name: /预约任务/ }).click();
|
||||
await panel.getByRole("button", { name: /定时任务/ }).click();
|
||||
await expect(rows.filter({ hasText: reportTitle ?? "" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await openConditionFeed(page);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { responsesFixture } from "./responses-fixture";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
type MockAgentApiOptions = {
|
||||
@@ -19,7 +20,7 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
|
||||
icon: "bolt" as const
|
||||
};
|
||||
const triggeredSessions = new Map<string, { sessionId: string; triggerId: string }>();
|
||||
await page.route("**/api/v1/agent/chat/**", async (route) => {
|
||||
await page.route("http://127.0.0.1:8787/api/v1/**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
@@ -34,6 +35,18 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
|
||||
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
|
||||
if (pathname.endsWith("/suggestions") && route.request().method() === "POST") {
|
||||
await route.fulfill({ headers: corsHeaders, json: {
|
||||
suggestions: [
|
||||
{ question: "分析当前管网需要优先核对的压力监测点。", activity_id: null },
|
||||
{ question: "检查近期供水任务的证据与待确认事项。", activity_id: null },
|
||||
{ question: "解读近期分析成果并列出进一步验证的问题。", activity_id: null },
|
||||
],
|
||||
generated_at: "2026-10-09T07:00:00Z",
|
||||
} });
|
||||
return;
|
||||
}
|
||||
|
||||
const collaborationMatch = pathname.match(/\/task\/([^/]+)\/collaboration\/session$/);
|
||||
if (collaborationMatch && route.request().method() === "POST") {
|
||||
await route.fulfill({
|
||||
@@ -99,24 +112,21 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/stream")) {
|
||||
if (pathname.endsWith("/responses")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "text/event-stream",
|
||||
"X-Vercel-AI-UI-Message-Stream": "v1"
|
||||
"Content-Type": "text/event-stream"
|
||||
},
|
||||
body: [
|
||||
body: responsesFixture([
|
||||
{ type: "start", messageId: "assistant-browser-test" },
|
||||
{ type: "text-start", id: "answer" },
|
||||
{ type: "text-delta", id: "answer", delta: "已收到工况汇总请求。" },
|
||||
{ type: "text-end", id: "answer" },
|
||||
{ type: "finish", finishReason: "stop" }
|
||||
]
|
||||
.map((part) => `data: ${JSON.stringify(part)}\n\n`)
|
||||
.join("")
|
||||
])
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -161,6 +171,6 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({ headers: corsHeaders, json: {} });
|
||||
await route.fallback();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function mockWorkbenchContext(
|
||||
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
||||
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||
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: "",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Translate existing UI scenarios into Responses wire fixtures. Production
|
||||
// clients accept Responses only; this helper belongs exclusively to tests.
|
||||
export function responseFixtureWriter() {
|
||||
let sequence = 0;
|
||||
let id = "resp_browser_fixture";
|
||||
let messageId = "msg_browser_fixture";
|
||||
const texts = new Map<string, string>();
|
||||
const contentIndices = new Map<string, number>();
|
||||
const closed = new Set<string>();
|
||||
let error: string | undefined;
|
||||
const event = (type: string, fields: Record<string, unknown>) => `event: ${type}\ndata: ${JSON.stringify({ type, sequence_number: sequence++, ...fields })}\n\n`;
|
||||
const textFields = (key: string) => ({ item_id: messageId, output_index: 0, content_index: contentIndices.get(key) ?? 0 });
|
||||
return (chunk: Record<string, unknown>) => {
|
||||
if (chunk.type === "start") {
|
||||
messageId = String(chunk.messageId); id = `resp_${messageId}`;
|
||||
return event("response.created", { response: { id, object: "response", status: "in_progress" } })
|
||||
+ event("response.output_item.added", { output_index: 0, item: { type: "message", role: "assistant", id: messageId, content: [] } });
|
||||
}
|
||||
if (chunk.type === "text-start") {
|
||||
const key = String(chunk.id);
|
||||
contentIndices.set(key, contentIndices.size); texts.set(key, "");
|
||||
return event("response.content_part.added", { ...textFields(key), part: { type: "output_text", text: "" } });
|
||||
}
|
||||
if (chunk.type === "text-delta") {
|
||||
const key = String(chunk.id); texts.set(key, `${texts.get(key) ?? ""}${String(chunk.delta)}`);
|
||||
return event("response.output_text.delta", { ...textFields(key), delta: chunk.delta });
|
||||
}
|
||||
if (chunk.type === "text-end") { const key = String(chunk.id); closed.add(key); return event("response.output_text.done", { ...textFields(key), text: texts.get(key) ?? "" }); }
|
||||
if (typeof chunk.type === "string" && chunk.type.startsWith("data-")) return event(`tjwater.${chunk.type.slice(5)}`, { response_id: id, data: chunk.data, ...(chunk.id ? { id: chunk.id } : {}) });
|
||||
if (chunk.type === "error") { error = String(chunk.errorText); return ""; }
|
||||
if (chunk.type === "finish") {
|
||||
const rest = [...texts.keys()].filter((key) => !closed.has(key)).map((key) => event("response.output_text.done", { ...textFields(key), text: texts.get(key) })).join("");
|
||||
return rest + event(error ? "response.failed" : "response.completed", { response: { id, object: "response", status: error ? "failed" : "completed", ...(error ? { error: { message: error } } : {}) } });
|
||||
}
|
||||
return "";
|
||||
};
|
||||
}
|
||||
|
||||
export function responsesFixture(chunks: Record<string, unknown>[]) {
|
||||
const write = responseFixtureWriter();
|
||||
return chunks.map(write).join("");
|
||||
}
|
||||
@@ -123,7 +123,7 @@ test("keeps mock conditions while retaining explicit backend alerts", async ({ p
|
||||
await expect(panel).not.toContainText("演示 合成低压调查");
|
||||
|
||||
const alertButton = page.getByRole("button", {
|
||||
name: "查看异常处置面板,当前 1 条待复核工况"
|
||||
name: "查看待办与告警,当前 1 条待处理事项"
|
||||
});
|
||||
await alertButton.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
@@ -462,11 +462,11 @@ test("keeps mock conditions while using backend work orders and alerts", async (
|
||||
await expect(detail.locator("details")).toHaveCount(0);
|
||||
|
||||
const alertButton = page.getByRole("button", {
|
||||
name: "查看异常处置面板,当前 1 条待复核工况"
|
||||
name: "查看待办与告警,当前 1 条待处理事项"
|
||||
});
|
||||
await alertButton.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByText("来源:后端运行态", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("业务告警来源:后端运行态", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("后端告警:泵站通信中断")).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
|
||||
type SyncProbe = {
|
||||
available: boolean;
|
||||
connections: number;
|
||||
cursors: string[];
|
||||
warnings: number;
|
||||
disconnect: (scheduled: boolean) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window { syncProbe: SyncProbe }
|
||||
}
|
||||
|
||||
test("scheduled renewals stay silent while a sustained outage warns and recovers", async ({ page }) => {
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
await page.route("**/api/v1/workbench/sync-snapshot", (route) => route.fulfill({ json: {
|
||||
schema: "workbench-sync-snapshot@1", event_cursor: 41, generated_at: "2026-10-12T02:00:00Z",
|
||||
runtime: { availability: "not_configured", source: "none", scenario_availability: "not_configured",
|
||||
artifact_availability: "not_configured", schedule_availability: "not_configured",
|
||||
work_order_availability: "not_configured", alert_availability: "not_configured",
|
||||
generated_at: "2026-10-12T02:00:00Z", conditions: [], alerts: [], scenarios: [],
|
||||
latest_analysis: null, work_orders: [] }, tasks: [], notifications: []
|
||||
} }));
|
||||
await page.addInitScript(() => {
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
let stream: ReadableStreamDefaultController<Uint8Array> | undefined;
|
||||
window.syncProbe = {
|
||||
available: true, connections: 0, cursors: [], warnings: 0,
|
||||
disconnect: (scheduled) => {
|
||||
if (!stream) throw new Error("No active event stream");
|
||||
if (scheduled) {
|
||||
stream.enqueue(new TextEncoder().encode("event: reconnect\ndata: {}\n\n"));
|
||||
stream.close();
|
||||
} else {
|
||||
stream.error(new Error("Synthetic connection loss"));
|
||||
}
|
||||
stream = undefined;
|
||||
}
|
||||
};
|
||||
new MutationObserver(() => {
|
||||
if (document.body?.textContent?.includes("业务同步正在重连")) window.syncProbe.warnings += 1;
|
||||
}).observe(document, { subtree: true, childList: true, characterData: true });
|
||||
window.fetch = async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
if (!url.includes("/api/v1/workbench/events")) return originalFetch(input, init);
|
||||
window.syncProbe.cursors.push(new Headers(init?.headers).get("Last-Event-ID") ?? "");
|
||||
if (!window.syncProbe.available) return new Response("{}", { status: 503 });
|
||||
window.syncProbe.connections += 1;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) { stream = controller; },
|
||||
cancel() { stream = undefined; }
|
||||
});
|
||||
return new Response(body, { headers: { "Content-Type": "text/event-stream" } });
|
||||
};
|
||||
});
|
||||
const now = new Date("2026-10-12T02:00:00Z");
|
||||
await page.clock.install({ time: now });
|
||||
await page.clock.pauseAt(now);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect.poll(() => page.evaluate(() => window.syncProbe.connections), { timeout: 15_000 }).toBe(1);
|
||||
|
||||
for (let cycle = 0; cycle < 3; cycle += 1) {
|
||||
await page.evaluate(() => window.syncProbe.disconnect(true));
|
||||
await page.clock.runFor(1_100);
|
||||
await expect.poll(() => page.evaluate(() => window.syncProbe.connections)).toBe(cycle + 2);
|
||||
expect(await page.evaluate(() => window.syncProbe.warnings)).toBe(0);
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.syncProbe.available = false;
|
||||
window.syncProbe.disconnect(false);
|
||||
});
|
||||
await page.clock.runFor(14_000);
|
||||
await expect(page.getByText("业务同步正在重连", { exact: true })).toHaveCount(0);
|
||||
await page.clock.runFor(1_100);
|
||||
await expect(page.getByText("业务同步正在重连", { exact: true })).toBeVisible();
|
||||
await page.clock.runFor(500);
|
||||
expect(await page.evaluate(() => window.syncProbe.warnings)).toBeGreaterThan(0);
|
||||
const notice = page.getByText("业务同步正在重连", { exact: true });
|
||||
await expect(notice.locator("xpath=ancestor::li[@data-sonner-toast]")).toHaveCSS("opacity", "1");
|
||||
await expect(notice).toBeInViewport();
|
||||
await page.screenshot({ path: test.info().outputPath("sustained-outage.png") });
|
||||
|
||||
await page.evaluate(() => { window.syncProbe.available = true; });
|
||||
await page.clock.runFor(16_000);
|
||||
await expect.poll(() => page.evaluate(() => window.syncProbe.connections)).toBe(5);
|
||||
await page.clock.runFor(500);
|
||||
await expect(page.getByText("业务同步正在重连", { exact: true })).toHaveCount(0);
|
||||
expect(await page.evaluate(() => window.syncProbe.cursors.every((cursor) => cursor === "41"))).toBe(true);
|
||||
await page.screenshot({ path: test.info().outputPath("recovered.png") });
|
||||
});
|
||||
@@ -10,7 +10,7 @@ test("top bar uses compact controls on narrow mobile viewports", async ({ page }
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "切换模拟方案" })).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: "切换地图 Dev Panel" })).toBeHidden();
|
||||
const alertButton = page.getByRole("button", { name: /查看异常处置面板/ });
|
||||
const alertButton = page.getByRole("button", { name: /查看待办与告警/ });
|
||||
const userButton = page.getByRole("button", { name: "打开用户菜单" });
|
||||
await expect(alertButton).toBeVisible();
|
||||
await expect(userButton).toBeVisible();
|
||||
@@ -55,6 +55,6 @@ test("top bar keeps text horizontal at the desktop breakpoint", async ({ page })
|
||||
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||
})
|
||||
.map((span) => span.textContent?.trim())
|
||||
.filter((text) => text === "任务浮条" || text === "工况任务" || text === "异常处置").length;
|
||||
.filter((text) => text === "任务浮条" || text === "工况任务" || text === "待办与告警").length;
|
||||
})).toBe(0);
|
||||
});
|
||||
|
||||
@@ -186,7 +186,7 @@ async function prepareDeterministicWorkbench(page: Page) {
|
||||
TJWATER_MAP_URL: "https://visual-map.invalid/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
||||
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
||||
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_ENABLE_DEV_PANEL: "false",
|
||||
TJWATER_ENABLE_MSW: "false"
|
||||
};`
|
||||
@@ -248,6 +248,9 @@ async function openWorkbench(page: Page) {
|
||||
await page.waitForFunction(() =>
|
||||
Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit"))
|
||||
);
|
||||
await expect(page.getByRole("button", {
|
||||
name: "查看待办与告警,当前 0 条待处理事项"
|
||||
})).toBeVisible();
|
||||
}
|
||||
|
||||
type BasemapLabel = "浅色" | "影像" | "天地图矢量" | "天地图影像";
|
||||
@@ -331,6 +334,7 @@ async function expectLoadedChineseFont(page: Page) {
|
||||
}
|
||||
|
||||
async function expectBodyContrast(page: Page) {
|
||||
const body = page.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。");
|
||||
const body = page.getByRole("group", { name: "推荐问题" }).getByRole("button").first();
|
||||
await expect(body).toBeVisible();
|
||||
expect(await readRenderedContrast(body)).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
@@ -244,7 +244,7 @@ async function prepareMainFrame(page: Page, latestAnalysis: unknown = MOCK_SPATI
|
||||
TJWATER_MAP_URL: "https://workspace-map.invalid/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
||||
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
||||
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_ENABLE_DEV_PANEL: "true",
|
||||
TJWATER_ENABLE_MSW: "false"
|
||||
};`
|
||||
|
||||