feat(workbench): integrate runtime data and map tooling
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
}]);
|
||||
}
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 257 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 92 KiB |
@@ -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));
|
||||
}
|
||||
|
||||