feat(workbench): integrate runtime data and map tooling

This commit is contained in:
2026-09-22 10:45:46 +08:00
parent bf44cc0f25
commit bebcf2a06c
163 changed files with 15642 additions and 1312 deletions
+170 -18
View File
@@ -1,7 +1,9 @@
import { expect, test, type Locator } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
test.beforeEach(async ({ page }) => {
await mockWorkbenchContext(page);
await mockAgentApi(page, {
sessions: [
{
@@ -31,11 +33,12 @@ async function waitForAnimations(locator: Locator) {
});
}
test("desktop Agent lives in the solid Main Frame navigation pane", async ({ page }) => {
test("desktop Agent docks beside the workspace with a solid reading surface", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const topBar = page.locator("header.acrylic-navigation");
const navigation = page.getByRole("complementary", { name: "工作台导航" });
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");
@@ -43,19 +46,18 @@ test("desktop Agent lives in the solid Main Frame navigation pane", async ({ pag
const agentControls = agentPanel.locator(".agent-panel-band").last();
await expect(topBar).toBeVisible();
await expect(navigation).toBeVisible();
await expect(workspace).toBeVisible();
await expect(agentPanel).toBeVisible();
await expect(agentHeader).toBeVisible();
await expect(agentControls).toBeVisible();
await waitForAnimations(agentPanel);
await expectAcrylicSurface(topBar);
await expect(navigation).toHaveCSS("background-color", "rgb(237, 241, 245)");
await expect(navigation).toHaveCSS("backdrop-filter", "none");
await expect(agentPanel).toHaveCSS("background-color", "rgb(247, 249, 251)");
await expect(agentPanel).toHaveCSS("background-color", "oklch(0.965 0.012 248)");
await expect(agentPanel).toHaveCSS("backdrop-filter", "none");
await expect(agentPanel).toHaveCSS("border-radius", "0px");
await expect(agentPanel).toHaveCSS("box-shadow", "none");
await expect(agentPanel).toHaveCSS("border-top-left-radius", "0px");
await expect(agentPanel).toHaveCSS("border-top-right-radius", "16px");
await expect(agentPanel).not.toHaveCSS("box-shadow", "none");
await expect(agentControls).toHaveCSS("border-top-width", "1px");
await expect(
agentPanel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
@@ -69,22 +71,21 @@ test("desktop Agent lives in the solid Main Frame navigation pane", async ({ pag
expect(panelBox).not.toBeNull();
expect(conversationBox).not.toBeNull();
expect(scrollBox).not.toBeNull();
expect(panelBox!.x).toBe(48);
expect(panelBox!.width).toBe(319);
expect(
Math.abs(conversationBox!.x - panelBox!.x)
).toBeLessThanOrEqual(1);
expect(panelBox!.x).toBe(0);
expect(panelBox!.width).toBe(500);
await expect.poll(async () => (await workspace.boundingBox())?.x).toBe(500);
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 expect
.poll(() => getSurfacePresentation(agentPanel))
.toEqual(beforeFocus);
await expect.poll(() => getSurfacePresentation(agentPanel)).toEqual(beforeFocus);
await page.screenshot({ path: "test-results/agent-history-ui/desktop-dock.png" });
});
test("desktop Agent history remains usable inside the docked navigation pane", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const historyButton = agentPanel.getByRole("button", {
@@ -108,6 +109,7 @@ test("desktop Agent history remains usable inside the docked navigation pane", a
await expect(renameInput).toHaveCSS("box-shadow", "none");
await expect(renameInput).toHaveCSS("border-color", "rgb(203, 213, 225)");
await expect(editingItem).toHaveCSS("border-color", "rgba(0, 0, 0, 0)");
await page.screenshot({ path: "test-results/agent-history-ui/desktop-rename.png" });
await history.getByRole("button", { name: "取消重命名" }).click();
await page.keyboard.press("Escape");
@@ -173,23 +175,173 @@ test("mobile Agent mirrors the desktop floating history surface", async ({ page
expectRoundedBox(conversationAfter!, conversationBefore!);
await expect(agentConversation).toHaveClass(/agent-panel-conversation-canvas/);
await expect(agentPanel.locator(".agent-panel-composer")).toBeVisible();
await page.screenshot({ path: "test-results/agent-history-ui/mobile-history.png" });
await page.keyboard.press("Escape");
await expect(history).toBeHidden();
await expect(historyButton).toBeFocused();
});
test("Main Frame navigation respects forced color mode", async ({ page }) => {
test("docked Agent respects forced color mode", async ({ page }) => {
await page.emulateMedia({ forcedColors: "active" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const navigation = page.getByRole("complementary", { name: "工作台导航" });
const navigation = page.getByRole("complementary", { name: "Agent 命令面板" });
const workspace = page.locator("#main-workspace");
await expect(navigation).toBeVisible();
await expect(navigation).toHaveCSS("backdrop-filter", "none");
await expect(navigation).toHaveCSS("box-shadow", "none");
await expect(workspace).toBeVisible();
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
await page.screenshot({ path: "test-results/agent-history-ui/forced-colors.png" });
});
test("long history titles keep rename and explicit delete usable across viewport sizes", async ({
page
}) => {
const sessionId = "browser-history-long-session";
const originalTitle =
"城南供水压力复核:夜间最小流量与压力变送器连续性联合调查,待现场反馈后人工审核";
let title = originalTitle;
let deleted = false;
const writes: Array<{ method: string; path: string; body: unknown }> = [];
await page.route("**/api/v1/agent/chat/sessions", (route) =>
route.fulfill({
json: {
sessions: deleted
? []
: [
{
session_id: sessionId,
title,
created_at: 100,
updated_at: 200,
run_status: "completed"
}
]
}
})
);
await page.route("**/api/v1/agent/chat/session/**", async (route) => {
const request = route.request();
const method = request.method();
const path = new URL(request.url()).pathname;
if (method === "PATCH" && path === `/api/v1/agent/chat/session/${sessionId}/title`) {
const body = request.postDataJSON();
title = body.title;
writes.push({ method, path, body });
return route.fulfill({ json: {} });
}
if (method === "DELETE" && path === `/api/v1/agent/chat/session/${sessionId}`) {
deleted = true;
writes.push({ method, path, body: null });
return route.fulfill({ json: {} });
}
return route.fallback();
});
for (const width of [320, 375, 1440]) {
title = originalTitle;
deleted = false;
writes.length = 0;
await page.setViewportSize({ width, height: 844 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page
.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ })
.click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
const modelButton = panel.getByRole("button", { name: "选择 Agent 模型" });
await expect(modelButton).toBeVisible();
await expect(modelButton).toHaveAccessibleDescription("当前模型:测试模型");
await expect(modelButton).toHaveAttribute("title", "测试模型");
await waitForAnimations(panel);
const approvalBox = await panel.getByRole("button", { name: "权限批准模式" }).boundingBox();
const modelBox = await modelButton.boundingBox();
expect(approvalBox!.x + approvalBox!.width).toBeLessThanOrEqual(modelBox!.x);
const controls = await panel
.locator('[data-slot="input-group-addon"][data-align="block-end"]')
.getByRole("button")
.evaluateAll((elements) =>
elements.map((element) => {
const box = element.getBoundingClientRect();
return { left: box.left, right: box.right };
})
);
expect(controls.length).toBeGreaterThanOrEqual(3);
for (let index = 0; index < controls.length; index += 1) {
expect(controls[index].left).toBeGreaterThanOrEqual(0);
expect(controls[index].right).toBeLessThanOrEqual(width);
if (index > 0) expect(controls[index - 1].right).toBeLessThanOrEqual(controls[index].left);
}
await modelButton.click();
await expect(page.getByRole("menuitem", { name: /测试模型/ })).toBeVisible();
if (width < 1024) {
await page.screenshot({ path: `test-results/agent-history-ui/${width}-model-menu.png` });
}
await page.keyboard.press("Escape");
await expect(modelButton).toBeFocused();
const approvalButton = panel.getByRole("button", { name: "权限批准模式" });
await approvalButton.click();
await expect(page.getByRole("menuitem", { name: /请求批准/ })).toBeVisible();
await page.keyboard.press("Escape");
await expect(approvalButton).toBeFocused();
const historyButton = panel.getByRole("button", { name: "打开 Agent 历史记录" });
await historyButton.click();
const history = panel.getByRole("region", { name: "Agent 历史记录" });
await expect(history.locator(".agent-history-session-trigger")).toContainText(originalTitle);
await history.getByRole("button", { name: "重命名对话", exact: true }).click();
const input = history.getByRole("textbox");
await expect(input).toBeFocused();
await input.fill(`${originalTitle}(已复核)`);
await expect(history.getByRole("button", { name: "保存对话主题" })).toBeVisible();
await waitForAnimations(history);
const box = await history.boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
expect(await history.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(
true
);
const inputBox = await input.boundingBox();
const saveBox = await history.getByRole("button", { name: "保存对话主题" }).boundingBox();
expect(inputBox!.width).toBeGreaterThan(40);
expect(inputBox!.x + inputBox!.width).toBeLessThanOrEqual(saveBox!.x);
await page.screenshot({ path: `test-results/agent-history-ui/${width}-long-rename.png` });
await history.getByRole("button", { name: "取消重命名" }).click();
await expect(history.locator(".agent-history-session-trigger")).toContainText(originalTitle);
expect(writes).toEqual([]);
await history.getByRole("button", { name: "重命名对话", exact: true }).click();
await input.fill("城南压力复核(已复核)");
await history.getByRole("button", { name: "保存对话主题" }).click();
await expect(history.locator(".agent-history-session-trigger")).toContainText(
"城南压力复核(已复核)"
);
expect(writes).toEqual([
{
method: "PATCH",
path: `/api/v1/agent/chat/session/${sessionId}/title`,
body: { title: "城南压力复核(已复核)", is_title_manually_edited: true }
}
]);
await history.getByRole("button", { name: "删除对话", exact: true }).click();
await expect(history.getByText("删除后无法从历史记录中恢复")).toBeVisible();
await expect(history.getByRole("button", { name: "确认", exact: true })).toBeVisible();
await history.getByRole("button", { name: "取消删除" }).click();
expect(writes).toHaveLength(1);
await history.getByRole("button", { name: "删除对话", exact: true }).click();
await history.getByRole("button", { name: "确认", exact: true }).click();
await expect(history.getByText("暂无历史记录", { exact: true })).toBeVisible();
expect(writes).toHaveLength(2);
expect(writes[1]).toEqual({
method: "DELETE",
path: `/api/v1/agent/chat/session/${sessionId}`,
body: null
});
await page.keyboard.press("Escape");
await expect(history).toBeHidden();
await expect(historyButton).toBeFocused();
}
});
async function expectAcrylicSurface(shell: Locator, allowedFilteredDescendant?: string) {
+34 -7
View File
@@ -1,28 +1,51 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
test.beforeEach(async ({ page }) => {
await mockAgentApi(page);
});
async function openAgentPanel(page: Page) {
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
}
test("the docked Agent collapses and restores the main workspace", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAgentPanel(page);
const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" });
const workspace = page.locator("#main-workspace");
const expandedWidth = Math.round((await agentPanel.boundingBox())?.width ?? 0);
expect(expandedWidth).toBeGreaterThan(400);
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(expandedWidth);
await expect
.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0))
.toBeGreaterThan(400);
const expandedOffset = Math.round((await workspace.boundingBox())?.x ?? 0);
await expect
.poll(async () => Math.round((await agentPanel.boundingBox())?.width ?? 0))
.toBe(expandedOffset);
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(72);
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(0);
await page.getByLabel(/展开 Agent 助手面板/).click();
await expect(agentPanel).toBeVisible();
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(expandedWidth);
await expect
.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0))
.toBe(expandedOffset);
});
test("the floating condition feed coordinates with the docked Agent", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAgentPanel(page);
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
const workspace = page.locator("#main-workspace");
await expect
.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 将通过后端会话流式响应");
await prompt.fill("保留尚未发送的诊断问题");
const conditionToggle = page
@@ -32,11 +55,15 @@ test("the floating condition feed coordinates with the docked Agent", async ({ p
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(conditionPanel).toBeVisible();
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
await expect(prompt).toHaveValue("保留尚未发送的诊断问题");
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
await expect(conditionPanel.getByRole("button", { name: "收起工况任务" })).toBeVisible();
await conditionPanel.getByRole("button", { name: "收起工况任务" }).click();
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
await expect
.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0))
.toBe(expandedOffset);
await expect(prompt).toHaveValue("保留尚未发送的诊断问题");
await conditionToggle.click();
await expect(conditionPanel).toBeHidden();
@@ -72,12 +99,12 @@ test.describe("mobile touch layout", () => {
});
test("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
await mockAgentApi(page);
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /查看异常处置面板/ }).click();
await expect(page.getByText("来源:工况演示回退", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "工况汇总" }).click();
await expect(
+18
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
import { mockScadaApi } from "./support/mock-scada-api";
test("submits a custom answer with the actionable id after a later tool placeholder", async ({
page
@@ -6,6 +7,7 @@ test("submits a custom answer with the actionable id after a later tool placehol
const replies: Array<{ pathname: string; body: unknown }> = [];
await mockQuestionApi(page, replies);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const prompt = page
.getByPlaceholder(/输入调度问题/)
@@ -60,6 +62,22 @@ test("keeps the custom answer control within a 375px Agent sheet", async ({ page
});
async function mockQuestionApi(page: Page, replies: Array<{ pathname: string; body: unknown }>) {
await mockScadaApi(page);
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_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_MAP_URL: "https://question-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_MAPBOX_ACCESS_TOKEN: "",
TJWATER_ENABLE_MSW: "false"
})};`
})
);
await page.route("https://question-map.invalid/**", (route) => route.fulfill({ status: 204 }));
await page.route("**/api/v1/agent/chat/**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
+46 -27
View File
@@ -1,6 +1,7 @@
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";
const STREAM_CHUNK_SIZE = 18;
const STREAM_CHUNK_DELAY_MS = 20;
@@ -178,6 +179,22 @@ test.afterAll(async () => {
});
test("stream completion and the plan card remain positionally stable", async ({ page }) => {
await mockScadaApi(page);
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_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_MAP_URL: "https://stream-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_MAPBOX_ACCESS_TOKEN: "",
TJWATER_ENABLE_MSW: "false"
})};`
})
);
await page.route("https://stream-map.invalid/**", (route) => route.fulfill({ status: 204 }));
await page.route("**/api/v1/agent/chat/**", async (route) => {
const path = new URL(route.request().url()).pathname;
@@ -206,6 +223,7 @@ test("stream completion and the plan card remain positionally stable", async ({
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await expect(
page.getByRole("button", { name: "选择 Agent 模型" }).filter({ visible: true }).first()
@@ -290,33 +308,26 @@ 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-operational-float");
const operationalBrief = page.locator(".agent-panel-control").filter({
has: page.getByRole("button", { name: "预览影响", exact: true })
});
await expect(operationalBrief).toBeVisible();
await expect(operationalBrief).toHaveCSS("position", "absolute");
await expect(operationalBrief).not.toHaveClass(/agent-panel-floating-acrylic/);
await expect(operationalBrief).toHaveCSS("background-color", "rgba(255, 255, 255, 0.86)");
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", "16px");
await expect(operationalBrief).toHaveCSS("background-image", "none");
await expect(operationalBrief).toHaveCSS("border-top-width", "0px");
await expect(operationalBrief).toHaveCSS(
"box-shadow",
"rgba(15, 33, 55, 0.14) 0px 12px 32px 0px, rgba(15, 33, 55, 0.08) 0px 3px 10px 0px"
);
// Desktop uses an inline brief above the conversation, not the legacy floating overlay.
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) => getComputedStyle(element).boxShadow))
.not.toContain("inset");
await expect
.poll(() => operationalBrief.evaluate((element) => getComputedStyle(element).backdropFilter))
.toContain("blur(");
const operationalBriefBox = await operationalBrief.boundingBox();
const conversationBox = await page.locator(".agent-panel-conversation-canvas").boundingBox();
expect(operationalBriefBox).not.toBeNull();
expect(conversationBox).not.toBeNull();
expect(operationalBriefBox!.y).toBeLessThan(conversationBox!.y + conversationBox!.height);
await expect(page.locator(".agent-panel-conversation-content-has-brief")).toHaveCSS(
"padding-top",
"208px"
);
.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);
@@ -596,9 +607,17 @@ test("stream completion and the plan card remain positionally stable", async ({
boxShadow: style.boxShadow
};
};
expect(await scrollToLatestButton.evaluate(readAcrylicMaterial)).toEqual(
await page.locator(".agent-panel-composer").evaluate(readAcrylicMaterial)
);
await expect(prompt).toBeVisible();
await expect(page.locator(".agent-panel-composer")).toHaveCount(0);
await expect
.poll(() =>
prompt.evaluate((element) => {
const form = element.closest("form")!;
const scroll = document.querySelector(".agent-conversation-scroll")!;
return form.getBoundingClientRect().top - scroll.getBoundingClientRect().bottom;
})
)
.toBeGreaterThanOrEqual(0);
const selectableAssistantMessage = page.getByRole("heading", { name: "诊断结果" }).first();
await selectableAssistantMessage.evaluate((element) => {
+22 -5
View File
@@ -1,4 +1,14 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
test.beforeEach(async ({ page }) => {
await mockAgentApi(page);
});
async function openAgentPanel(page: Page) {
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
}
test("speech capability detection preserves the server hydration tree", async ({ page }) => {
const hydrationErrors: string[] = [];
@@ -16,6 +26,7 @@ test("speech capability detection preserves the server hydration tree", async ({
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAgentPanel(page);
await expect(page.getByRole("button", { name: "语音输入" })).toBeVisible();
await expect(page.getByRole("button", { name: "权限批准模式" })).toBeVisible();
@@ -46,6 +57,7 @@ test("speech recognition failure clears the active animation and explains the er
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAgentPanel(page);
await page.getByRole("button", { name: "语音输入" }).click();
@@ -54,7 +66,7 @@ test("speech recognition failure clears the active animation and explains the er
await expect(page.getByText("无法使用麦克风,请在浏览器地址栏允许麦克风权限后重试")).toBeVisible();
});
test("speech recognition writes final transcript into the prompt", async ({ page }) => {
test("speech recognition appends every final transcript in one result batch", async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(window, "SpeechRecognition", {
configurable: true,
@@ -64,8 +76,11 @@ test("speech recognition writes final transcript into the prompt", async ({ page
start() {
window.setTimeout(() => {
const result = Object.assign([{ transcript: "检查城南泵站水位" }], { isFinal: true });
this.onresult?.({ resultIndex: 0, results: [result] });
const firstResult = Object.assign([{ transcript: "检查城南泵站" }], {
isFinal: true
});
const secondResult = Object.assign([{ transcript: "水位" }], { isFinal: true });
this.onresult?.({ resultIndex: 0, results: [firstResult, secondResult] });
}, 20);
}
@@ -78,10 +93,11 @@ test("speech recognition writes final transcript into the prompt", async ({ page
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAgentPanel(page);
await page.getByRole("button", { name: "语音输入" }).click();
await expect(page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应")).toHaveValue("检查城南泵站水位");
await expect(page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应")).toHaveValue("检查城南泵站 水位");
});
test("speech recognition pulse stays transparent across its loop boundary", async ({ page }) => {
@@ -98,6 +114,7 @@ test("speech recognition pulse stays transparent across its loop boundary", asyn
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAgentPanel(page);
await page.getByRole("button", { name: "语音输入" }).click();
+147
View File
@@ -0,0 +1,147 @@
import { expect, test, type Page } from "@playwright/test";
import { mockScadaApi } from "./support/mock-scada-api";
test("SCADA properties temporarily replace the task ticker", async ({ page }) => {
await mockFeaturePanelRuntime(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => (
Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit"))
&& (window.__waterNetworkMap?.querySourceFeatures("scada").length ?? 0) > 0
));
const taskTicker = page.locator(".agent-task-ticker");
await expect(taskTicker).toBeVisible();
await clickFirstScadaFeature(page);
await expect(page.getByRole("button", { name: "关闭要素信息" })).toBeVisible();
await expect(page.getByRole("heading", { name: "SCADA SCADA-OVERLAP-1" })).toBeVisible();
await expect(page.getByText("压力监测", { exact: true })).toBeVisible();
await expect(taskTicker).toBeHidden();
await page.getByRole("button", { name: "关闭要素信息" }).click();
await expect(taskTicker).toBeVisible();
});
test("GIS inventory attributes stay folded until requested", async ({ page }) => {
await mockFeaturePanelRuntime(page);
await page.route(
"https://tjwater-api.invalid/api/v1/elements/scada/SCADA-OVERLAP-1/gis-properties",
(route) => route.fulfill({
contentType: "application/json",
body: JSON.stringify({
element_type: "scada",
element_id: "SCADA-OVERLAP-1",
groups: [{
dataset_key: "fx_water_assets_20200423",
dataset_title: "奉贤全网除头桥 GIS 数据导出",
source_date: "2020-04-23",
layer_key: "fx_20200423_flow_meters",
layer_name: "流量仪",
source_fid: 12,
match_method: "geometry",
match_distance_m: 0.2,
attributes: { 设备类别: "LLY", 干管口径: 300 }
}]
})
})
);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => (
Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit"))
&& (window.__waterNetworkMap?.querySourceFeatures("scada").length ?? 0) > 0
));
await clickFirstScadaFeature(page);
const featurePanel = page.getByRole("complementary", { name: "要素属性" });
const collapsedWidth = (await featurePanel.boundingBox())?.width ?? 0;
await expect(page.getByText("1 份")).toBeVisible();
await expect(page.getByText("设备类别").first()).toBeHidden();
await page.getByText("GIS 台账属性").click();
await expect.poll(async () => (await featurePanel.boundingBox())?.width ?? 0)
.toBeGreaterThan(collapsedWidth + 100);
await expect(page.getByText("流量仪", { exact: true })).toBeVisible();
await expect(page.getByText("设备类别").first()).toBeHidden();
const datasetIndicator = page
.getByText("流量仪", { exact: true })
.locator("xpath=ancestor::summary")
.locator('[data-disclosure-indicator="accordion"]');
await expect.poll(() => datasetIndicator.evaluate((element) => getComputedStyle(element).transform))
.toBe("none");
await page.getByText("流量仪", { exact: true }).click();
await expect.poll(() => datasetIndicator.evaluate((element) => getComputedStyle(element).transform))
.toContain("-1");
await expect(page.getByText("设备类别").first()).toBeVisible();
await expect(page.getByText("口径", { exact: true })).toBeVisible();
await expect(page.getByText("300 mm")).toBeVisible();
await page.setViewportSize({ width: 375, height: 812 });
await expect.poll(async () => (await featurePanel.boundingBox())?.x ?? -1)
.toBeGreaterThanOrEqual(12);
await expect.poll(async () => {
const box = await featurePanel.boundingBox();
return (box?.x ?? 0) + (box?.width ?? 0);
}).toBeLessThanOrEqual(363);
});
async function clickFirstScadaFeature(page: Page) {
const fireMapClick = () => page.evaluate(() => {
const map = window.__waterNetworkMap;
const feature = map?.querySourceFeatures("scada")[0];
if (!map || feature?.geometry.type !== "Point") {
throw new Error("No SCADA point is available for the feature popover test");
}
const projectedPoint = map.project(feature.geometry.coordinates as [number, number]);
map.fire("click", {
point: projectedPoint,
lngLat: map.unproject(projectedPoint),
originalEvent: new MouseEvent("click")
});
});
const closeButton = page.getByRole("button", { name: "关闭要素信息" });
await expect.poll(async () => {
await fireMapClick();
return closeButton.isVisible();
}).toBe(true);
}
async function mockFeaturePanelRuntime(page: Page) {
await page.route("**/runtime-config.js", async (route) => {
await route.fulfill({
contentType: "application/javascript",
body: `globalThis.__TJWATER_CONFIG__ = {
TJWATER_AUTH_MODE: "disabled",
TJWATER_MAPBOX_ACCESS_TOKEN: "",
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_WORKBENCH_RUNTIME_MODE: "demo",
TJWATER_ENABLE_DEV_PANEL: "false",
TJWATER_ENABLE_MSW: "false"
};`
});
});
await page.route("https://feature-panel-map.invalid/**", async (route) => {
await route.fulfill({ status: 204, body: "" });
});
await mockScadaApi(page, [{
device_id: "SCADA-OVERLAP-1",
device_type: "pressure",
node_id: "J-1",
link_id: null,
api_query_id: "overlap-query-1",
measurement_unit: "MPa",
transmission_mode: "realtime",
transmission_frequency: "2s",
reliability: 100,
x: 1,
y: 2,
longitude: 121.5,
latitude: 31
}]);
}
+10 -2
View File
@@ -4,7 +4,7 @@ test("Dev Panel opens as a floating desktop panel and closes with Escape", async
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean(window.__waterNetworkMap));
await page.waitForFunction(() => Boolean(
window.__waterNetworkMap?.getLayer("scada-hit")
window.__waterNetworkMap?.getLayer("scada-pressure-hit")
));
const trigger = page.getByRole("button", { name: "切换地图 Dev Panel" });
await expect(trigger).toBeVisible();
@@ -33,6 +33,13 @@ test("Dev Panel opens as a floating desktop panel and closes with Escape", async
await panel.getByRole("button", { name: "开启水流" }).click();
await expect(panel.getByRole("button", { name: "关闭水流" })).toBeEnabled();
const sourceSelect = panel.getByRole("combobox", { name: "业务源" });
await sourceSelect.click();
await expect(page.getByRole("option", { name: "管线", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(panel).toBeVisible();
await expect(sourceSelect).toBeFocused();
await page.keyboard.press("Escape");
await expect(panel).toBeHidden();
});
@@ -54,7 +61,8 @@ test("labels remain active per business group and clear globally", async ({ page
const panel = page.getByTestId("map-dev-panel");
const apply = panel.getByRole("button", { name: "应用标注", exact: true });
await apply.click();
await panel.getByLabel("业务图层组").selectOption("junctions");
await panel.getByRole("combobox", { name: "业务图层组" }).click();
await page.getByRole("option", { name: "节点", exact: true }).click();
await apply.click();
await expect.poll(() => page.evaluate(() => ({
+2 -2
View File
@@ -5,9 +5,9 @@ test("map notice is centered in the mobile viewport", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开用户菜单" }).click();
await page.getByRole("menuitem", { name: /查看运行状态/ }).click();
await page.getByRole("menuitem", { name: /操作参考/ }).click();
const notice = page.locator('[role="status"]').filter({ hasText: "数据状态" }).first();
const notice = page.locator('[role="status"]').filter({ hasText: "快捷键" }).first();
await expect(notice).toBeVisible();
const box = await notice.boundingBox();
+6 -3
View File
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { mockHierarchicalWorkOrder } from "./support/mock-hierarchical-work-order";
test.describe("mobile workbench sheet", () => {
test.use({ viewport: { width: 390, height: 844 } });
@@ -131,22 +132,24 @@ test.describe("mobile workbench sheet", () => {
await sheet.getByRole("button", { name: /SCADA 数据诊断/ }).first().click();
await expect(sheet.getByRole("button", { name: "收起工况任务" })).toBeVisible();
const workflowTitle = sheet.getByText("SCADA 诊断流程", { exact: true });
const workflowTitle = sheet.getByText("演示执行流程 · SCADA 诊断流程", { exact: true });
await expect(workflowTitle).toBeVisible();
await expect(workflowTitle.locator("..").locator("svg")).toHaveCount(0);
});
test("keeps the waiting-for-reply state readable in the narrow sheet", async ({ page }) => {
await mockHierarchicalWorkOrder(page, "reply");
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开工况任务" }).click();
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
const awaitingReplyWorkOrder = sheet
.locator('button[aria-pressed]')
.filter({ hasText: "系统工单:泵站边界复核复令" });
.filter({ hasText: "演示工单:泵站边界复核复令" });
await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible();
await awaitingReplyWorkOrder.click();
await expect(sheet.getByText("当前阶段:待复令", { exact: true })).toBeVisible();
await expect(sheet.getByText("2/3 完成", { exact: true })).toBeVisible();
await expect(sheet.getByText("确认模型边界已同步更新 · 待复令", { exact: true })).toBeVisible();
});
test("clears mobile sheet state when crossing into the desktop layout", async ({ page }) => {
+367
View File
@@ -0,0 +1,367 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import {
createAnalysisRuntimeSnapshot,
MOCK_SPATIAL_ANALYSIS
} from "./support/mock-analysis-runtime";
import { mockScadaApi } from "./support/mock-scada-api";
test.beforeEach(async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await prepareWorkbench(page);
});
test("assigned desktop panels avoid native form controls and horizontal overflow", async ({
page
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.evaluate(() => document.fonts.ready);
await expect(page.locator("[data-sonner-toast]")).toHaveCount(0, { timeout: 15_000 });
const mapToolbar = page.getByRole("navigation", { name: "地图工具" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(conditionPanel).toBeVisible();
const [toolbarBox, conditionBox] = await Promise.all([
mapToolbar.boundingBox(),
conditionPanel.boundingBox()
]);
expect(toolbarBox).not.toBeNull();
expect(conditionBox).not.toBeNull();
expect(Math.abs(toolbarBox!.y - conditionBox!.y)).toBeLessThanOrEqual(1);
await page.getByRole("button", { name: "工况任务", exact: true }).click();
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
await expectContained(agentPanel, 1440);
await page.getByRole("button", { name: "打开 Agent 历史记录" }).click();
await expectContained(page.getByRole("region", { name: "Agent 历史记录" }), 1440);
await page.setViewportSize({ width: 1280, height: 900 });
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
const layerPanel = page.getByRole("region", { name: "图层" });
await expectContained(layerPanel, 1280);
await expect(layerPanel.getByText("监测点", { exact: true })).toBeVisible();
await expect(layerPanel.getByRole("button", { name: /压力监测点/ })).toBeVisible();
await expect(layerPanel.getByRole("button", { name: /流量监测点/ })).toBeVisible();
await expect(layerPanel.getByRole("button", { name: /水质监测点/ })).toBeVisible();
const sourceGisGroup = layerPanel.locator("details").filter({ hasText: "GIS 原始数据" });
await expect(sourceGisGroup).not.toHaveAttribute("open", "");
await sourceGisGroup.locator("summary").click();
await expect(sourceGisGroup).toHaveAttribute("open", "");
await expect(sourceGisGroup.getByText("2020-04-23 · 全网除头桥快照")).toBeVisible();
await expect(sourceGisGroup.getByText("2020-08-11 · 专项管线台账")).toBeVisible();
await expect(sourceGisGroup.getByText("1 要素", { exact: false }).first()).toBeVisible();
await sourceGisGroup.getByRole("button", { name: /GIS 管线/ }).click();
await sourceGisGroup.getByRole("button", { name: /DN100\+ 台账/ }).click();
for (const facilityLabel of [
"GIS 阀门",
"排水阀",
"流量仪",
"消火栓",
"接水点",
"用户水表",
"非居民水表"
]) {
await sourceGisGroup.getByRole("button", { name: new RegExp(facilityLabel) }).click();
}
await expect(sourceGisGroup).toHaveAttribute("open", "");
await page.screenshot({
path: "test-results/non-map-panels/1280-layer-panel.png",
fullPage: true
});
await page.getByRole("button", { name: /图例:查看当前地图符号/ }).click();
const legendPanel = page.getByRole("region", { name: "图例" });
await expectContained(legendPanel, 1280);
await expect(legendPanel.getByRole("region", { name: "模型管线" })).toBeVisible();
await expect(legendPanel.getByRole("region", { name: "供水设施" })).toBeVisible();
await expect(legendPanel.getByRole("region", { name: "监测点" })).toBeVisible();
const aprilSourcePipeLegend = legendPanel.getByRole("region", {
name: "原始 GIS 管线 · 2020-04-23"
});
await expect(aprilSourcePipeLegend).toBeVisible();
await expect(aprilSourcePipeLegend.getByText("DN600 及以上", { exact: true })).toBeVisible();
await expect(aprilSourcePipeLegend.getByText("DN300-DN599", { exact: true })).toBeVisible();
await expect(aprilSourcePipeLegend.getByText("DN300 以下", { exact: true })).toBeVisible();
await expect(aprilSourcePipeLegend.getByText("未标注口径", { exact: true })).toBeVisible();
const augustSourcePipeLegend = legendPanel.getByRole("region", {
name: "原始 GIS 管线 · 2020-08-11"
});
await expect(augustSourcePipeLegend).toBeVisible();
await expect(augustSourcePipeLegend.getByText("DN100 及以上", { exact: true })).toBeVisible();
const sourceFacilityLegend = legendPanel.getByRole("region", { name: "原始 GIS 设施" });
await expect(sourceFacilityLegend).toBeVisible();
await expect(sourceFacilityLegend.locator('img[src^="/map/gis-"]')).toHaveCount(7);
await expect(legendPanel.getByRole("region", { name: "分析覆盖" })).toHaveCount(0);
await expect(legendPanel.locator(".status-dot")).toHaveCount(0);
await expect(legendPanel.locator('svg line[stroke-width="5"]')).not.toHaveCount(0);
await expect(legendPanel.locator('svg line[stroke-width="2"]')).not.toHaveCount(0);
await page.screenshot({
path: "test-results/non-map-panels/1280-legend-panel.png",
fullPage: true
});
await sourceFacilityLegend.scrollIntoViewIfNeeded();
await page.screenshot({
path: "test-results/non-map-panels/1280-legend-source-gis.png",
fullPage: true
});
await page.getByRole("button", { name: /测量:距离与范围测量/ }).click();
const measurePanel = page.getByRole("region", { name: "测量", exact: true });
await expectContained(measurePanel, 1280);
await expect(measurePanel).toHaveCSS("width", "324px");
const meterUnit = measurePanel.getByRole("button", { name: "m", exact: true });
const kilometerUnit = measurePanel.getByRole("button", { name: "km", exact: true });
await expect(kilometerUnit).toHaveAttribute("aria-pressed", "true");
await expect(kilometerUnit).toHaveClass(/bg-blue-600/);
await meterUnit.click();
await expect(meterUnit).toHaveAttribute("aria-pressed", "true");
await expect(meterUnit).toHaveClass(/bg-blue-600/);
await kilometerUnit.click();
const distanceMode = measurePanel.getByRole("button", { name: "距离", exact: true });
const areaMode = measurePanel.getByRole("button", { name: "面积", exact: true });
const segmentMode = measurePanel.getByRole("button", { name: "管段", exact: true });
await expect(distanceMode).toHaveAttribute("aria-pressed", "true");
await areaMode.focus();
await expect(areaMode).toBeFocused();
await page.keyboard.press("Enter");
await expect(areaMode).toHaveAttribute("aria-pressed", "true");
await expect(measurePanel.getByText("当前面积", { exact: true })).toBeVisible();
await segmentMode.click();
await expect(segmentMode).toHaveAttribute("aria-pressed", "true");
await expect(measurePanel.getByText("资产长度", { exact: true })).toBeVisible();
await distanceMode.click();
await expect(measurePanel.getByRole("button", { name: "复制结果" })).toBeDisabled();
await expect(measurePanel.getByRole("button", { name: "清除测量" })).toBeDisabled();
await expect(measurePanel.getByText("等待开始", { exact: true })).toBeVisible();
const startMeasureButton = measurePanel.getByRole("button", { name: /开始测量/ });
await expect(startMeasureButton).toHaveClass(/bg-slate-50\/90/);
await expect(startMeasureButton).not.toHaveClass(/bg-blue-600/);
await expect(startMeasureButton).toContainText("在地图中连续取点");
await startMeasureButton.click();
const stopMeasureButton = measurePanel.getByRole("button", { name: /停止测量/ });
await expect(stopMeasureButton).toBeVisible();
await expect(stopMeasureButton).toHaveAttribute("data-state", "active");
await expect(stopMeasureButton).toHaveClass(/border-blue-300/);
await expect(stopMeasureButton).toContainText("暂停并保留当前读数");
await expect(stopMeasureButton).toContainText("进行中");
await expect(measurePanel.getByText("测量中", { exact: true })).toBeVisible();
await page.screenshot({
path: "test-results/non-map-panels/1280-measure-panel-active.png",
fullPage: true
});
await stopMeasureButton.click();
const resumedStartMeasureButton = measurePanel.getByRole("button", { name: /开始测量/ });
await expect(resumedStartMeasureButton).toBeEnabled();
await expect(resumedStartMeasureButton).toHaveClass(/bg-slate-50\/90/);
await expect(resumedStartMeasureButton).not.toHaveClass(/bg-blue-600/);
await page.screenshot({
path: "test-results/non-map-panels/1280-measure-panel.png",
fullPage: true
});
await page.getByRole("button", { name: /标注:管理地图标注/ }).click();
await expectContained(page.getByRole("region", { name: "标注" }), 1280);
await page.screenshot({
path: "test-results/non-map-panels/1280-tool-panels.png",
fullPage: true
});
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const devPanel = page.getByRole("complementary", { name: "地图开发面板" });
await expectContained(devPanel, 1280);
await expect(devPanel.locator("select")).toHaveCount(0);
const layerGroup = devPanel.getByRole("combobox", { name: "业务图层组" });
await layerGroup.focus();
await layerGroup.press("Enter");
const junctionOption = page.getByRole("option", { name: "节点" });
await expect(junctionOption).toBeVisible();
await junctionOption.focus();
await page.keyboard.press("Enter");
await expect(layerGroup).toHaveText("节点");
await expect(
devPanel.locator('input:not([type="range"]):not([type="color"]):not([type="checkbox"])').first()
).toHaveCSS("font-family", /Noto Sans SC/);
await page.screenshot({ path: "test-results/non-map-panels/1280-dev-panel.png", fullPage: true });
});
for (const width of [375, 320]) {
test(`mobile Agent and tool panels fit ${width}px without horizontal overflow`, async ({
page
}) => {
await page.setViewportSize({ width, height: 844 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.evaluate(() => document.fonts.ready);
await expect(page.locator("[data-sonner-toast]")).toHaveCount(0, { timeout: 15_000 });
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(page.locator("[data-sonner-toast]")).toHaveCount(0, { timeout: 15_000 });
await page.screenshot({
path: `test-results/non-map-panels/${width}-agent.png`,
fullPage: true
});
await page.getByRole("button", { name: "关闭 Agent 面板" }).click();
await page.getByRole("button", { name: "更多:更多地图操作" }).click();
const toolPanel = page.getByRole("region", { name: "更多" });
await expectContained(toolPanel, width);
await page.screenshot({
path: `test-results/non-map-panels/${width}-more.png`,
fullPage: true
});
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
const mobileLayerPanel = page.getByRole("region", { name: "图层" });
await expectContained(mobileLayerPanel, width);
await expect(mobileLayerPanel.getByRole("button", { name: /管线,按管径分级/ })).toBeVisible();
await expect(mobileLayerPanel.getByText("GIS 原始数据", { exact: true })).toBeAttached();
await page.screenshot({
path: `test-results/non-map-panels/${width}-layers.png`,
fullPage: true
});
await page.getByRole("button", { name: /图例:查看当前地图符号/ }).click();
const mobileLegendPanel = page.getByRole("region", { name: "图例" });
await expectContained(mobileLegendPanel, width);
await expect(mobileLegendPanel.getByRole("region", { name: "模型管线" })).toBeVisible();
await expect(mobileLegendPanel.getByRole("region", { name: "供水设施" })).toBeAttached();
await expect(mobileLegendPanel.getByRole("region", { name: "监测点" })).toBeAttached();
await expect(mobileLegendPanel.getByRole("region", { name: /原始 GIS 管线/ })).toHaveCount(0);
await page.screenshot({
path: `test-results/non-map-panels/${width}-legend.png`,
fullPage: true
});
await page.getByRole("button", { name: /测量:距离与范围测量/ }).click();
const mobileMeasurePanel = page.getByRole("region", { name: "测量", exact: true });
await expectContained(mobileMeasurePanel, width);
await expect(mobileMeasurePanel.getByRole("group", { name: "测量类型" })).toBeVisible();
await expect(mobileMeasurePanel.getByRole("button", { name: "复制结果" })).toBeDisabled();
await expect(mobileMeasurePanel.getByRole("button", { name: "清除测量" })).toBeDisabled();
await mobileMeasurePanel.getByRole("button", { name: "面积", exact: true }).click();
await expect(mobileMeasurePanel.getByText("当前面积", { exact: true })).toBeVisible();
await mobileMeasurePanel.getByRole("button", { name: /开始测量/ }).click();
await expect(mobileMeasurePanel.getByText("测量中", { exact: true })).toBeVisible();
await expect(mobileMeasurePanel.getByRole("button", { name: /停止测量/ })).toContainText("进行中");
await mobileMeasurePanel.getByRole("button", { name: /停止测量/ }).click();
await page.screenshot({
path: `test-results/non-map-panels/${width}-measure.png`,
fullPage: true
});
if (width === 375) {
await page.getByRole("button", { name: "打开工况任务" }).click();
const conditionSheet = page.getByRole("region", { name: "工况任务抽屉" });
await expectContained(conditionSheet, width);
await page.screenshot({
path: "test-results/non-map-panels/375-condition-feed.png",
fullPage: true
});
await conditionSheet
.getByRole("button", { name: /SCADA 数据诊断/ })
.first()
.click();
await expectContained(conditionSheet, width);
await page.screenshot({
path: "test-results/non-map-panels/375-condition-detail.png",
fullPage: true
});
}
});
}
for (const width of [1280, 375]) {
test(`artifact preview and document remain contained at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: Math.max(width, 1280), height: 900 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.evaluate(() => document.fonts.ready);
await expect(page.locator("[data-sonner-toast]")).toHaveCount(0, { timeout: 15_000 });
await page.getByRole("button", { name: "打开最新后端分析成果" }).click();
await page.getByRole("button", { name: "将成果收起为预览" }).click();
await page.setViewportSize({ width, height: 900 });
const preview = page.getByRole("complementary", { name: "后端分析成果预览" });
await expectContained(preview, width);
await page.screenshot({
path: `test-results/non-map-panels/${width}-artifact-preview.png`,
fullPage: true
});
await preview.getByRole("button", { name: "打开成果" }).click();
const workspace = page.locator("#artifact-workspace");
await expectContained(workspace, width);
const heading = workspace.getByRole("heading", { name: "北区爆管定位" });
await expect(heading).toBeVisible();
await expect(heading).toHaveCSS("font-family", /Noto Sans SC/);
if (width === 1280) {
const [railBox, headingBox] = await Promise.all([
page.getByRole("complementary", { name: "Agent 折叠栏" }).boundingBox(),
heading.boundingBox()
]);
expect(railBox).not.toBeNull();
expect(headingBox).not.toBeNull();
expect(headingBox!.x).toBeGreaterThanOrEqual(railBox!.x + railBox!.width);
}
await expect(page.locator("[data-sonner-toast]")).toHaveCount(0, { timeout: 15_000 });
await page.screenshot({
path: `test-results/non-map-panels/${width}-artifact-document.png`,
fullPage: true
});
});
}
async function expectContained(locator: Locator, viewportWidth: number) {
await expect(locator).toBeVisible();
const box = await locator.boundingBox();
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(viewportWidth + 1);
expect(await locator.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(
true
);
}
async function prepareWorkbench(page: Page) {
await mockAgentApi(page);
await page.route("**/runtime-config.js", (route) =>
route.fulfill({
contentType: "application/javascript",
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
TJWATER_AUTH_MODE: "disabled",
TJWATER_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_ENABLE_DEV_PANEL: "true",
TJWATER_ENABLE_MSW: "false"
})}`
})
);
await page.route("https://non-map-panels.invalid/**", (route) => {
const url = new URL(route.request().url());
if (url.searchParams.get("resultType") === "hits") {
return route.fulfill({
contentType: "application/xml",
body: '<wfs:FeatureCollection numberMatched="1" numberReturned="0" />'
});
}
return route.fulfill({ status: 204 });
});
await page.route("https://demotiles.maplibre.org/**", (route) => route.fulfill({ status: 204 }));
await page.route("**/*.riv", (route) => route.abort());
await mockScadaApi(page, [], createAnalysisRuntimeSnapshot(MOCK_SPATIAL_ANALYSIS));
}
@@ -0,0 +1,547 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
test.describe("外围样板管理", () => {
test.beforeEach(async ({ page }) => {
await mockAgentApi(page);
await page.route("**/runtime-config.js", (route) =>
route.fulfill({
contentType: "text/javascript",
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
TJWATER_AUTH_MODE: "disabled",
TJWATER_SERVER_API_BASE_URL: "http://server.test",
TJWATER_MAP_URL: "http://map.test/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next"
})};`
})
);
await page.route("http://server.test/api/v1/projects**", (route) =>
route.fulfill({
json: {
items: [
{
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e",
gs_workspace: "tjwater_next",
status: "active"
}
],
total: 1,
limit: 1000,
offset: 0
}
})
);
await page.route("http://server.test/api/v1/scada-devices**", (route) =>
route.fulfill({ json: { items: [], total: 0, limit: 1000, offset: 0 } })
);
await page.route("http://map.test/**", (route) => route.fulfill({ status: 204 }));
});
test("展示已配置的历史回放与合成样本", async ({ page }, testInfo) => {
const observationQueries: Record<string, string>[] = [];
const writeRequests: string[] = [];
await page.route("**/runtime-config.js", (route) =>
route.fulfill({
contentType: "text/javascript",
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
TJWATER_AUTH_MODE: "disabled",
TJWATER_SERVER_API_BASE_URL: "http://server.test",
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "http://peripheral.test",
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: "demo",
TJWATER_PERIPHERAL_SAMPLE_DEMO_START: "2026-04-30T08:00:00Z",
TJWATER_PERIPHERAL_SAMPLE_DEMO_END: "2026-04-30T16:00:00Z"
})};`
})
);
await page.route("http://server.test/api/v1/projects**", (route) =>
route.fulfill({
json: {
items: [
{
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e",
gs_workspace: "tjwater_next",
status: "active"
}
],
total: 1,
limit: 1000,
offset: 0
}
})
);
const feedback = {
feedback_id: "40000000-0000-4000-8000-000000000001",
task_id: "10000000-0000-4000-8000-000000000001",
recorded_by: "operator",
recorded_at: "2026-04-30T09:10:00Z",
occurred_at: "2026-04-30T09:00:00Z",
applicable_time: { start: "2026-04-30T08:00:00Z", end: "2026-04-30T16:00:00Z" },
asset_ids: ["DEMO-P001"],
finding: "instrument_fault",
notes: "压力变送器零点漂移",
attachment_refs: [],
origin: "synthetic"
};
const task = {
task_id: feedback.task_id,
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e",
revision: 3,
title: "DEMO-P001 测点偏差核查",
summary: "校验记录外围核查",
source: {
system: "next",
task_id: "20000000-0000-4000-8000-000000000001",
object_type: "validation",
object_id: "30000000-0000-4000-8000-000000000001",
revision: 2
},
asset_ids: ["DEMO-P001"],
analysis_window: { start: "2026-04-30T08:00:00Z", end: "2026-04-30T16:00:00Z" },
evidence_refs: [],
requested_check: "核查仪表状态",
status: "pending_review",
origin: "synthetic",
created_at: "2026-04-30T08:05:00Z",
updated_at: "2026-04-30T09:10:00Z",
created_by: "operator",
history: [
{
sequence: 1,
from_status: null,
to_status: "pending",
actor: "operator",
at: "2026-04-30T08:05:00Z",
reason: "提交样板核查"
},
{
sequence: 2,
from_status: "processing",
to_status: "pending_review",
actor: "operator",
at: "2026-04-30T09:10:00Z",
reason: "等待审核"
}
],
feedback: [feedback]
};
await page.route("http://peripheral.test/api/sample/v1/**", async (route) => {
const url = new URL(route.request().url());
if (route.request().method() !== "GET") writeRequests.push(route.request().url());
if (url.pathname.endsWith("/monitoring/observations"))
observationQueries.push(Object.fromEntries(url.searchParams));
if (url.pathname.endsWith("/context"))
return route.fulfill({
json: {
protocol_version: "tjwater-peripheral-sample@1",
origin: "synthetic",
project_id: task.project_id,
user_id: "50000000-0000-4000-8000-000000000001",
can_write: true,
scenarios: ["normal"],
managed_resources: { work_orders: "available", test_data: "available" },
state_transitions: {
pending: ["processing"],
processing: ["pending_review"],
pending_review: ["processing", "completed"],
completed: []
}
}
});
if (url.pathname.endsWith("/scenario"))
return route.fulfill({ json: { scenario: "normal", revision: 1 } });
if (url.pathname.endsWith("/tasks"))
return route.fulfill({ json: { items: [task], next_cursor: null } });
if (url.pathname.endsWith("/work-orders"))
return route.fulfill({ json: { items: createSourceWorkOrders(task.project_id), next_cursor: null } });
if (url.pathname.endsWith("/test-data"))
return route.fulfill({ json: { items: [], next_cursor: null } });
if (url.pathname.endsWith("/monitoring/points"))
return route.fulfill({
json: {
items: Array.from({ length: 118 }, (_, index) => ({
point_id: `SCADA-${index + 1}`,
label: `压力测点 ${index + 1}`,
unit: "m",
status: "active",
origin: "historical_replay"
})),
next_cursor: null,
origin: "historical_replay",
source_id: "tjwater_v2"
}
});
if (url.pathname.endsWith("/monitoring/alerts"))
return route.fulfill({
json: {
availability: "not_configured",
items: [],
origin: "historical_replay",
reason: "来源未配置",
source_id: "tjwater_v2"
}
});
if (url.pathname.endsWith("/monitoring/observations"))
return route.fulfill({
json: {
origin: "historical_replay",
source_id: "tjwater_v2",
source_revision: "2026-04-30",
truncated: false,
items: [
{
record_id: "reading-1",
point_id: "SCADA-1",
observed_at: "2026-04-30T08:15:00Z",
value: 31.42,
quality: "valid",
unit: "m"
},
{
record_id: "reading-2",
point_id: "SCADA-1",
observed_at: "2026-04-30T08:30:00Z",
value: null,
quality: "missing",
unit: "m"
}
]
}
});
if (url.pathname.endsWith("/demo-data"))
return route.fulfill({ json: createDemoDataset(task.project_id) });
return route.fulfill({
status: 404,
json: { code: "NOT_FOUND", message: "not found", effect: "none", request_id: null }
});
});
await page.goto("/");
await page.getByRole("button", { name: "外围样板管理" }).click();
await expect(page.getByText(/118 个测点 · 告警来源未配置 · 真实项目样本回放/)).toBeVisible();
const hierarchy = page.getByLabel("任务层级");
await expect(hierarchy).toContainText("外围合成数据核查");
await expect(hierarchy).toContainText("3 个子任务");
await expect(page.getByLabel("子任务阶段 合成通信核查")).toContainText(/派发.*当前阶段.*执行.*待开始.*复令.*待开始/);
await expect(page.getByLabel("子任务阶段 合成压力偏差复核")).toContainText(/派发.*已完成.*执行.*当前阶段.*复令.*待开始/);
await expect(page.getByLabel("子任务阶段 合成零流量样本确认")).toContainText(/派发.*已完成.*执行.*已完成.*复令.*已完成/);
const scenarioSelect = page.getByRole("combobox", { name: "场景" });
await scenarioSelect.focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("listbox")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("listbox")).toBeHidden();
await expect(scenarioSelect).toBeFocused();
const pointSelect = page.getByRole("combobox", { name: "测点", exact: true });
await pointSelect.click();
await page.getByRole("option", { name: "压力测点 2 · m" }).click();
await expect(pointSelect).toContainText("压力测点 2 · m");
const historyMode = page.getByRole("combobox", { name: "时间模式", exact: true });
const historyStart = page.getByRole("button", { name: "开始(UTC)", exact: true });
const historyEnd = page.getByRole("button", { name: "结束(UTC)", exact: true });
await expect(historyStart).toContainText("2026-04-30 08:00");
await historyMode.click();
await page.getByRole("option", { name: "最近 8 小时", exact: true }).click();
await expect(page.getByText(/UTC 历史窗口 · 最近 8 小时/)).toBeVisible();
await expect(historyStart).not.toContainText("2026-04-30 08:00");
await historyMode.click();
await page.getByRole("option", { name: "演示历史窗口", exact: true }).click();
await expect(historyStart).toContainText("2026-04-30 08:00");
await expect(historyEnd).toContainText("2026-04-30 16:00");
await historyStart.click();
const startDialog = page.getByRole("dialog", { name: "选择开始(UTC)" });
await startDialog.getByRole("gridcell", { name: "2026年4月29日", exact: true }).click();
await startDialog.getByRole("combobox", { name: "分钟" }).click();
await page.getByRole("option", { name: "15", exact: true }).click();
await startDialog.getByRole("button", { name: "确认", exact: true }).click();
await expect(historyStart).toContainText("2026-04-29 08:15");
await historyEnd.click();
const endDialog = page.getByRole("dialog", { name: "选择结束(UTC)" });
await endDialog.getByRole("combobox", { name: "分钟" }).click();
await page.getByRole("option", { name: "45", exact: true }).click();
await endDialog.getByRole("button", { name: "确认", exact: true }).click();
await expect(historyEnd).toContainText("2026-04-30 16:45");
expect(observationQueries).toEqual([]);
await page.getByRole("button", { name: "读取历史读数" }).click();
await expect(page.getByText("31.42 m")).toBeVisible();
await expect(page.getByText("缺失")).toBeVisible();
expect(observationQueries).toEqual([
{ point_id: "SCADA-2", start: "2026-04-29T08:15:00Z", end: "2026-04-30T16:45:00Z" }
]);
await page.getByText("合成演示样本", { exact: true }).click();
await page.getByRole("button", { name: "手动读取合成样本" }).click();
await expect(
page.getByText(/5 个虚拟测点,240 条读数,9 条状态,2 条告警,4 条运行事件/)
).toBeVisible();
await expect(page.getByText("0 m", { exact: true }).first()).toBeVisible();
await expect(page.getByText("缺失", { exact: true }).first()).toBeVisible();
for (const width of [320, 375, 1280]) {
await page.setViewportSize({ width, height: 720 });
await pointSelect.scrollIntoViewIfNeeded();
await pointSelect.click();
await expect(page.getByRole("listbox")).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
width
);
await testInfo.attach(`peripheral-select-${width}px`, {
body: await page.screenshot(),
contentType: "image/png"
});
await page.keyboard.press("Escape");
await historyStart.click();
await expect(startDialog).toBeVisible();
await expect(
startDialog.locator(
'input[type="datetime-local"], input[type="date"], input[type="time"], select'
)
).toHaveCount(0);
await startDialog.getByRole("gridcell", { name: "2026年4月28日", exact: true }).click();
const calendarBox = await startDialog.boundingBox();
expect(calendarBox!.x).toBeGreaterThanOrEqual(0);
expect(calendarBox!.x + calendarBox!.width).toBeLessThanOrEqual(width);
expect(
await startDialog.evaluate((element) => element.scrollWidth <= element.clientWidth)
).toBe(true);
await page.screenshot({ path: `test-results/peripheral-datetime/${width}-calendar.png` });
await page.keyboard.press("Escape");
await expect(startDialog).toBeHidden();
await expect(historyStart).toBeFocused();
await expect(historyStart).toContainText("2026-04-29 08:15");
}
expect(observationQueries).toHaveLength(1);
expect(writeRequests).toEqual([]);
});
test("未配置时从顶栏进入且不暗连外围服务", async ({ page }) => {
const peripheralRequests: string[] = [];
page.on("request", (request) => {
if (request.url().includes("/api/sample/v1")) peripheralRequests.push(request.url());
});
await page.goto("/");
await page.getByRole("button", { name: "外围样板管理" }).click();
await expect(page.getByRole("complementary", { name: "外围样板管理" })).toBeVisible();
await expect(
page
.getByRole("complementary", { name: "外围样板管理" })
.getByText("外围系统模拟接入,不是正式工单/现场结果", { exact: true })
.last()
).toBeVisible();
await expect(page.getByText(/当前不会连接任何外围服务/)).toBeVisible();
expect(peripheralRequests).toEqual([]);
});
test("增量存储未供给时保留原有外围能力且不请求缺表资源", async ({ page }) => {
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const managedRequests: string[] = [];
await page.route("**/runtime-config.js", (route) => route.fulfill({
contentType: "text/javascript",
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
TJWATER_AUTH_MODE: "disabled",
TJWATER_SERVER_API_BASE_URL: "http://server.test",
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "http://peripheral.test"
})}`
}));
await page.route("http://peripheral.test/api/sample/v1/**", (route) => {
const path = new URL(route.request().url()).pathname;
if (path.includes("/work-orders") || path.includes("/test-data")) {
managedRequests.push(path);
return route.fulfill({ status: 503, json: {} });
}
if (path.endsWith("/context")) return route.fulfill({ json: {
protocol_version: "tjwater-peripheral-sample@1", origin: "synthetic", project_id: projectId,
user_id: "50000000-0000-4000-8000-000000000001", can_write: true,
scenarios: ["normal"], managed_resources: {
work_orders: "not_configured", test_data: "not_configured"
}, state_transitions: { pending: [], processing: [], pending_review: [], completed: [] }
} });
if (path.endsWith("/scenario")) return route.fulfill({ json: { scenario: "normal", revision: 1 } });
if (path.endsWith("/tasks")) return route.fulfill({ json: { items: [], next_cursor: null } });
if (path.endsWith("/monitoring/points")) return route.fulfill({ json: {
items: [], next_cursor: null, origin: "historical_replay", source_id: "tjwater_v2"
} });
if (path.endsWith("/monitoring/alerts")) return route.fulfill({ json: {
items: [], availability: "not_configured", reason: "告警源未配置",
origin: "historical_replay", source_id: "tjwater_v2"
} });
return route.fulfill({ status: 404, json: {} });
});
await page.goto("/");
await page.getByRole("button", { name: "外围样板管理" }).click();
const panel = page.getByRole("complementary", { name: "外围样板管理" });
await expect(panel.getByText("测试工单存储待供给", { exact: true })).toBeVisible();
await expect(panel.getByText("测试数据存储待供给", { exact: true })).toBeVisible();
await expect(panel.getByText("连接与演练场景", { exact: true })).toBeVisible();
expect(managedRequests).toEqual([]);
});
test("创建、修改并软删除测试工单和测试数据", async ({ page }) => {
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
let workOrders: Record<string, unknown>[] = [];
let testData: Record<string, unknown>[] = [];
await page.route("**/runtime-config.js", (route) => route.fulfill({
contentType: "text/javascript",
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({ TJWATER_AUTH_MODE: "disabled", TJWATER_SERVER_API_BASE_URL: "http://server.test", TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "http://peripheral.test" })}`
}));
await page.route("http://peripheral.test/api/sample/v1/**", async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname;
const now = "2026-09-18T00:00:00Z";
if (path.endsWith("/context")) return route.fulfill({ json: { protocol_version: "tjwater-peripheral-sample@1", origin: "synthetic", project_id: projectId, user_id: "50000000-0000-4000-8000-000000000001", can_write: true, scenarios: ["normal"], managed_resources: { work_orders: "available", test_data: "available" }, state_transitions: { pending: ["processing"], processing: ["pending_review"], pending_review: ["processing", "completed"], completed: [] } } });
if (path.endsWith("/scenario")) return route.fulfill({ json: { scenario: "normal", revision: 1 } });
if (path.endsWith("/tasks")) return route.fulfill({ json: { items: [], next_cursor: null } });
if (path.endsWith("/monitoring/points")) return route.fulfill({ json: { items: [], next_cursor: null, origin: "historical_replay", source_id: "test" } });
if (path.endsWith("/monitoring/alerts")) return route.fulfill({ json: { items: [], availability: "not_configured", reason: "未配置", origin: "historical_replay", source_id: "test" } });
if (path.endsWith("/demo-data")) return route.fulfill({ json: createDemoDataset(projectId) });
if (path.endsWith("/work-orders") && request.method() === "GET") return route.fulfill({ json: { items: workOrders, next_cursor: null } });
if (path.endsWith("/work-orders") && request.method() === "POST") {
const body = request.postDataJSON();
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" };
delete item.request_id; workOrders = [item]; return route.fulfill({ status: 201, json: item });
}
if (path.includes("/work-orders/") && request.method() === "PATCH") {
const body = request.postDataJSON(); workOrders = [{ ...workOrders[0], ...body, revision: 2 }]; delete workOrders[0].request_id; delete workOrders[0].expected_revision;
return route.fulfill({ json: workOrders[0] });
}
if (path.includes("/work-orders/") && request.method() === "DELETE") { workOrders = []; return route.fulfill({ json: { resource_type: "work_order", resource_id: path.split("/").at(-1), revision: 3, deleted: true } }); }
if (path.endsWith("/test-data") && request.method() === "GET") return route.fulfill({ json: { items: testData, next_cursor: null } });
if (path.endsWith("/test-data") && request.method() === "POST") {
const body = request.postDataJSON(); const item = { ...body, record_id: "60000000-0000-4000-8000-000000000001", project_id: projectId, revision: 1, origin: "synthetic", created_at: now, updated_at: now, created_by: "tester", updated_by: "tester" };
delete item.request_id; testData = [item]; return route.fulfill({ status: 201, json: item });
}
if (path.includes("/test-data/") && request.method() === "PATCH") { const body = request.postDataJSON(); testData = [{ ...testData[0], ...body, revision: 2 }]; delete testData[0].request_id; delete testData[0].expected_revision; return route.fulfill({ json: testData[0] }); }
if (path.includes("/test-data/") && request.method() === "DELETE") { testData = []; return route.fulfill({ json: { resource_type: "test_data_record", resource_id: path.split("/").at(-1), revision: 3, deleted: true } }); }
return route.fulfill({ status: 404, json: { code: "NOT_FOUND", message: "not found", effect: "none", request_id: null } });
});
await page.goto("/");
await page.getByRole("button", { name: "外围样板管理" }).click();
const panel = page.getByRole("complementary", { name: "外围样板管理" });
await expect(panel.getByText("外围任务编排", { exact: true })).toBeVisible();
await expect(panel.getByLabel("任务层级")).toContainText("外围合成数据核查");
await expect(panel.getByRole("textbox", { name: "标题" })).toBeHidden();
await panel.getByRole("button", { name: "新增子任务" }).click();
await panel.getByRole("textbox", { name: "标题" }).fill("演示 测试工单");
await panel.getByRole("textbox", { name: "说明" }).fill("外围 CRUD 联调");
await panel.getByRole("button", { name: "创建子任务" }).click();
await expect(panel.getByText("演示 测试工单", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "编辑子任务 演示 测试工单" }).click();
await panel.getByRole("textbox", { name: "标题" }).fill("演示 测试工单(已改)");
await panel.getByRole("button", { name: "保存修改" }).click();
await expect(panel.getByText("演示 测试工单(已改)", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "删除子任务 演示 测试工单(已改)" }).click();
await expect(panel.getByText("演示 测试工单(已改)", { exact: true })).toBeHidden();
await panel.getByRole("group", { name: "外围样板资源类型" }).getByRole("button", { name: /测试数据/ }).click();
await panel.getByRole("button", { name: "新增测试数据" }).click();
await panel.getByRole("textbox", { name: "名称" }).fill("演示 压力样本");
await panel.getByRole("textbox", { name: "数值" }).fill("31.2");
await panel.getByRole("button", { name: "创建数据" }).click();
await expect(panel.getByText("演示 压力样本", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "编辑数据 演示 压力样本" }).click();
await panel.getByRole("textbox", { name: "数值" }).fill("30.8");
await panel.getByRole("button", { name: "保存修改" }).click();
await expect(panel.getByText(/30.8 m/)).toBeVisible();
await panel.getByRole("button", { name: "删除数据 演示 压力样本" }).click();
await expect(panel.getByText("演示 压力样本", { exact: true })).toBeHidden();
});
test("在 375px 视口内完整呈现面板", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 720 });
await page.goto("/");
const launcher = page.getByRole("button", { name: "外围样板管理" });
await launcher.focus();
await page.keyboard.press("Enter");
const panel = page.getByRole("complementary", { name: "外围样板管理" });
await expect(panel).toBeVisible();
const box = await panel.boundingBox();
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(375);
});
});
function createDemoDataset(projectId: string) {
const points = Array.from({ length: 5 }, (_, index) => ({
point_id: `DEMO-${index + 1}`,
label: `虚拟压力测点 ${index + 1}`,
unit: "m",
status: "active",
origin: "synthetic"
}));
return {
generator_version: "water-demo@1",
seed: "water-demo-seed-20260917",
origin: "synthetic",
window: { start: "2026-09-17T00:00:00Z", end: "2026-09-17T12:00:00Z" },
project_id: projectId,
source_id: "peripheral-demo",
source_revision: "demo-r1",
points,
observations: Array.from({ length: 240 }, (_, index) => ({
record_id: `demo-reading-${index}`,
point_id: `DEMO-${(index % 5) + 1}`,
observed_at: `2026-09-17T${String(Math.floor(index / 20)).padStart(2, "0")}:${String((index % 4) * 15).padStart(2, "0")}:00Z`,
value: index === 0 ? 0 : index === 5 ? null : 31 + index / 100,
quality: index === 5 ? "missing" : "valid",
unit: "m"
})),
device_states: Array.from({ length: 9 }, (_, index) => ({
record_id: `60000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
asset_id: `VIRTUAL-${index + 1}`,
asset_type: "pressure_sensor",
observed_at: "2026-09-17T01:00:00Z",
state: index === 1 ? "offline" : "online"
})),
alerts: [
{
alert_id: "70000000-0000-4000-8000-000000000001",
asset_id: "VIRTUAL-2",
code: "communication_lost",
message: "演示通信中断",
occurred_at: "2026-09-17T02:00:00Z",
cleared_at: "2026-09-17T02:15:00Z"
},
{
alert_id: "70000000-0000-4000-8000-000000000002",
asset_id: "VIRTUAL-3",
code: "low_level",
message: "演示低水位",
occurred_at: "2026-09-17T05:00:00Z",
cleared_at: null
}
],
operations: Array.from({ length: 4 }, (_, index) => ({
record_id: `80000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
actor: "synthetic-generator",
asset_id: `VIRTUAL-${index + 1}`,
operation: ["pump_start", "pump_stop", "valve_open", "valve_close"][index],
note: "演示事件,不是控制指令",
occurred_at: `2026-09-17T0${index + 1}:30:00Z`
}))
};
}
function createSourceWorkOrders(projectId: string) {
return [
["合成通信核查", "核查监测点通信和数据上送状态", "open", "high"],
["合成压力偏差复核", "复核压力偏差与历史回放证据", "in_progress", "urgent"],
["合成零流量样本确认", "确认零流量样本及质量标记", "completed", "normal"]
].map(([title, summary, status, priority], index) => ({
work_order_id: `50000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`,
project_id: projectId,
revision: 1,
title,
summary,
status,
priority,
assignee: null,
scheduled_at: "2026-09-17T08:00:00Z",
origin: "synthetic",
created_at: "2026-09-17T00:00:00Z",
updated_at: "2026-09-17T00:00:00Z",
created_by: "synthetic-generator",
updated_by: "synthetic-generator"
}));
}
@@ -0,0 +1,57 @@
import { expect, test } from "@playwright/test";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
test("uses backend project identity, role and map extent with the existing fallback available", async ({
page
}) => {
await mockWorkbenchContext(page, undefined, {
metadata: {
project_id: "project-1",
name: "北区供水项目",
code: "north-supply",
gs_workspace: "tjwater_next",
map_extent: {
bbox: [13358338.895, 3503549.844, 13369470.85, 3516410.23],
zoom: 13
},
status: "active",
project_role: "member"
},
accessContext: {
user_id: "user-1",
username: "north.operator",
system_role: "user",
is_system_admin: false,
project_id: "project-1",
project_role: "member",
permissions: ["webgis.view"]
}
});
await page.goto("/", { waitUntil: "domcontentloaded" });
const userMenu = page.getByRole("button", { name: "打开用户菜单" });
await expect(userMenu).toContainText("north.operator");
await userMenu.click();
await expect(page.getByText("北区供水项目 · 项目成员", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect.poll(async () => isProjectCenter(await readMapCenter(page))).toBe(true);
await page.evaluate(() => window.__waterNetworkMap?.jumpTo({ center: [121.5, 31] }));
await page.getByRole("button", { name: "缩放到全局" }).click();
await expect.poll(async () => isProjectCenter(await readMapCenter(page))).toBe(true);
});
async function readMapCenter(page: import("@playwright/test").Page) {
return page.evaluate(() => {
const center = window.__waterNetworkMap?.getCenter();
return center
? { longitude: center.lng, latitude: center.lat }
: { longitude: Number.NaN, latitude: Number.NaN };
});
}
function isProjectCenter(center: { longitude: number; latitude: number }) {
return Math.abs(center.longitude - 120.05) < 0.15
&& Math.abs(center.latitude - 30.05) < 0.15;
}
+34 -16
View File
@@ -3,20 +3,31 @@ import { mockScadaApi } from "./support/mock-scada-api";
test("loads SCADA from the backend API and aligns map features by device_id", async ({ page }) => {
await mockRuntimeAndMap(page, { valves: 0, reservoirs: 0, pumps: 0, tanks: 0 });
await mockScadaApi(page, [{
device_id: "SCADA-1",
device_type: "pressure",
node_id: "J-1",
link_id: null,
api_query_id: "query-1",
transmission_mode: "realtime",
transmission_frequency: "2s",
reliability: 100,
x: 1,
y: 2,
longitude: 121.5,
latitude: 30.9
}]);
await mockScadaApi(
page,
[{
device_id: "SCADA-1",
device_type: "pressure",
node_id: "J-1",
link_id: null,
api_query_id: "query-1",
measurement_unit: "MPa",
transmission_mode: "realtime",
transmission_frequency: "2s",
reliability: 100,
x: 1,
y: 2,
longitude: 121.5,
latitude: 30.9
}],
undefined,
[{
time: "2026-09-17T02:29:00Z",
device_id: "SCADA-1",
monitored_value: 0.31,
cleaned_value: 0.3
}]
);
const deviceRequestPromise = page.waitForRequest((request) =>
request.url().includes("/api/v1/scada-devices")
);
@@ -30,9 +41,16 @@ test("loads SCADA from the backend API and aligns map features by device_id", as
if (!map) return [];
return map.querySourceFeatures("scada").map((feature) => ({
id: String(feature.id),
deviceId: String(feature.properties?.device_id)
deviceId: String(feature.properties?.device_id),
latestValue: Number(feature.properties?.latest_value),
latestValueWithUnit: String(feature.properties?.latest_value_with_unit)
}));
})).toContainEqual({ id: "SCADA-1", deviceId: "SCADA-1" });
})).toContainEqual({
id: "SCADA-1",
deviceId: "SCADA-1",
latestValue: 0.31,
latestValueWithUnit: "0.31 MPa"
});
const layerTool = page.getByRole("button", { name: /图层:管理地图图层/ });
await expect(layerTool).toBeEnabled();
@@ -1,8 +1,9 @@
import { expect, test, type Locator } from "@playwright/test";
import { expect, test, type Locator, type Page } from "@playwright/test";
import { mockHierarchicalWorkOrder } from "./support/mock-hierarchical-work-order";
test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
await openConditionFeed(page);
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(panel).toBeVisible();
@@ -75,14 +76,12 @@ test("scheduled conditions keep acrylic blur outside transformed ancestors", asy
test("narrow desktop keeps Agent and expanded conditions from overlapping", async ({ page }) => {
await page.setViewportSize({ width: 1024, height: 768 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await openConditionFeed(page);
const panel = page.getByRole("region", { name: "工况任务", exact: true });
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
await resizeHandle.focus();
await page.keyboard.press("End");
await expect(agentPanel).toHaveCSS("width", "484px");
await expect(agentPanel).toHaveCSS("width", "500px");
const compactAgentBox = await agentPanel.boundingBox();
const compactConditionBox = await panel.boundingBox();
@@ -112,7 +111,7 @@ test("narrow desktop keeps Agent and expanded conditions from overlapping", asyn
test("switching conditions keeps the detail viewport fully expanded", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
await openConditionFeed(page);
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click();
@@ -161,7 +160,7 @@ test("switching conditions keeps the detail viewport fully expanded", async ({ p
.getByRole("button", { name: /SCADA 数据诊断/ })
.first()
.click();
const workflowTitle = panel.getByText("SCADA 诊断流程", { exact: true });
const workflowTitle = panel.getByText("演示执行流程 · SCADA 诊断流程", { exact: true });
await expect(workflowTitle).toBeVisible();
await expect(workflowTitle.locator("..").locator("svg")).toHaveCount(0);
});
@@ -169,8 +168,9 @@ test("switching conditions keeps the detail viewport fully expanded", async ({ p
test("keeps execution spinners without breathing badges after selecting timeline tasks", async ({
page
}) => {
await mockHierarchicalWorkOrder(page, "execution");
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
await openConditionFeed(page);
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click();
@@ -182,14 +182,14 @@ test("keeps execution spinners without breathing badges after selecting timeline
.first();
const executingWorkOrder = timeline
.locator('button[aria-pressed]')
.filter({ hasText: "系统工单:北辰分区阀门开度调整" });
.filter({ hasText: "演示工单:北辰分区阀门开度调整" });
await expectRunningStatusAnimations(runningCondition);
await runningCondition.click();
await expectRunningStatusAnimations(runningCondition);
await expect(
panel
.getByText("SCADA 诊断流程", { exact: true })
.getByText("演示执行流程 · SCADA 诊断流程", { exact: true })
.locator("..")
.locator(".status-badge .status-dot")
).toHaveCount(0);
@@ -198,18 +198,26 @@ test("keeps execution spinners without breathing badges after selecting timeline
await expect(executingWorkOrder.getByText("执行中", { exact: true })).toBeVisible();
await executingWorkOrder.click();
await expectRunningStatusAnimations(executingWorkOrder);
const detail = panel.getByTestId("scheduled-condition-detail-scroll");
await expect(detail.getByText("子任务", { exact: true })).toBeVisible();
await expect(detail.getByText("1/3 完成", { exact: true })).toBeVisible();
await expect(detail.getByText(/上传阀门操作前后照片/).first()).toBeVisible();
const activeSubtask = detail.locator("details").filter({ hasText: "记录最终开度" });
await expect(activeSubtask).toHaveAttribute("open", "");
await expect(activeSubtask.getByText("执行中", { exact: true })).toBeVisible();
});
test("shows completed field work as a static waiting-for-reply state", async ({ page }) => {
await mockHierarchicalWorkOrder(page, "reply");
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
await openConditionFeed(page);
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click();
const awaitingReplyWorkOrder = panel
.getByTestId("scheduled-condition-timeline")
.locator('button[aria-pressed]')
.filter({ hasText: "系统工单:泵站边界复核复令" });
.filter({ hasText: "演示工单:泵站边界复核复令" });
await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible();
await expect(awaitingReplyWorkOrder.locator(".status-icon")).toHaveAttribute(
@@ -225,7 +233,8 @@ test("shows completed field work as a static waiting-for-reply state", async ({
);
await awaitingReplyWorkOrder.click();
await expect(panel.getByText("当前阶段:待复令", { exact: true })).toBeVisible();
await expect(panel.getByText("2/3 完成", { exact: true })).toBeVisible();
await expect(panel.getByText("确认模型边界已同步更新 · 待复令", { exact: true })).toBeVisible();
await expect(
panel
.getByTestId("scheduled-condition-detail-scroll")
@@ -253,7 +262,19 @@ async function readScrollbarPresentation(locator: Locator) {
});
}
async function openConditionFeed(page: Page) {
const button = page.getByRole("button", { name: "工况任务", exact: true });
await button.focus();
await page.keyboard.press("Enter");
}
async function expectAcrylicComposition(panel: Locator) {
await expect.poll(() => panel.evaluate((element) => getComputedStyle(element).transform)).toBe(
"none"
);
await expect.poll(() => panel.evaluate((element) => getComputedStyle(element).willChange)).toBe(
"auto"
);
const composition = await panel.evaluate((element) => {
const rootStyle = getComputedStyle(element);
const blockingAncestors: string[] = [];
@@ -0,0 +1,56 @@
export const MOCK_SPATIAL_ANALYSIS = {
run_id: "analysis-layout-spatial",
name: "北区爆管定位",
run_type: "burst_location",
run_type_label: "爆管定位",
status: "completed",
status_label: "已完成",
created_by: "layout-test",
created_at: "2026-09-18T02:20:00Z",
started_at: "2026-09-18T02:10:00Z",
results: [{
result_type: "burst_location",
created_at: "2026-09-18T02:20:00Z",
facts: [{ label: "关联管段", value: "P-42" }]
}],
map_payload: {
type: "FeatureCollection",
features: [{
type: "Feature",
properties: { label: "后端爆管定位" },
geometry: { type: "Point", coordinates: [121.5, 30.9] }
}]
}
} as const;
export const MOCK_NON_SPATIAL_ANALYSIS = {
...MOCK_SPATIAL_ANALYSIS,
run_id: "analysis-layout-table",
name: "供水服务分区平衡分析",
run_type: "pressure_analysis",
run_type_label: "压力分析",
results: [{
result_type: "pressure_analysis",
created_at: "2026-09-18T02:20:00Z",
facts: [{ label: "结果状态", value: "已完成" }]
}],
map_payload: null
} as const;
export function createAnalysisRuntimeSnapshot(latestAnalysis: unknown) {
return {
availability: "ready",
source: "analysis_runs",
scenario_availability: "ready",
artifact_availability: "ready",
schedule_availability: "not_configured",
work_order_availability: "not_configured",
alert_availability: "not_configured",
generated_at: "2026-09-18T02:30:00Z",
conditions: [],
alerts: [],
scenarios: [],
latest_analysis: latestAnalysis,
work_orders: []
};
}
@@ -0,0 +1,92 @@
import type { Page } from "@playwright/test";
import { mockWorkbenchContext } from "./mock-workbench-context";
type WorkOrderPhase = "execution" | "reply";
export async function mockHierarchicalWorkOrder(page: Page, phase: WorkOrderPhase) {
const parentTitle = phase === "execution"
? "演示工单:北辰分区阀门开度调整"
: "演示工单:泵站边界复核复令";
const parentStatus = phase === "execution" ? "running" : "awaiting_reply";
const subtaskTitles = phase === "execution"
? ["上传阀门操作前后照片", "记录最终开度", "复核北辰末端压力不低于 0.22MPa"]
: ["上传泵站运行截图", "填写出水压力复核结果", "确认模型边界已同步更新"];
const activeIndex = phase === "execution" ? 1 : 2;
const scheduledAt = shanghaiTimestamp(10, 30);
const workOrderId = `mock-${phase}-work-order`;
await mockWorkbenchContext(page, {
availability: "not_configured",
source: "none",
scenario_availability: "not_configured",
artifact_availability: "not_configured",
schedule_availability: "not_configured",
work_order_availability: "ready",
alert_availability: "not_configured",
generated_at: scheduledAt,
conditions: [],
alerts: [],
scenarios: [],
latest_analysis: null,
work_orders: [{
id: workOrderId,
code: phase === "execution" ? "DEMO-EXEC-001" : "DEMO-REPLY-001",
scheduled_at: scheduledAt,
title: parentTitle,
summary: phase === "execution"
? "现场调整边界阀门并逐项复核。"
: "完成泵站边界复核并等待调度复令。",
status: parentStatus,
risk_level: phase === "execution" ? "attention" : "normal",
updated_at: scheduledAt,
detail: "浏览器测试使用的后端工单投影。",
recommendation: "按子任务逐项完成并回传。",
duration_minutes: 45,
source: "生产工单系统",
dispatcher: "调度中心",
priority: phase === "execution" ? "urgent" : "normal",
subtasks: subtaskTitles.map((title, index) => {
const position = index < activeIndex ? "before" : index > activeIndex ? "after" : "active";
const stageStatuses = phase === "execution" && position === "active"
? ["done", "current", "pending"]
: phase === "reply" && position === "active"
? ["done", "done", "current"]
: position === "before"
? ["done", "done", "done"]
: ["pending", "pending", "pending"];
return {
id: `${workOrderId}-subtask-${index + 1}`,
title,
summary: `完成“${title}”并保留阶段记录。`,
status: position === "before" ? "completed" : position === "active" ? "running" : "pending",
scheduled_at: shanghaiTimestamp(10, 30 + index * 15),
location: phase === "execution" ? "北辰分区边界阀门 BV-07" : "北辰二级泵站 P-02",
assignee: phase === "execution" ? "北辰抢修二组" : "泵站运行班组",
duration_minutes: 15,
reply_window_minutes: 20,
stages: [
{ id: "dispatch", sequence: 1, title: "派发", status: stageStatuses[0], owner: "调度中心", time_label: "10:20", description: "确认范围并派发。" },
{ id: "execution", sequence: 2, title: "执行中", status: stageStatuses[1], owner: "现场班组", time_label: "10:30-10:45", description: "现场执行本项任务。" },
{ id: "reply", sequence: 3, title: "待复令", status: stageStatuses[2], owner: "现场班组", time_label: "10:45-11:05", description: "回传结果并等待复令。" }
],
reply_requirements: [`回传“${title}”的执行结果`]
};
})
}]
});
}
function shanghaiTimestamp(hour: number, minute: number) {
const target = new Date();
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit"
}).formatToParts(target);
const get = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? "";
const normalizedHour = hour + Math.floor(minute / 60);
return `${get("year")}-${get("month")}-${get("day")}T${String(normalizedHour).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}:00+08:00`;
}
+86 -1
View File
@@ -1,6 +1,30 @@
import type { Page } from "@playwright/test";
export async function mockScadaApi(page: Page, devices: unknown[] = []) {
const emptyRuntimeSnapshot = {
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-09-17T02:30:00Z",
conditions: [],
alerts: [],
scenarios: [],
latest_analysis: null,
work_orders: []
};
export async function mockScadaApi(
page: Page,
devices: unknown[] = [],
runtimeSnapshot: unknown = emptyRuntimeSnapshot,
readings: unknown[] = [],
projectPresentation?: { metadata: unknown; accessContext: unknown },
analysisRuns: Record<string, unknown> = {},
analysisHistory: unknown[] = []
) {
await page.route("https://tjwater-api.invalid/**", async (route) => {
const url = new URL(route.request().url());
if (url.pathname === "/api/v1/projects") {
@@ -22,6 +46,67 @@ export async function mockScadaApi(page: Page, devices: unknown[] = []) {
});
return;
}
if (url.pathname === "/api/v1/projects/current/metadata" && projectPresentation) {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify(projectPresentation.metadata)
});
return;
}
if (url.pathname === "/api/v1/access-context" && projectPresentation) {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify(projectPresentation.accessContext)
});
return;
}
if (url.pathname === "/api/v1/timeseries/scada-readings") {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify(readings)
});
return;
}
if (url.pathname === "/api/v1/workbench/runtime") {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify(runtimeSnapshot)
});
return;
}
if (url.pathname === "/api/v1/workbench/analysis-runs") {
const limit = Number(url.searchParams.get("limit") ?? 10);
const offset = Number(url.searchParams.get("offset") ?? 0);
const items = analysisHistory.slice(offset, offset + limit);
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
items,
limit,
offset,
next_offset: offset + limit < analysisHistory.length ? offset + limit : null
})
});
return;
}
const analysisRunMatch = url.pathname.match(/^\/api\/v1\/workbench\/analysis-runs\/([^/]+)$/);
if (analysisRunMatch) {
const runId = decodeURIComponent(analysisRunMatch[1]);
const analysisRun = analysisRuns[runId];
if (analysisRun) {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify(analysisRun)
});
} else {
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ detail: "Analysis run not found" })
});
}
return;
}
await route.fulfill({ status: 404, contentType: "application/json", body: "{}" });
});
}
@@ -0,0 +1,38 @@
import type { Page } from "@playwright/test";
import { mockScadaApi } from "./mock-scada-api";
// UI-only tests still need the authorized project lookup used by Agent API calls.
export async function mockWorkbenchContext(
page: Page,
runtimeSnapshot?: unknown,
projectPresentation?: { metadata: unknown; accessContext: unknown },
analysisRuns: Record<string, unknown> = {},
analysisHistory: unknown[] = [],
options: { runtimeMode?: "auto" | "demo" | "history" | "backend" } = {}
) {
await mockScadaApi(
page,
[],
runtimeSnapshot,
[],
projectPresentation,
analysisRuns,
analysisHistory
);
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_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_MAPBOX_ACCESS_TOKEN: "",
TJWATER_WORKBENCH_RUNTIME_MODE: options.runtimeMode ?? "auto",
TJWATER_ENABLE_MSW: "false"
})};`
})
);
await page.route("https://workbench-map.invalid/**", (route) => route.fulfill({ status: 204 }));
}
@@ -4,18 +4,14 @@ test("opening Agent and condition panels does not move the map camera", async ({
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(
() =>
Boolean(window.__waterNetworkMap?.getLayer("scada-hit")) &&
Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")) &&
!window.__waterNetworkMap?.isMoving()
);
await page.evaluate(() => {
const map = window.__waterNetworkMap;
document.documentElement.dataset.panelCameraMoveCount = "0";
map?.on("movestart", () => {
const currentCount = Number(document.documentElement.dataset.panelCameraMoveCount ?? "0");
document.documentElement.dataset.panelCameraMoveCount = String(currentCount + 1);
});
});
const initialCamera = await readCamera(page);
await expect(page.locator('aside[aria-label="Agent 折叠栏"]')).toBeVisible();
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
await expect(page.locator('aside[aria-label="Agent 折叠栏"]')).toBeVisible();
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
@@ -24,9 +20,33 @@ test("opening Agent and condition panels does not move the map camera", async ({
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
await expect(conditionPanel).toHaveCSS("width", "880px");
await expect(conditionPanel.getByRole("button", { name: "收起工况任务" })).toBeVisible();
await expect
.poll(async () => (await conditionPanel.boundingBox())?.width ?? 0)
.toBeGreaterThan(800);
expect(await page.evaluate(() => document.documentElement.dataset.panelCameraMoveCount)).toBe(
"0"
);
await page.waitForFunction(() => !window.__waterNetworkMap?.isMoving());
const finalCamera = await readCamera(page);
expect(finalCamera.longitude).toBeCloseTo(initialCamera.longitude, 10);
expect(finalCamera.latitude).toBeCloseTo(initialCamera.latitude, 10);
expect(finalCamera.zoom).toBeCloseTo(initialCamera.zoom, 10);
expect(finalCamera.bearing).toBeCloseTo(initialCamera.bearing, 10);
expect(finalCamera.pitch).toBeCloseTo(initialCamera.pitch, 10);
});
async function readCamera(page: import("@playwright/test").Page) {
return page.evaluate(() => {
const map = window.__waterNetworkMap;
if (!map) throw new Error("Map is not ready");
const center = map.getCenter();
return {
longitude: center.lng,
latitude: center.lat,
zoom: map.getZoom(),
bearing: map.getBearing(),
pitch: map.getPitch()
};
});
}
@@ -0,0 +1,513 @@
import { expect, test } from "@playwright/test";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
const TODAY = shanghaiTimestamp(10, 30);
const TODAY_10_10 = shanghaiTimestamp(10, 10);
const TODAY_10_20 = shanghaiTimestamp(10, 20);
const TODAY_10_25 = shanghaiTimestamp(10, 25);
const TODAY_10_35 = shanghaiTimestamp(10, 35);
const TODAY_11_00 = shanghaiTimestamp(11, 0);
const YESTERDAY_10_10 = shanghaiTimestamp(10, 10, -1);
const YESTERDAY_10_20 = shanghaiTimestamp(10, 20, -1);
test("history mode opens on the latest day with backend work orders", async ({ page }) => {
await mockWorkbenchContext(page, {
availability: "ready",
source: "scheduled_tasks",
scenario_availability: "not_configured",
artifact_availability: "not_configured",
schedule_availability: "ready",
work_order_availability: "ready",
alert_availability: "ready",
generated_at: "2026-09-18T02:30:00Z",
conditions: [{
id: "backend-schedule-demo-day-next",
task_id: "backend-schedule",
session_id: "scheduled-condition-backend-schedule-demo-day",
title: "历史 SCADA 诊断计划",
summary: "来自后端持久化的目标明确的诊断计划。",
status: "running",
risk_level: "normal",
scheduled_at: "2026-08-17T09:40:00Z",
updated_at: "2026-08-17T09:40:00Z",
detail: "按计划复核 SCADA 缺测与越界记录。",
recommendation: null,
evidence: ["调度 ID:demo-day"],
model_name: "TJWater scheduler"
}],
alerts: [],
scenarios: [],
latest_analysis: null,
work_orders: [{
id: "wo-history-1",
code: "WO-20260817-001",
scheduled_at: "2026-08-17T09:40:00Z",
title: "历史工单:阀门复核",
summary: "来自后端工单投影的历史预约任务。",
status: "pending",
risk_level: "normal",
updated_at: "2026-08-17T09:35:00Z",
detail: "复核阀门状态并回传结果。",
recommendation: null,
duration_minutes: 30,
source: "工单系统",
dispatcher: "调度中心",
priority: "normal",
subtasks: [{
id: "wo-history-1-subtask-1",
title: "阀门状态复核",
summary: "现场复核阀门状态并回传。",
status: "running",
scheduled_at: "2026-08-17T09:40:00Z",
location: "北区阀门 V-17",
assignee: "北区班组",
duration_minutes: 30,
reply_window_minutes: 15,
stages: [
{ id: "dispatch", sequence: 1, title: "派发", status: "done", owner: "调度中心", time_label: "17:20", description: "已派发。" },
{ id: "execution", sequence: 2, title: "执行", status: "current", owner: "北区班组", time_label: "17:40-18:10", description: "现场复核中。" },
{ id: "reply", sequence: 3, title: "复令", status: "pending", owner: "北区班组", time_label: "18:10-18:25", description: "等待结果回传。" }
],
reply_requirements: ["回传阀门状态"]
}]
}]
}, undefined, {}, [], { runtimeMode: "history" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByText("历史 2026-08-17 17:40", { exact: true })).toBeVisible();
await expect(page.getByRole("region", { name: "运行时间轴" })).toContainText("2026.08.17");
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(panel).toContainText("工况演示数据 · 后端工单");
await expect(panel).toContainText("SCADA 数据诊断");
await panel.getByRole("button", { name: "展开工况任务" }).click();
await expect(panel).toContainText("历史工单:阀门复核");
await expect(panel).not.toContainText("历史 SCADA 诊断计划");
});
test("keeps mock conditions while retaining explicit backend alerts", async ({ page }) => {
await mockWorkbenchContext(page, {
availability: "ready",
source: "scheduled_tasks",
scenario_availability: "not_configured",
artifact_availability: "not_configured",
schedule_availability: "not_configured",
work_order_availability: "not_configured",
alert_availability: "ready",
generated_at: TODAY,
conditions: [{
id: "schedule-run-test-1",
task_id: "backend-schedule",
session_id: "scheduled-condition-schedule-run-test-1",
title: "演示 合成低压调查",
summary: "来自后端持久化 Action Run 的 演示 沙箱测试状态。",
status: "error",
risk_level: "critical",
scheduled_at: TODAY_10_20,
updated_at: TODAY_10_25,
detail: "演示 测试运行状态:已失败。",
recommendation: "请在 演示 调查任务中检查失败步骤与运行日志。",
evidence: ["运行状态:failed"],
model_name: "TJWater scheduler"
}],
alerts: [{
id: "scheduled-condition-alert-schedule-run-test-1",
condition_id: "schedule-run-test-1",
source: {
task_type: "analysis_task",
task_id: "backend-schedule",
run_id: "schedule-run-test-1"
},
title: "演示 测试待复核:演示 合成低压调查",
description: "运行失败。演示 测试运行状态:已失败。",
occurred_at: TODAY_10_25,
severity: "critical"
}],
scenarios: [],
latest_analysis: null,
work_orders: []
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(panel).toContainText("工况演示数据 · 未接入工单");
await expect(panel).toContainText("SCADA 数据诊断");
await expect(panel).not.toContainText("演示 合成低压调查");
const alertButton = page.getByRole("button", {
name: "查看异常处置面板,当前 1 条待复核工况"
});
await alertButton.focus();
await page.keyboard.press("Enter");
await expect(page.getByText("演示 测试待复核:演示 合成低压调查")).toBeVisible();
});
test("opens the latest persisted analysis as the active visualization", async ({ page }) => {
const latestAnalysis = {
run_id: "analysis-1",
name: "北区爆管定位",
run_type: "burst_location",
run_type_label: "爆管定位",
status: "completed",
status_label: "已完成",
created_by: "alice",
created_at: TODAY_10_20,
started_at: TODAY_10_10,
results: [{
result_type: "burst_location",
created_at: TODAY_10_20,
facts: [{ label: "关联管段", value: "P-42" }]
}],
map_payload: {
type: "FeatureCollection",
features: [{
type: "Feature",
properties: { label: "后端爆管定位" },
geometry: { type: "Point", coordinates: [121.5, 30.9] }
}]
}
};
const historicalAnalysis = {
...latestAnalysis,
run_id: "analysis-2",
name: "南区历史定位",
created_at: YESTERDAY_10_20,
started_at: YESTERDAY_10_10,
results: [{
result_type: "burst_location",
created_at: YESTERDAY_10_20,
facts: [{ label: "关联管段", value: "P-older" }]
}],
map_payload: {
type: "FeatureCollection",
features: [{
type: "Feature",
properties: { label: "历史运行几何" },
geometry: { type: "Point", coordinates: [121.4, 30.8] }
}]
}
};
await mockWorkbenchContext(page, {
availability: "ready",
source: "analysis_runs",
scenario_availability: "ready",
artifact_availability: "ready",
schedule_availability: "ready",
work_order_availability: "not_configured",
alert_availability: "not_configured",
generated_at: TODAY,
conditions: [{
id: "analysis-run-analysis-1",
task_id: "backend-analysis",
session_id: "scheduled-condition-analysis-run-analysis-1",
title: "北区爆管定位",
summary: "来自项目后端持久化分析运行:爆管定位。",
status: "completed",
risk_level: "normal",
scheduled_at: TODAY_10_10,
updated_at: TODAY_10_20,
detail: "分析类型:爆管定位;后端状态:已完成。",
recommendation: null,
evidence: ["运行 ID:analysis-1"],
model_name: "TJWater analysis runtime"
}, {
id: "analysis-run-analysis-2",
task_id: "backend-analysis",
session_id: "scheduled-condition-analysis-run-analysis-2",
title: "南区历史定位",
summary: "来自项目后端持久化分析运行:爆管定位。",
status: "completed",
risk_level: "normal",
scheduled_at: YESTERDAY_10_10,
updated_at: YESTERDAY_10_20,
detail: "分析类型:爆管定位;后端状态:已完成。",
recommendation: null,
evidence: ["运行 ID:analysis-2"],
model_name: "TJWater analysis runtime"
}],
alerts: [],
scenarios: [
{
id: "analysis-run-analysis-1",
run_id: "analysis-1",
name: "北区爆管定位",
description: "爆管定位 · 已完成 · 创建人 alice",
status: "review",
run_type: "burst_location",
run_type_label: "爆管定位",
run_status: "completed",
created_at: TODAY_10_20
},
{
id: "analysis-run-analysis-2",
run_id: "analysis-2",
name: "南区历史定位",
description: "爆管定位 · 已完成 · 创建人 alice",
status: "review",
run_type: "burst_location",
run_type_label: "爆管定位",
run_status: "completed",
created_at: YESTERDAY_10_20
}
],
latest_analysis: latestAnalysis,
work_orders: []
}, undefined, {
"analysis-1": latestAnalysis,
"analysis-2": historicalAnalysis
}, [{
run_id: "analysis-1",
name: "北区爆管定位",
run_type: "burst_location",
run_type_label: "爆管定位",
status: "completed",
status_label: "已完成",
created_by: "alice",
created_at: TODAY_10_20,
started_at: TODAY_10_10,
has_results: true
}, {
run_id: "analysis-2",
name: "南区历史定位",
run_type: "burst_location",
run_type_label: "爆管定位",
status: "completed",
status_label: "已完成",
created_by: "alice",
created_at: YESTERDAY_10_20,
started_at: YESTERDAY_10_10,
has_results: true
}, ...Array.from({ length: 9 }, (_, index) => ({
run_id: `analysis-${index + 3}`,
name: `批次分析 ${index + 3}`,
run_type: "pressure_analysis",
run_type_label: "压力分析",
status: "completed",
status_label: "已完成",
created_by: "scheduler",
created_at: YESTERDAY_10_20,
started_at: YESTERDAY_10_10,
has_results: index % 2 === 0
}))]);
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("button", { name: "切换模拟方案" })).toHaveCount(0);
await expect(page.getByRole("button", { name: /当前分析视图/ })).toHaveCount(0);
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(conditionPanel).toContainText("工况演示数据 · 未接入工单");
await expect(conditionPanel).not.toContainText("北区爆管定位");
await expect(conditionPanel).not.toContainText("南区历史定位");
await expect(conditionPanel).toContainText("SCADA 数据诊断");
await page.getByRole("button", { name: "工况任务", exact: true }).click();
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toHaveCount(0);
await page.getByRole("button", { name: "打开最新后端分析成果" }).click();
await expect(page.getByRole("complementary", { name: "后端分析成果预览" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "北区爆管定位" })).toBeVisible();
await expect(page.getByText("Backend Analysis")).toBeVisible();
await expect(page.getByText("关联管段:P-42")).toBeVisible();
const analysisViewButton = page.getByRole("button", { name: "当前分析视图:北区爆管定位" });
await expect(analysisViewButton).toContainText("分析视图 · 北区爆管定位");
await expect.poll(() => page.evaluate(() =>
window.__waterNetworkMap
?.querySourceFeatures("simulation-backend-analysis")
.map((feature) => feature.properties?.label) ?? []
)).toContain("后端爆管定位");
await analysisViewButton.click();
await expect(page.getByText(/爆管定位 · 项目后端分析运行 · 后端分析运行/)).toBeVisible();
await page.getByText("关闭分析视图", { exact: true }).click();
await expect(page.getByRole("button", { name: /当前分析视图/ })).toHaveCount(0);
await expect.poll(() => page.evaluate(() =>
window.__waterNetworkMap
?.querySourceFeatures("simulation-backend-analysis").length ?? 0
)).toBe(0);
await page.getByRole("button", { name: "项目分析成果" }).click();
const resultsPanel = page.getByRole("complementary", { name: "项目分析成果" });
await resultsPanel.getByText("浏览项目分析成果", { exact: true }).click();
await expect(resultsPanel.getByRole("button", { name: "打开分析成果:北区爆管定位" })).toBeVisible();
await expect(resultsPanel.getByRole("button", { name: "打开分析成果:批次分析 11" })).toHaveCount(0);
await resultsPanel.getByRole("button", { name: "加载更多成果" }).click();
await expect(resultsPanel.getByRole("button", { name: "打开分析成果:批次分析 11" })).toBeVisible();
await resultsPanel.getByRole("button", { name: "打开分析成果:南区历史定位" }).click();
await expect(resultsPanel).toHaveCount(0);
await expect(page.getByRole("heading", { name: "南区历史定位" })).toBeVisible();
await expect(page.getByRole("button", { name: "当前分析视图:南区历史定位" })).toBeVisible();
await expect.poll(() => page.evaluate(() =>
window.__waterNetworkMap
?.querySourceFeatures("simulation-backend-analysis")
.map((feature) => feature.properties?.label) ?? []
)).toContain("历史运行几何");
});
test("keeps a non-spatial backend result in the analysis workspace without demo geometry", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await mockWorkbenchContext(page, {
availability: "ready",
source: "analysis_runs",
scenario_availability: "ready",
artifact_availability: "ready",
schedule_availability: "not_configured",
work_order_availability: "not_configured",
alert_availability: "not_configured",
generated_at: TODAY,
conditions: [],
alerts: [],
scenarios: [],
latest_analysis: {
run_id: "analysis-table-only",
name: "压力统计结果",
run_type: "pressure_analysis",
run_type_label: "压力分析",
status: "completed",
status_label: "已完成",
created_by: "alice",
created_at: TODAY_10_20,
started_at: TODAY_10_10,
results: [{
result_type: "pressure_analysis",
created_at: TODAY_10_20,
facts: [{ label: "结果状态", value: "已完成" }]
}],
map_payload: null
},
work_orders: []
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开最新后端分析成果" }).click();
await expect(page.getByRole("heading", { name: "压力统计结果" })).toBeVisible();
await page.getByRole("button", { name: "当前分析视图:压力统计结果" }).click();
await expect(page.getByText("仅看地图", { exact: true })).toHaveCount(0);
await page.getByRole("menuitem", { name: /半屏显示/ }).click();
await expect(page.getByRole("menu")).toHaveCount(0);
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_split");
await expect(page.locator("#workspace-map")).toBeVisible();
await expect(page.locator("#artifact-workspace")).toBeVisible();
await expect.poll(() => page.evaluate(() => ({
backend: window.__waterNetworkMap
?.querySourceFeatures("simulation-backend-analysis").length ?? 0,
demo: window.__waterNetworkMap
?.querySourceFeatures("simulation-impact-area").length ?? 0
}))).toEqual({ backend: 0, demo: 0 });
});
test("keeps mock conditions while using backend work orders and alerts", async ({ page }) => {
await mockWorkbenchContext(page, {
availability: "ready",
source: "scheduled_tasks",
scenario_availability: "not_configured",
artifact_availability: "not_configured",
schedule_availability: "ready",
work_order_availability: "ready",
alert_availability: "ready",
generated_at: TODAY,
conditions: [{
id: "backend-schedule-diagnosis-next",
task_id: "backend-schedule",
session_id: "scheduled-condition-backend-schedule-diagnosis",
title: "SCADA 后端定时诊断",
summary: "来自项目数据库的持久化调度定义。",
status: "pending",
risk_level: "normal",
scheduled_at: TODAY_10_35,
updated_at: TODAY,
detail: "每 5 分钟触发一次。",
recommendation: null,
evidence: ["调度 ID:diagnosis-5m"],
model_name: "TJWater scheduler"
}],
alerts: [{
id: "backend-alert-alarm-1",
condition_id: null,
source: {
task_type: "scheduled_task",
task_id: "diagnosis-5m",
run_id: "trigger-1"
},
title: "后端告警:泵站通信中断",
description: "由诊断任务明确推送到项目数据库。",
occurred_at: TODAY,
severity: "warning"
}],
scenarios: [],
latest_analysis: null,
work_orders: [{
id: "wo-backend-1",
code: "WO-20260917-001",
scheduled_at: TODAY_11_00,
title: "后端工单:北区阀门复核",
summary: "现场复核阀门开度并回传。",
status: "running",
risk_level: "attention",
updated_at: TODAY,
detail: "来自正式工单同步适配器。",
recommendation: "完成后复核压力。",
duration_minutes: 45,
source: "生产工单系统",
dispatcher: "调度中心",
priority: "urgent",
subtasks: [{
id: "wo-backend-1-subtask-1",
title: "阀门开度复核",
summary: "现场复核阀门开度。",
status: "running",
scheduled_at: TODAY_11_00,
location: "北区阀门 V-1",
assignee: "北区班组",
duration_minutes: 45,
reply_window_minutes: 20,
stages: [
{ id: "dispatch", sequence: 1, title: "派发", status: "done", owner: "调度中心", time_label: "10:50", description: "已派发。" },
{ id: "execution", sequence: 2, title: "执行中", status: "current", owner: "北区班组", time_label: "11:00—11:45", description: "现场执行中。" },
{ id: "reply", sequence: 3, title: "待复令", status: "pending", owner: "北区班组", time_label: "11:45—12:05", description: "等待复令。" }
],
reply_requirements: ["回传阀门开度"]
}]
}]
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(panel).toContainText("工况演示数据 · 后端工单");
await expect(panel).toContainText("SCADA 数据诊断");
await expect(panel).not.toContainText("SCADA 后端定时诊断");
await panel.getByRole("button", { name: "展开工况任务" }).click();
await expect(panel).toContainText("后端工单:北区阀门复核");
await expect(panel).not.toContainText("工单演示回退");
await panel.getByRole("button", { name: /后端工单:北区阀门复核/ }).click();
const detail = panel.getByTestId("scheduled-condition-detail-scroll");
await expect(detail.getByText("子任务", { exact: true })).toBeVisible();
await expect(detail.getByText(/阀门开度复核/).first()).toBeVisible();
await expect(detail.getByText("0/1 完成", { exact: true })).toBeVisible();
await expect(
detail.locator("details").filter({ hasText: "阀门开度复核" }).getByText("执行中", { exact: true })
).toBeVisible();
const alertButton = page.getByRole("button", {
name: "查看异常处置面板,当前 1 条待复核工况"
});
await alertButton.focus();
await page.keyboard.press("Enter");
await expect(page.getByText("来源:后端运行态", { exact: true })).toBeVisible();
await expect(page.getByText("后端告警:泵站通信中断")).toBeVisible();
});
function shanghaiTimestamp(hour: number, minute: number, dayOffset = 0) {
const target = new Date(Date.now() + dayOffset * 24 * 60 * 60 * 1000);
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit"
}).formatToParts(target);
const get = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? "";
return `${get("year")}-${get("month")}-${get("day")}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00+08:00`;
}
+26 -33
View File
@@ -17,6 +17,7 @@ test.describe("Main Frame workbench", () => {
test("desktop light basemap keeps the Main Frame workspace legible", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await openWorkbench(page);
await openDesktopAgent(page);
await expectDesktopMainFrameGeometry(page);
await expectSolidMainFrameMaterials(page);
@@ -31,6 +32,7 @@ test.describe("Main Frame workbench", () => {
test("desktop satellite basemap preserves the solid Main Frame hierarchy", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await openWorkbench(page);
await openDesktopAgent(page);
await selectBasemap(page, "影像");
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite");
@@ -42,20 +44,21 @@ test.describe("Main Frame workbench", () => {
});
});
test("wide desktop expands the map document when navigation is collapsed", async ({ page }) => {
test("wide desktop expands the map document when Agent is collapsed", async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await openWorkbench(page);
await openDesktopAgent(page);
const navigation = page.getByRole("complementary", { name: "工作台导航" });
const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" });
const workspace = page.locator("#main-workspace");
const expandedWorkspace = await workspace.boundingBox();
expect(expandedWorkspace).not.toBeNull();
await expect(navigation).toHaveCSS("width", "368px");
await expect(agentPanel).toHaveCSS("width", "540px");
await page.getByRole("button", { name: "折叠工作台导航" }).click();
await expect(navigation).toHaveCSS("width", "48px");
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(48);
await expect.poll(async () => Math.round((await workspace.boundingBox())?.width ?? 0)).toBe(1872);
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
await expect.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0)).toBe(0);
await expect.poll(async () => Math.round((await workspace.boundingBox())?.width ?? 0)).toBe(1920);
expect((await workspace.boundingBox())!.width).toBeGreaterThan(expandedWorkspace!.width);
await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", {
animations: "disabled",
@@ -179,7 +182,7 @@ async function openWorkbench(page: Page) {
await page.evaluate(async () => {
await document.fonts.ready;
});
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-hit")));
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
}
async function selectBasemap(page: Page, label: "浅色" | "影像") {
@@ -192,54 +195,44 @@ async function selectBasemap(page: Page, label: "浅色" | "影像") {
);
}
async function openDesktopAgent(page: Page) {
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
}
async function expectDesktopMainFrameGeometry(page: Page) {
const navigation = page.getByRole("complementary", { name: "工作台导航" });
const workspace = page.locator("#main-workspace");
const mapWindow = page.locator("#workspace-map");
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
await expect(navigation).toHaveCSS("width", "368px");
await expect(agentPanel).toHaveCSS("border-radius", "0px");
const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" });
await expect(agentPanel).toHaveCSS("width", "500px");
await expect(agentPanel).toHaveCSS("border-top-right-radius", "16px");
await expect(mapWindow).toHaveCSS("border-radius", "0px");
const navigationBox = await navigation.boundingBox();
const workspaceBox = await workspace.boundingBox();
const mapBox = await mapWindow.boundingBox();
const agentBox = await agentPanel.boundingBox();
expect(navigationBox).not.toBeNull();
expect(workspaceBox).not.toBeNull();
expect(mapBox).not.toBeNull();
expect(agentBox).not.toBeNull();
expect(navigationBox!.x).toBe(0);
expect(navigationBox!.y).toBe(56);
expect(workspaceBox!.x).toBe(navigationBox!.x + navigationBox!.width);
expect(workspaceBox!.y).toBe(navigationBox!.y);
expect(agentBox!.x).toBe(0);
expect(agentBox!.y).toBe(56);
expect(workspaceBox!.x).toBe(agentBox!.x + agentBox!.width);
expect(workspaceBox!.y).toBe(agentBox!.y);
expect(mapBox).toEqual(workspaceBox);
expect(agentBox!.x).toBeGreaterThanOrEqual(48);
expect(agentBox!.x).toBeLessThanOrEqual(49);
expect(
Math.abs(agentBox!.x + agentBox!.width - (navigationBox!.x + navigationBox!.width))
).toBeLessThanOrEqual(1);
}
async function expectSolidMainFrameMaterials(page: Page) {
const materials = await page.evaluate(() => {
const navigation = getComputedStyle(document.querySelector('[aria-label="工作台导航"]')!);
const agent = getComputedStyle(document.querySelector('aside[aria-label="Agent 命令面板"]')!);
return {
navigationBackdrop: navigation.backdropFilter,
navigationBackground: navigation.backgroundColor,
agentBackdrop: agent.backdropFilter,
agentBackground: agent.backgroundColor,
agentShadow: agent.boxShadow
};
});
expect(materials).toEqual({
navigationBackdrop: "none",
navigationBackground: "rgb(237, 241, 245)",
agentBackdrop: "none",
agentBackground: "rgb(247, 249, 251)",
agentShadow: "none"
});
expect(materials.agentBackdrop).toBe("none");
expect(materials.agentBackground).toBe("oklch(0.965 0.012 248)");
expect(materials.agentShadow).not.toBe("none");
}
async function expectLoadedChineseFont(page: Page) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 92 KiB

+129 -26
View File
@@ -1,8 +1,15 @@
import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import {
createAnalysisRuntimeSnapshot,
MOCK_NON_SPATIAL_ANALYSIS,
MOCK_SPATIAL_ANALYSIS
} from "./support/mock-analysis-runtime";
import { mockScadaApi } from "./support/mock-scada-api";
test("the default workspace docks Agent beside the persistent map", async ({ page }) => {
test("the workspace starts with Agent collapsed and docks it only when opened", async ({
page
}) => {
await page.setViewportSize({ width: 1600, height: 900 });
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -12,34 +19,52 @@ test("the default workspace docks Agent beside the persistent map", async ({ pag
await expect(workspace).toHaveAttribute("data-layout-mode", "default");
await expect(map).toBeVisible();
await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
await expect(page.getByLabel("Agent 命令面板")).toHaveCount(0);
await expect.poll(async () => (await workspace.boundingBox())?.x).toBe(0);
await expect(map.getByRole("navigation", { name: "地图工具" })).toBeVisible();
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "任务视图" })).toHaveCount(0);
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await expect(panel).toBeVisible();
await expectDockedWorkspace(page);
await expect(map).toBeVisible();
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
await expect.poll(async () => (await workspace.boundingBox())?.x).toBe(0);
await expect.poll(async () => (await workspace.boundingBox())?.width).toBe(1600);
});
test("a generated Artifact stays in peek until explicitly opened", async ({ page }) => {
test("a persisted Artifact opens and can be reduced to a preview", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
const peek = page.getByRole("complementary", { name: "Agent 成果预览" });
await openFirstArtifact(page);
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_split");
await expect(page.getByRole("heading", { name: "北区爆管定位" })).toBeVisible();
await page.getByRole("button", { name: "将成果收起为预览" }).click();
const peek = page.getByRole("complementary", { name: "后端分析成果预览" });
await expect(peek).toBeVisible();
await expect(peek).toContainText("北辰分区压力异常诊断");
await expect(peek).toContainText("北区爆管定位");
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "北区爆管定位" })).toHaveCount(0);
await peek.getByRole("button", { name: "打开成果" }).click();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_split");
await expect(page.locator("#workspace-map")).toBeVisible();
await expect(page.locator("#artifact-workspace")).toBeVisible();
await expect(page.getByRole("heading", { name: "北辰分区压力异常诊断" })).toBeVisible();
await expect(page.getByRole("heading", { name: "北区爆管定位" })).toBeVisible();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
});
test("Agent expands over an active Artifact without resizing the main workspace", async ({ page }) => {
test("Agent docks beside an active Artifact and restores split view when collapsed", async ({
page
}) => {
await page.setViewportSize({ width: 1600, height: 900 });
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -48,14 +73,32 @@ test("Agent expands over an active Artifact without resizing the main workspace"
const workspace = page.locator("#main-workspace");
const before = await workspace.boundingBox();
expect(before).not.toBeNull();
await expect(workspace).toHaveAttribute("data-layout-mode", "map_split");
await page.getByLabel(/展开 Agent 助手面板/).click();
await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
const after = await workspace.boundingBox();
await expectDockedWorkspace(page);
const panelBox = await page.getByLabel("Agent 命令面板").boundingBox();
expect(panelBox).not.toBeNull();
await expect
.poll(async () => (await workspace.boundingBox())?.width)
.toBe(before!.width - panelBox!.width);
await expect(workspace).toHaveAttribute("data-layout-mode", "focus");
await expect(page.locator("#workspace-map")).toBeHidden();
const title = page.getByRole("heading", { name: "北区爆管定位" });
await expect(title).toBeVisible();
const titleBox = await title.boundingBox();
expect(titleBox!.x).toBeGreaterThanOrEqual(panelBox!.x + panelBox!.width);
expect(after).not.toBeNull();
expect(after!.x).toBe(before!.x);
expect(after!.width).toBe(before!.width);
await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
await expect.poll(async () => (await workspace.boundingBox())?.x).toBe(before!.x);
await expect.poll(async () => (await workspace.boundingBox())?.width).toBe(before!.width);
await expect(workspace).toHaveAttribute("data-layout-mode", "map_split");
await expect(page.locator("#workspace-map")).toBeVisible();
await expect(title).toBeVisible();
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: "test-results/workspace-regression/restored-split.png" });
});
test("spatial Artifacts switch between split, focus and peek", async ({ page }) => {
@@ -68,29 +111,27 @@ test("spatial Artifacts switch between split, focus and peek", async ({ page })
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
await expect(page.locator("#workspace-map")).toBeHidden();
await page.getByRole("button", { name: "显示地图" }).click();
await page.getByRole("button", { name: "半屏显示" }).click();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "map_split");
await expect(page.locator("#workspace-map")).toBeVisible();
await page.getByRole("button", { name: "将成果收起为预览" }).click();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
await expect(page.getByRole("complementary", { name: "Agent 成果预览" })).toBeVisible();
await expect(page.getByLabel("Agent 命令面板")).toBeVisible();
await expect(page.getByRole("complementary", { name: "后端分析成果预览" })).toBeVisible();
await expect(page.getByLabel("Agent 折叠栏")).toBeVisible();
await expect(page.getByLabel("Agent 命令面板")).toHaveCount(0);
await page.getByRole("button", { name: "销毁预览成果" }).click();
await expect(page.getByRole("complementary", { name: "Agent 成果预览" })).toHaveCount(0);
await expect(page.getByRole("complementary", { name: "后端分析成果预览" })).toHaveCount(0);
await expect(page.locator("#workspace-map")).toBeVisible();
});
test("non-spatial Artifacts open directly in focus mode", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await prepareMainFrame(page);
await prepareMainFrame(page, MOCK_NON_SPATIAL_ANALYSIS);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
await page.getByRole("button", { name: "销毁预览成果" }).click();
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
await page.getByRole("button", { name: "打开成果" }).click();
await page.getByRole("button", { name: "打开最新后端分析成果" }).click();
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "focus");
await expect(page.getByRole("heading", { name: "供水服务分区平衡分析" })).toBeVisible();
@@ -126,11 +167,73 @@ test("the condition feed remains floating and starts closed", async ({ page }) =
});
async function openFirstArtifact(page: Page) {
await page.getByRole("button", { name: "生成 Agent 模拟分析成果" }).click();
await page.getByRole("button", { name: "打开成果" }).click();
await page.getByRole("button", { name: "打开最新后端分析成果" }).click();
}
async function prepareMainFrame(page: Page) {
test("header actions remain separate across compact and wide breakpoints", async ({ page }) => {
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.evaluate(() => document.fonts.ready);
for (const width of [1600, 1536, 1440, 1280, 1024, 375, 320, 1800, 1920]) {
await page.setViewportSize({ width, height: 900 });
const titleSize = await page.locator("header h1").evaluate((element) => ({
available: element.clientWidth,
content: element.scrollWidth
}));
expect(
titleSize.content,
`${width}px: header title ${JSON.stringify(titleSize)}`
).toBeLessThanOrEqual(titleSize.available);
const buttons = await page
.locator("header button")
.filter({ visible: true })
.evaluateAll((elements) =>
elements
.map((element) => {
const box = element.getBoundingClientRect();
return {
name: element.getAttribute("aria-label") ?? element.textContent,
left: box.left,
right: box.right
};
})
.sort((a, b) => a.left - b.left)
);
for (let index = 0; index < buttons.length; index += 1) {
const button = buttons[index];
expect(button.left, `${width}px: ${button.name} left edge`).toBeGreaterThanOrEqual(0);
expect(button.right, `${width}px: ${button.name} right edge`).toBeLessThanOrEqual(width);
if (index)
expect(
button.left,
`${width}px: ${buttons[index - 1].name} overlaps ${button.name}`
).toBeGreaterThanOrEqual(buttons[index - 1].right);
}
await page
.locator("header")
.screenshot({ path: `test-results/workspace-regression/${width}-header.png` });
}
});
async function expectDockedWorkspace(page: Page) {
await expect
.poll(() =>
page.evaluate(() => {
const panel = document
.querySelector('aside[aria-label="Agent 命令面板"]')!
.getBoundingClientRect();
const workspace = document.querySelector("#main-workspace")!.getBoundingClientRect();
return {
panelLeft: panel.left,
gap: workspace.left - panel.right,
combinedWidth: panel.width + workspace.width
};
})
)
.toEqual({ panelLeft: 0, gap: 0, combinedWidth: page.viewportSize()!.width });
}
async function prepareMainFrame(page: Page, latestAnalysis: unknown = MOCK_SPATIAL_ANALYSIS) {
await mockAgentApi(page);
await page.route("**/runtime-config.js", async (route) => {
await route.fulfill({
@@ -150,5 +253,5 @@ async function prepareMainFrame(page: Page) {
await page.route("https://workspace-map.invalid/**", async (route) => {
await route.fulfill({ status: 204 });
});
await mockScadaApi(page);
await mockScadaApi(page, [], createAnalysisRuntimeSnapshot(latestAnalysis));
}