feat(workbench): integrate operational workflows and map state

This commit is contained in:
2026-09-24 04:34:46 +08:00
parent 1752b9ef84
commit 436d5500f1
76 changed files with 5081 additions and 1937 deletions
+16 -5
View File
@@ -200,14 +200,18 @@ test("docked Agent respects forced color mode", async ({ page }) => {
test("long history titles keep rename and explicit delete usable across viewport sizes", async ({
page
}) => {
test.setTimeout(60_000);
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({
await page.route(/\/api\/v1\/agent\/chat\/sessions(?:\?.*)?$/, (route) =>
route.request().method() === "OPTIONS"
? route.fallback()
: route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
json: {
sessions: deleted
? []
@@ -226,17 +230,24 @@ test("long history titles keep rename and explicit delete usable across viewport
await page.route("**/api/v1/agent/chat/session/**", async (route) => {
const request = route.request();
const method = request.method();
if (method === "OPTIONS") return route.fallback();
const path = new URL(request.url()).pathname;
if (method === "PATCH" && path === `/api/v1/agent/chat/session/${sessionId}/title`) {
const body = request.postDataJSON();
title = body.title;
writes.push({ method, path, body });
return route.fulfill({ json: {} });
return route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
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.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
json: {}
});
}
return route.fallback();
});
@@ -252,7 +263,7 @@ test("long history titles keep rename and explicit delete usable across viewport
.click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
const modelButton = panel.getByRole("button", { name: "选择 Agent 模型" });
await expect(modelButton).toBeVisible();
await expect(modelButton).toBeVisible({ timeout: 10_000 });
await expect(modelButton).toHaveAccessibleDescription("当前模型:测试模型");
await expect(modelButton).toHaveAttribute("title", "测试模型");
await waitForAnimations(panel);
+17 -2
View File
@@ -196,6 +196,18 @@ test("stream completion and the plan card remain positionally stable", async ({
);
await page.route("https://stream-map.invalid/**", (route) => route.fulfill({ status: 204 }));
await page.route("**/api/v1/agent/chat/**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: {
"Access-Control-Allow-Headers": "Authorization, Content-Type, X-Project-Id",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Origin": "*"
}
});
return;
}
const path = new URL(route.request().url()).pathname;
if (path.endsWith("/stream")) {
@@ -219,7 +231,10 @@ test("stream completion and the plan card remain positionally stable", async ({
? { sessions: [] }
: {};
await route.fulfill({ json: body });
await route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
json: body
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -227,7 +242,7 @@ test("stream completion and the plan card remain positionally stable", async ({
await expect(
page.getByRole("button", { name: "选择 Agent 模型" }).filter({ visible: true }).first()
).toContainText("测试模型");
).toContainText("测试模型", { timeout: 10_000 });
const prompt = page
.getByPlaceholder(/输入调度问题/)
.filter({ visible: true })
+8 -8
View File
@@ -9,23 +9,23 @@ test("desktop controls share focus, hover, press and open states", async ({ page
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const scenario = page.getByRole("button", { name: "切换模拟方案" });
await expect(scenario).toBeVisible();
await expect.poll(async () => (await scenario.boundingBox())?.height).toBe(32);
const conditionFeed = page.getByRole("button", { name: "工况任务" });
await expect(conditionFeed).toBeVisible();
await expect.poll(async () => (await conditionFeed.boundingBox())?.height).toBe(32);
const compactHitArea = await scenario.evaluate(
const compactHitArea = await conditionFeed.evaluate(
(element) => getComputedStyle(element, "::after").inset
);
expect(compactHitArea).toBe("-4px");
await scenario.focus();
await conditionFeed.focus();
await expect
.poll(async () => scenario.evaluate((element) => getComputedStyle(element).boxShadow))
.poll(async () => conditionFeed.evaluate((element) => getComputedStyle(element).boxShadow))
.not.toBe("none");
await scenario.hover();
await conditionFeed.hover();
await expect
.poll(async () => scenario.evaluate((element) => getComputedStyle(element).backgroundColor))
.poll(async () => conditionFeed.evaluate((element) => getComputedStyle(element).backgroundColor))
.not.toBe("rgba(0, 0, 0, 0)");
const pressTarget = page.getByRole("button", { name: /任务浮条/ });
+168 -6
View File
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { writeFile } from "node:fs/promises";
import { expect, test, type Page } from "@playwright/test";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
const EMPTY_RASTER_TILE = Buffer.from(
@@ -6,13 +7,71 @@ const EMPTY_RASTER_TILE = Buffer.from(
"base64"
);
type FrameTimeSummary = {
frames: number;
median_frame_ms: number;
p95_frame_ms: number;
slow_frame_ratio: number;
};
async function measureFramePerformance(page: Page, bearing?: number): Promise<FrameTimeSummary> {
const frameTimes = await page.evaluate(
({ targetBearing }) =>
new Promise<number[]>((resolve) => {
const samples: number[] = [];
const startedAt = performance.now();
let previousFrame = startedAt;
const sample = (now: number) => {
samples.push(now - previousFrame);
previousFrame = now;
if (now - startedAt >= 1_800) {
resolve(samples.slice(1));
return;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
if (targetBearing !== null) {
window.__waterNetworkMap!.easeTo({ bearing: targetBearing, duration: 1_400 });
}
}),
{ targetBearing: bearing ?? null }
);
const sorted = [...frameTimes].sort((left, right) => left - right);
const percentile = (value: number) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * value))];
return {
frames: sorted.length,
median_frame_ms: percentile(0.5),
p95_frame_ms: percentile(0.95),
slow_frame_ratio: sorted.filter((frameTime) => frameTime > 33.4).length / sorted.length
};
}
async function waitForMapIdle(page: Page) {
await page.evaluate(
() =>
new Promise<void>((resolve) => {
const map = window.__waterNetworkMap!;
if (map.loaded()) {
resolve();
return;
}
const timeout = window.setTimeout(resolve, 5_000);
map.once("idle", () => {
window.clearTimeout(timeout);
resolve();
});
})
);
}
// Uses generated local building data, while unrelated backend services are isolated.
test("3D adds opaque buildings without enabling duplicate GIS layers", async ({ page }) => {
test.skip(
process.env.TJWATER_CITY_SCENE_TEST !== "1",
"Requires the generated local city-model scene"
);
test.setTimeout(60000);
test.setTimeout(90000);
await mockWorkbenchContext(page, undefined, undefined, {}, [], {
tiandituToken: "city-scene-tianditu-token"
});
@@ -61,6 +120,46 @@ test("3D adds opaque buildings without enabling duplicate GIS layers", async ({
expect(buildingPoint).not.toBeNull();
const mapCanvasBounds = await page.locator(".maplibregl-canvas").boundingBox();
expect(mapCanvasBounds).not.toBeNull();
await page.evaluate(({ x, y }) => {
const map = window.__waterNetworkMap!;
const location = map.unproject([x, y]);
const source = map.getSource("scada") as { setData: (data: unknown) => void };
source.setData({
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Point", coordinates: [location.lng, location.lat] },
properties: {
device_id: "OCCLUDED-SCADA-001",
device_type: "pressure",
name: "建筑后方测试点",
status: "online",
value: 0.31,
unit: "MPa"
}
}
]
});
}, buildingPoint!);
await expect
.poll(() =>
page.evaluate(
({ x, y }) =>
window.__waterNetworkMap!.queryRenderedFeatures([x, y], {
layers: ["scada-pressure-hit"]
}).length,
buildingPoint!
)
)
.toBeGreaterThan(0);
await page.mouse.move(
mapCanvasBounds!.x + buildingPoint!.x,
mapCanvasBounds!.y + buildingPoint!.y
);
await expect
.poll(() => page.locator(".maplibregl-canvas").evaluate((canvas) => canvas.style.cursor))
.toBe("");
await page.mouse.click(
mapCanvasBounds!.x + buildingPoint!.x,
mapCanvasBounds!.y + buildingPoint!.y
@@ -79,16 +178,29 @@ test("3D adds opaque buildings without enabling duplicate GIS layers", async ({
"fengxian-city-buildings",
"fill-extrusion-opacity"
),
light: window.__waterNetworkMap!.getLight(),
antialias: window
.__waterNetworkMap!
.getCanvas()
.getContext("webgl2")
?.getContextAttributes()?.antialias,
layerOrder: window.__waterNetworkMap!.getStyle().layers.map((layer) => layer.id)
}));
expect(scene.layers).toEqual(["fengxian-city-buildings"]);
expect(scene.sources).toEqual(["fengxian-city-buildings"]);
expect(scene.opacity).toBe(1);
expect(scene.light).toMatchObject({
anchor: "map",
position: [1.5, 225, 35],
color: "#EAF2F4",
intensity: 0.42
});
expect(scene.antialias).toBe(true);
const buildingIndex = scene.layerOrder.indexOf("fengxian-city-buildings");
expect(buildingIndex).toBeGreaterThan(scene.layerOrder.indexOf("tianditu-vector-annotation"));
expect(buildingIndex).toBeLessThan(
scene.layerOrder.findIndex((layerId) => layerId.startsWith("pipes-"))
);
expect(buildingIndex).toBeGreaterThan(scene.layerOrder.indexOf("pipes-flow"));
expect(buildingIndex).toBeGreaterThan(scene.layerOrder.indexOf("pipes-hover"));
expect(buildingIndex).toBeLessThan(scene.layerOrder.indexOf("pipes-hit"));
for (const layer of businessLayers) {
expect(
await page.evaluate(
@@ -97,10 +209,24 @@ test("3D adds opaque buildings without enabling duplicate GIS layers", async ({
)
).toBe(layer.visibility);
}
await page.evaluate(() => window.__waterNetworkMap?.rotateTo(90, { duration: 0 }));
await waitForMapIdle(page);
const desktopStationaryPerformance = await measureFramePerformance(page);
const desktopRotationPerformance = await measureFramePerformance(page, 90);
expect(desktopStationaryPerformance.frames).toBeGreaterThan(5);
expect(desktopRotationPerformance.frames).toBeGreaterThan(5);
await expect(page.locator("[data-map-compass]")).toHaveAttribute("data-bearing", "90.0");
await page.evaluate(() => window.__waterNetworkMap?.rotateTo(0, { duration: 0 }));
await page.screenshot({ path: "../city-model-fengxian/reports/browser-nanqiao.png" });
const downloadPromise = page.waitForEvent("download");
const moreButton = page.getByRole("button", { name: "更多:更多地图操作", exact: true });
await moreButton.click();
await page.getByRole("button", { name: /^当前分辨率 / }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^supply-network-view-1\.0x-/);
await download.saveAs("../city-model-fengxian/reports/browser-export.png");
await expect(page.getByText(/已导出 \d+ × \d+ PNG。/)).toBeVisible();
await moreButton.click();
await expect(moreButton).toHaveAttribute("aria-pressed", "false");
await toggle.click();
await expect
.poll(() =>
@@ -112,8 +238,44 @@ test("3D adds opaque buildings without enabling duplicate GIS layers", async ({
await page.setViewportSize({ width: 390, height: 844 });
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
await expect
.poll(
() =>
page.evaluate(
() =>
window.__waterNetworkMap?.queryRenderedFeatures({ layers: ["fengxian-city-buildings"] })
.length ?? 0
),
{ timeout: 30000 }
)
.toBeGreaterThan(0);
await waitForMapIdle(page);
const mobileStationaryPerformance = await measureFramePerformance(page);
const mobileRotationPerformance = await measureFramePerformance(page, -45);
expect(mobileStationaryPerformance.frames).toBeGreaterThan(5);
expect(mobileRotationPerformance.frames).toBeGreaterThan(5);
const box = await toggle.boundingBox();
expect(box?.width).toBe(40);
expect(box?.height).toBe(40);
await page.screenshot({ path: "../city-model-fengxian/reports/browser-mobile.png" });
await writeFile(
"../city-model-fengxian/reports/browser-performance.json",
`${JSON.stringify(
{
median_frame_ms: desktopRotationPerformance.median_frame_ms,
p95_frame_ms: desktopRotationPerformance.p95_frame_ms,
samples: {
desktop_stationary: desktopStationaryPerformance,
desktop_rotation: desktopRotationPerformance,
mobile_stationary: mobileStationaryPerformance,
mobile_rotation: mobileRotationPerformance
},
environment:
"Playwright Chromium on this workstation; stationary and rotating desktop/mobile 3D views with MSAA"
},
null,
2
)}\n`,
"utf8"
);
});
+2
View File
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
test("Dev Panel opens as a floating desktop panel and closes with Escape", async ({ page }) => {
test.setTimeout(60_000);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean(window.__waterNetworkMap));
await page.waitForFunction(() => Boolean(
@@ -52,6 +53,7 @@ test("Dev entry is absent below the desktop breakpoint", async ({ page }) => {
});
test("labels remain active per business group and clear globally", async ({ page }) => {
test.setTimeout(60_000);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean(
window.__waterNetworkMap?.getLayer("workbench-value-label-pipes")
+5 -1
View File
@@ -142,9 +142,13 @@ test.describe("mobile workbench sheet", () => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开工况任务" }).click();
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
await sheet.getByRole("button", { name: "展开工况任务" }).click();
await sheet.getByRole("button", { name: /预约任务/ }).click();
await sheet.getByRole("button", { name: "收起工况任务" }).click();
const awaitingReplyWorkOrder = sheet
.locator('button[aria-pressed]')
.filter({ hasText: "演示工单:泵站边界复核复令" });
.filter({ hasText: "演示工单:泵站边界复核复令" })
.last();
await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible();
await awaitingReplyWorkOrder.click();
@@ -0,0 +1,455 @@
import { expect, test } from "@playwright/test";
import type {
OperationalTask,
OperationalTaskAction,
OperationalTaskSummary
} from "../../src/features/workbench/api/operational-task-client";
import { mockAgentApi } from "./support/mock-agent-api";
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const taskId = "72000000-0000-4000-8000-000000000001";
const appointmentTaskId = "72000000-0000-4000-8000-000000000002";
const actionId = "73000000-0000-4000-8000-000000000001";
test("审批动作展示中文参数和证据,viewer 不可审批,冲突后可核对未知回执", async ({ page }, testInfo) => {
test.setTimeout(60_000);
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: projectId, 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 }));
const baseAction: OperationalTaskAction = {
action_id: actionId,
task_id: taskId,
version: 3,
kind: "issue_command",
node_id: null,
reason: "隔离疑似漏损支路前需要调度员确认阀门目标与开度",
payload: {
command_type: "set_valve",
target: { kind: "valve", id: "V-102" },
parameters: { opening: 35 },
impact_summary: "隔离疑似漏损支路,可能短时影响演示小区供水压力",
preconditions: ["上游压力不低于 28m", "现场人员已到位"],
expires_at: "2026-09-23T09:00:00Z"
},
evidence_refs: ["e-pressure"],
required_approval_permission: "operational_action.command.approve",
required_execution_permission: "operational_action.command.execute",
approval_status: "pending",
execution_status: "not_submitted",
execution_request_id: null,
proposed_by: "agent",
proposed_at: "2026-09-23T08:25:00Z",
decided_by: null,
decided_at: null,
decision_comment: null,
executed_by: null,
executed_at: null,
receipt: null,
execution_detail: null
};
let task: OperationalTask = {
project_id: projectId,
task_id: taskId,
task_type: "event_investigation",
workflow: "burst_report",
lifecycle_status: "waiting_human",
active_run: null,
status: "waiting_human",
priority: "urgent",
revision: 2,
execution_generation: 2,
execution_request_id: "74000000-0000-4000-8000-000000000001",
title: "阀门调节审批",
summary: "爆管事件调查需要确认隔离阀门动作",
source: { kind: "operator", external_id: null, external_revision: null, event_kind: "burst_report" },
trigger: { kind: "manual", occurred_at: "2026-09-23T08:20:00Z", detail: "演示路与样板路交叉口" },
scheduled_for: null,
agent_session_id: "approval-session",
agent_lease_expires_at: null,
current_plan_revision: null,
current_report_revision: 1,
created_at: "2026-09-23T08:20:00Z",
updated_at: "2026-09-23T08:25:00Z",
latest_plan: null,
latest_report: {
revision: 1,
execution_generation: 1,
source_session_id: "approval-session",
published_at: "2026-09-23T08:24:00Z",
published_by: "agent",
report: {
title: "爆管事件调查报告",
current_conclusion: "疑似支路漏损,阀门动作尚未执行。",
conclusion_boundary: "等待人工确认和设备回执。",
evidence_refs: ["e-pressure"],
recommendations: ["确认 V-102 目标与开度后再执行"],
pending_items: ["调度员确认阀门动作"]
}
},
evidence: [{
evidence_id: "e-pressure",
kind: "system_data",
summary: "V-102 下游压力在五分钟内下降 8.2m",
source_label: "SCADA 压力监测",
source_ref: "PRESSURE-V102",
observed_at: "2026-09-23T08:18:00Z",
recorded_at: "2026-09-23T08:23:00Z",
recorded_by: "agent",
has_snapshot: false
}],
actions: [baseAction],
permissions: {
can_publish_artifacts: false,
can_propose_actions: false,
can_sync_external_feedback: false,
can_decide_work_orders: false,
can_execute_work_orders: false,
can_decide_commands: false,
can_execute_commands: false
},
work_order_snapshot: null
};
const appointmentTask: OperationalTask = {
...task,
task_id: appointmentTaskId,
task_type: "external_work_order",
workflow: "general",
lifecycle_status: "open",
active_run: null,
status: "queued",
priority: "normal",
execution_request_id: "74000000-0000-4000-8000-000000000002",
title: "城北阀井预约维修",
summary: "外围工单同步的预约维修任务",
source: {
kind: "external_system",
external_id: "sample-work-order-1",
external_revision: 2,
event_kind: "general"
},
trigger: {
kind: "external_sync",
occurred_at: "2026-09-23T08:30:00Z",
detail: "预约 09/23 18:00 由北片区维修组处理"
},
scheduled_for: "2026-09-23T10:00:00Z",
agent_session_id: null,
current_report_revision: null,
latest_report: null,
evidence: [],
actions: [],
work_order_snapshot: {
work_order_id: "50000000-0000-4000-8000-000000000001",
revision: 2,
title: "城北阀井预约维修",
summary: "编辑后的预约维修说明",
status: "open",
priority: "normal",
assignee: "测试班组乙",
scheduled_at: "2026-09-23T10:00:00Z",
work_order_type: "general",
location: "联调测试点东侧",
scenario_code: "APPOINTMENT-TEST-001",
subtasks: [{
id: "field-service",
title: "阀井现场维修",
summary: "按预约窗口完成现场维修并回填结果。",
status: "pending",
scheduled_at: "2026-09-23T10:00:00Z",
location: "联调测试点东侧",
assignee: "测试班组乙",
duration_minutes: 60,
reply_window_minutes: 30,
stages: [
{ id: "dispatch", sequence: 1, title: "派发", status: "pending", owner: "调度岗", time_label: "待安排", description: "确认预约窗口。" },
{ id: "execution", sequence: 2, title: "执行", status: "pending", owner: "测试班组乙", time_label: "待安排", description: "完成现场维修。" },
{ id: "reply", sequence: 3, title: "复令", status: "pending", owner: "调度岗", time_label: "待安排", description: "核对回执并闭环。" }
],
reply_requirements: ["回填现场处理结果"]
}],
origin: "synthetic",
updated_at: "2026-09-23T08:30:00Z"
}
};
const summary = (item: OperationalTask): OperationalTaskSummary => {
const {
latest_plan,
latest_report,
evidence,
actions,
permissions,
work_order_snapshot,
...taskSummary
} = item;
void latest_plan; void latest_report; void evidence; void actions; void permissions; void work_order_snapshot;
return taskSummary;
};
let scheduleRequests = 0;
const runtimeSnapshot = {
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-23T08:30:00Z",
conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: []
};
await page.route("http://server.test/api/v1/workbench/sync-snapshot", (route) =>
route.fulfill({ json: {
schema: "workbench-sync-snapshot@1", event_cursor: 0, runtime: runtimeSnapshot,
tasks: [summary(task), summary(appointmentTask)], notifications: [],
generated_at: "2026-09-23T08:30:00Z"
} })
);
await page.route("http://server.test/api/v1/workbench/events**", (route) =>
route.fulfill({ contentType: "text/event-stream", body: ": test keepalive\n\n" })
);
await page.route("http://server.test/api/v1/operational-task-notifications**", (route) =>
route.fulfill({ json: { items: [], limit: 100, offset: 0, next_offset: null } })
);
await page.route("http://server.test/api/v1/operational-tasks**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname.endsWith("/operational-tasks/schedules")) {
scheduleRequests += 1;
return route.fulfill({ json: { items: [] } });
}
if (request.method() === "GET" && pathname.endsWith("/operational-tasks")) {
return route.fulfill({ json: { items: [summary(task), summary(appointmentTask)], limit: 50, offset: 0, next_offset: null } });
}
if (request.method() === "GET" && pathname.endsWith(`/operational-tasks/${taskId}`)) {
return route.fulfill({ json: task });
}
if (request.method() === "GET" && pathname.endsWith(`/operational-tasks/${appointmentTaskId}`)) {
return route.fulfill({ json: appointmentTask });
}
if (request.method() === "POST" && pathname.endsWith(`/actions/${actionId}/decision`)) {
return route.fulfill({ status: 409, json: { detail: "动作版本冲突:expected 3, actual 4" } });
}
if (request.method() === "POST" && pathname.endsWith(`/actions/${actionId}/reconcile`)) {
const receiptAction = {
...task.actions[0],
version: 5,
execution_status: "succeeded",
receipt: {
adapter: "scada-command-adapter",
receipt_id: "SCADA-RECEIPT-20260923-01",
submitted_at: "2026-09-23T08:28:00Z",
completed_at: "2026-09-23T08:29:00Z",
external_status: "completed",
detail: "V-102 开度已调整至 35%"
},
execution_detail: "设备回执确认完成"
};
task = { ...task, actions: [receiptAction] };
return route.fulfill({ json: receiptAction });
}
return route.fulfill({ status: 404, json: {} });
});
const openTask = async () => {
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
const expandButton = conditionPanel.getByRole("button", { name: "展开工况任务" });
if (await expandButton.isVisible()) await expandButton.click();
await conditionPanel.getByRole("button", { name: /调查任务/ }).click();
await conditionPanel.getByText("阀门调节审批", { exact: true }).first().click();
return conditionPanel;
};
await page.goto("/");
let conditionPanel = await openTask();
await expect(conditionPanel.getByRole("navigation", { name: "工况任务分类" })).toBeVisible();
await expect(conditionPanel.getByRole("button", { name: /定时任务/ })).toContainText("模拟数据");
await expect(conditionPanel.getByRole("button", { name: /调查任务/ })).toContainText("后端");
await expect(conditionPanel.getByRole("button", { name: /预约任务/ })).toContainText("后端");
await expect(conditionPanel.getByRole("button", { name: /预约任务/ })).toContainText("演示");
await conditionPanel.getByRole("button", { name: "配置定时流程" }).click();
await conditionPanel.getByLabel("定时流程名称").fill("夜间阀门巡检");
await conditionPanel.getByLabel("任务说明").fill("每十五分钟检查预约维修涉及的边界阀门。");
await conditionPanel.getByRole("button", { name: "创建定时流程" }).click();
await expect(conditionPanel.getByText("夜间阀门巡检", { exact: true }).first()).toBeVisible();
await conditionPanel.getByRole("button", { name: /预约任务/ }).click();
await expect(conditionPanel.getByText("城北阀井预约维修", { exact: true }).first()).toBeVisible();
await expect(conditionPanel.getByText(/^演示工单:/).first()).toBeVisible();
await conditionPanel.getByText("城北阀井预约维修", { exact: true }).first().click();
await expect(conditionPanel.getByText(/阀井现场维修.*待处理/)).toBeVisible();
await expect(conditionPanel.getByText("测试班组乙", { exact: true }).first()).toBeVisible();
await expect(conditionPanel.getByText(/位置:联调测试点东侧/)).toBeVisible();
await expect(conditionPanel.getByText("编辑后的预约维修说明", { exact: true })).toBeVisible();
await expect(conditionPanel.getByText("预约 09/23 18:00 由北片区维修组处理", { exact: true })).toHaveCount(0);
await page.waitForTimeout(3_500);
await page.addStyleTag({ content: "[data-sonner-toaster] { display: none !important; }" });
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: "/tmp/tjwater-operational-ui-review/task-categories-desktop.png" });
await conditionPanel.getByRole("button", { name: /调查任务/ }).click();
expect(scheduleRequests).toBe(0);
await expect(conditionPanel.getByText("上一轮报告,当前任务待人工", { exact: true })).toBeVisible();
await expect(conditionPanel.getByText(/第\s*[12]\s*代|generation/i)).toHaveCount(0);
const actionCard = conditionPanel.locator(`[data-action-id="${actionId}"]`);
await expect(actionCard.getByText("命令类型", { exact: true })).toBeVisible();
await expect(actionCard.getByText("对象类型", { exact: true })).toBeVisible();
await expect(actionCard.getByText("目标对象", { exact: true })).toBeVisible();
await expect(actionCard.getByText("执行参数", { exact: true })).toBeVisible();
await expect(actionCard.getByText("影响说明", { exact: true })).toBeVisible();
await expect(actionCard.getByText("调节阀门", { exact: true })).toBeVisible();
await expect(actionCard.getByText("阀门", { exact: true })).toBeVisible();
await expect(actionCard.getByText("开度 35%", { exact: true })).toBeVisible();
await expect(actionCard.getByText("确认依据", { exact: true })).toBeVisible();
await expect(actionCard.getByText(/V-102 下游压力.*SCADA 压力监测/)).toBeVisible();
await expect(actionCard.getByText(/当前账号仅可查看/)).toBeVisible();
await expect(actionCard.getByText(/operational_action\.command\.approve/)).toHaveCount(0);
await expect(actionCard.getByRole("button", { name: "批准下发命令" })).toHaveCount(0);
await page.evaluate(() => document.fonts.ready);
await testInfo.attach("approval-viewer-desktop", {
body: await actionCard.screenshot({ path: testInfo.outputPath("approval-viewer-desktop.png") }),
contentType: "image/png"
});
task = {
...task,
permissions: { ...task.permissions, can_decide_commands: true }
};
await page.reload();
conditionPanel = await openTask();
await conditionPanel.getByRole("button", { name: "批准下发命令" }).click();
await expect(conditionPanel.getByRole("alert")).toContainText("动作版本冲突:expected 3, actual 4");
const refreshButton = conditionPanel.getByRole("button", { name: "刷新后重新确认" });
task = {
...task,
actions: [{
...task.actions[0],
version: 4,
approval_status: "approved",
execution_status: "unknown",
execution_request_id: "75000000-0000-4000-8000-000000000001",
decided_by: "dispatcher",
decided_at: "2026-09-23T08:27:00Z"
}],
permissions: { ...task.permissions, can_execute_commands: true }
};
await refreshButton.click();
const reconcileButton = conditionPanel.getByRole("button", { name: "核对执行回执" });
await expect(reconcileButton).toBeVisible();
await reconcileButton.click();
const receipt = conditionPanel.getByRole("region", { name: "执行回执" });
await expect(receipt).toContainText("SCADA-RECEIPT-20260923-01");
await expect(receipt).toContainText("已完成");
await expect(receipt).toContainText("V-102 开度已调整至 35%");
await testInfo.attach("approval-receipt-desktop", {
body: await actionCard.screenshot({ path: testInfo.outputPath("approval-receipt-desktop.png") }),
contentType: "image/png"
});
});
test("后端没有调查任务时显示空态,分类标签按压不缩放", async ({ page }, testInfo) => {
test.setTimeout(60_000);
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: projectId, 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://server.test/api/v1/operational-task-notifications**", (route) =>
route.fulfill({ json: { items: [], limit: 100, offset: 0, next_offset: null } })
);
await page.route("http://server.test/api/v1/operational-tasks**", (route) =>
route.fulfill({ json: { items: [], limit: 50, offset: 0, next_offset: null } })
);
await page.route("http://server.test/api/v1/workbench/sync-snapshot", (route) =>
route.fulfill({ json: {
schema: "workbench-sync-snapshot@1", event_cursor: 0,
runtime: {
availability: "not_configured", source: "none",
scenario_availability: "not_configured", artifact_availability: "not_configured",
schedule_availability: "not_configured", work_order_availability: "not_configured",
alert_availability: "not_configured", generated_at: "2026-09-23T08:30:00Z",
conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: []
},
tasks: [], notifications: [], generated_at: "2026-09-23T08:30:00Z"
} })
);
await page.route("http://server.test/api/v1/workbench/events**", (route) =>
route.fulfill({ contentType: "text/event-stream", body: ": test keepalive\n\n" })
);
await page.route("http://map.test/**", (route) => route.fulfill({ status: 204 }));
await page.goto("/");
await page.getByRole("button", { name: "工况任务", exact: true }).press("Enter");
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
const expandButton = conditionPanel.getByRole("button", { name: "展开工况任务" });
if (await expandButton.isVisible()) await expandButton.click();
await conditionPanel.getByRole("button", { name: /调查任务/ }).click();
await expect(conditionPanel.getByText("暂无任务", { exact: true })).toBeVisible();
await expect(conditionPanel.getByText("新任务进入当前分类后会出现在这里。", { exact: true })).toBeVisible();
for (const width of [1280, 375, 320]) {
await page.setViewportSize({ width, height: 900 });
if (width <= 375) {
const openMobileTasks = page.getByRole("button", { name: "打开工况任务", exact: true });
if (await openMobileTasks.isVisible()) await openMobileTasks.click();
const expandMobile = conditionPanel.getByRole("button", { name: "展开工况任务" });
if (await expandMobile.isVisible()) await expandMobile.click();
}
const categories = conditionPanel.getByRole("navigation", { name: "工况任务分类" });
for (const label of ["定时任务", "调查任务", "预约任务"]) {
const tab = categories.getByRole("button", { name: new RegExp(label) });
await tab.hover();
const before = await tab.boundingBox();
expect(before).not.toBeNull();
await page.mouse.down();
try {
await tab.evaluate(async (element) => {
await Promise.all(element.getAnimations().map((animation) => animation.finished));
});
await expect.poll(() => tab.evaluate((element) => getComputedStyle(element).scale)).toBe("none");
const pressed = await tab.boundingBox();
expect(pressed).not.toBeNull();
expect(Math.abs(pressed!.width - before!.width)).toBeLessThan(0.5);
expect(Math.abs(pressed!.height - before!.height)).toBeLessThan(0.5);
} finally {
await page.mouse.up();
}
await expect(tab).toHaveAttribute("aria-current", "page");
}
await categories.screenshot({ path: testInfo.outputPath(`task-tabs-${width}.png`) });
}
await page.emulateMedia({ reducedMotion: "reduce" });
const investigationTab = conditionPanel.getByRole("navigation", { name: "工况任务分类" })
.getByRole("button", { name: /调查任务/ });
await investigationTab.focus();
await page.keyboard.press("Enter");
await expect(investigationTab).toHaveAttribute("aria-current", "page");
await expect(investigationTab).toBeFocused();
await expect.poll(() => investigationTab.evaluate((element) => getComputedStyle(element).transitionProperty)).toBe("none");
});
+349 -319
View File
@@ -1,7 +1,7 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
test.describe("外围样板管理", () => {
test.describe("外围工单测试", () => {
test.beforeEach(async ({ page }) => {
await mockAgentApi(page);
await page.route("**/runtime-config.js", (route) =>
@@ -37,9 +37,8 @@ test.describe("外围样板管理", () => {
await page.route("http://map.test/**", (route) => route.fulfill({ status: 204 }));
});
test("展示已配置的历史回放与合成样本", async ({ page }, testInfo) => {
const observationQueries: Record<string, string>[] = [];
const writeRequests: string[] = [];
test("外围面板只读取工单能力,不再请求监测回放与测试数据", async ({ page }) => {
const requestedPaths: string[] = [];
await page.route("**/runtime-config.js", (route) =>
route.fulfill({
contentType: "text/javascript",
@@ -47,9 +46,7 @@ test.describe("外围样板管理", () => {
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"
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: "demo"
})};`
})
);
@@ -69,72 +66,16 @@ test.describe("外围样板管理", () => {
}
})
);
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]
};
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
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));
requestedPaths.push(url.pathname);
if (url.pathname.endsWith("/context"))
return route.fulfill({
json: {
protocol_version: "tjwater-peripheral-sample@1",
origin: "synthetic",
project_id: task.project_id,
project_id: projectId,
user_id: "50000000-0000-4000-8000-000000000001",
can_write: true,
scenarios: ["normal"],
@@ -147,68 +88,8 @@ test.describe("外围样板管理", () => {
}
}
});
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({ json: { items: createSourceWorkOrders(projectId), next_cursor: null } });
return route.fulfill({
status: 404,
json: { code: "NOT_FOUND", message: "not found", effect: "none", request_id: null }
@@ -216,7 +97,11 @@ test.describe("外围样板管理", () => {
});
await page.goto("/");
await page.getByRole("button", { name: "外围样板管理" }).click();
await expect(page.getByText(/118 个测点 · 告警来源未配置 · 真实项目样本回放/)).toBeVisible();
const panel = page.getByRole("complementary", { name: "外围工单测试" });
await expect(panel).toBeVisible();
await expect(panel.getByText("工单接口测试", { exact: true })).toBeVisible();
await expect(panel.getByText("历史监测回放", { exact: true })).toHaveCount(0);
await expect(panel.getByText("测试数据", { exact: true })).toHaveCount(0);
const workOrderList = page.getByLabel("工单列表");
await expect(workOrderList).toContainText("合成通信核查");
await expect(workOrderList).toContainText("合成压力偏差复核");
@@ -224,114 +109,12 @@ test.describe("外围样板管理", () => {
await expect(workOrderList).toContainText("待处理");
await expect(workOrderList).toContainText("处理中");
await expect(workOrderList).toContainText("已完成");
await expect(page.getByLabel("工单步骤 合成通信核查")).toContainText(/派发.*当前步骤.*执行.*待开始.*复令.*待开始/);
await expect(page.getByLabel("工单步骤 合成压力偏差复核")).toContainText(/派发.*已完成.*执行.*当前步骤.*复令.*待开始/);
await expect(page.getByLabel("工单步骤 合成零流量样本确认")).toContainText(/派发.*已完成.*执行.*已完成.*复令.*已完成/);
await expect(workOrderList).not.toContainText("外围合成数据核查");
await expect(workOrderList).not.toContainText("子任务");
await testInfo.attach("peripheral-work-orders-1280", {
body: await workOrderList.screenshot(),
contentType: "image/png"
});
for (const width of [375, 320]) {
await page.setViewportSize({ width, height: 720 });
const firstOrderSteps = page.getByLabel("工单步骤 合成通信核查");
await firstOrderSteps.scrollIntoViewIfNeeded();
await expect(firstOrderSteps).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await testInfo.attach(`peripheral-work-orders-${width}`, {
body: await page.screenshot(),
contentType: "image/png"
});
}
await page.setViewportSize({ width: 1280, height: 720 });
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([]);
await expect(workOrderList.getByText("未提供过程", { exact: true })).toHaveCount(3);
expect(requestedPaths).toEqual(expect.arrayContaining([
"/api/sample/v1/context",
"/api/sample/v1/work-orders"
]));
expect(requestedPaths.some((path) => path.includes("/monitoring/") || path.endsWith("/test-data") || path.endsWith("/demo-data"))).toBe(false);
});
test("未配置时从顶栏进入且不暗连外围服务", async ({ page }) => {
@@ -341,17 +124,72 @@ test.describe("外围样板管理", () => {
});
await page.goto("/");
await page.getByRole("button", { name: "外围样板管理" }).click();
await expect(page.getByRole("complementary", { name: "外围样板管理" })).toBeVisible();
await expect(page.getByRole("complementary", { name: "外围工单测试" })).toBeVisible();
await expect(
page
.getByRole("complementary", { name: "外围样板管理" })
.getByText("外围系统模拟接入,不是正式工单/现场结果", { exact: true })
.getByRole("complementary", { name: "外围工单测试" })
.getByText("仅用于验证外围工单接口与同步过程,不代表真实现场处置", { exact: true })
.last()
).toBeVisible();
await expect(page.getByText(/当前不会连接任何外围服务/)).toBeVisible();
await expect(page.getByText(/当前不会连接外围工单服务/)).toBeVisible();
expect(peripheralRequests).toEqual([]);
});
test("管理面板打开时阻止地图接收鼠标行为", async ({ page }) => {
await page.goto("/");
await page.locator(".maplibregl-canvas-container").evaluate((element) => {
const probeWindow = window as Window & { __mapPointerEvents?: string[] };
probeWindow.__mapPointerEvents = [];
for (const type of ["pointerdown", "wheel"] as const) {
element.addEventListener(type, () => probeWindow.__mapPointerEvents?.push(type), true);
}
});
const expectMapInteractionBlocked = async (panelName: string) => {
const panel = page.getByRole("complementary", { name: panelName });
const [panelBox, canvasBox] = await Promise.all([
panel.boundingBox(),
page.locator(".maplibregl-canvas").boundingBox()
]);
expect(panelBox).not.toBeNull();
expect(canvasBox).not.toBeNull();
const x = panelBox!.x - 24;
const y = panelBox!.y + 120;
expect(x).toBeGreaterThan(canvasBox!.x);
expect(y).toBeLessThan(canvasBox!.y + canvasBox!.height);
await expect
.poll(() =>
page.evaluate(
({ x: pointX, y: pointY }) =>
document.elementFromPoint(pointX, pointY)?.hasAttribute("data-map-interaction-shield"),
{ x, y }
)
)
.toBe(true);
await page.mouse.click(x, y);
await page.mouse.wheel(0, 120);
await expect
.poll(() =>
page.evaluate(
() => (window as Window & { __mapPointerEvents?: string[] }).__mapPointerEvents
)
)
.toEqual([]);
};
await page.getByRole("button", { name: "外围样板管理" }).click();
await expect(page.getByRole("complementary", { name: "外围工单测试" })).toBeVisible();
await expectMapInteractionBlocked("外围工单测试");
await page.getByRole("button", { name: "外围样板管理" }).click();
await page.getByRole("button", { name: "项目分析成果" }).click();
await expect(page.getByRole("complementary", { name: "项目分析成果" })).toBeVisible();
await expectMapInteractionBlocked("项目分析成果");
});
test("增量存储未供给时保留原有外围能力且不请求缺表资源", async ({ page }) => {
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const managedRequests: string[] = [];
@@ -390,17 +228,18 @@ test.describe("外围样板管理", () => {
await page.goto("/");
await page.getByRole("button", { name: "外围样板管理" }).click();
const panel = page.getByRole("complementary", { name: "外围样板管理" });
const panel = page.getByRole("complementary", { name: "外围工单测试" });
await expect(panel.getByText("测试工单存储待供给", { exact: true })).toBeVisible();
await expect(panel.getByText("测试数据存储待供给", { exact: true })).toBeVisible();
await expect(panel.getByText("连接与演练场景", { exact: true })).toBeVisible();
await expect(panel.getByText("测试数据存储待供给", { exact: true })).toHaveCount(0);
await expect(panel.getByText("历史监测回放", { exact: true })).toHaveCount(0);
expect(managedRequests).toEqual([]);
});
test("创建、修改并软删除测试工单和测试数据", async ({ page }, testInfo) => {
test("创建编辑真实过程、刷新外部更新并软删除预约工单", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
let workOrders: Record<string, unknown>[] = [];
let testData: Record<string, unknown>[] = [];
const writes: 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" })}`
@@ -411,83 +250,168 @@ test.describe("外围样板管理", () => {
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();
writes.push(body);
const item = { ...body, work_order_id: "50000000-0000-4000-8000-000000000001", project_id: projectId, revision: 1, origin: "synthetic", created_at: now, updated_at: now, created_by: "tester", updated_by: "tester" };
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;
const body = request.postDataJSON();
writes.push(body);
const next = { ...workOrders[0], ...body, revision: 2, updated_at: "2026-09-18T01:00:00Z" };
if (body.clear_assignee) next.assignee = null;
if (body.clear_location) next.location = null;
if (body.clear_scenario_code) next.scenario_code = null;
if (body.clear_scheduled_at) next.scheduled_at = null;
if (body.clear_subtasks) next.subtasks = null;
for (const key of ["request_id", "expected_revision", "clear_assignee", "clear_location", "clear_scenario_code", "clear_scheduled_at", "clear_subtasks"]) delete next[key];
workOrders = [next];
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();
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();
const workOrderEditor = panel.getByRole("textbox", { name: "标题" }).locator("xpath=../..");
await expect(workOrderEditor).toHaveCSS("background-color", "rgb(255, 255, 255)");
await testInfo.attach("work-order-editor-1280", {
body: await workOrderEditor.screenshot(),
contentType: "image/png"
await panel.getByRole("button", { name: "新增预约工单" }).click();
let editor = panel.getByRole("region", { name: "新增预约工单" });
await editor.getByRole("textbox", { name: "标题" }).fill("合成预约维修");
await editor.getByRole("textbox", { name: "说明", exact: true }).fill("验证外围过程结构同步");
await editor.getByRole("textbox", { name: "负责人", exact: true }).fill("测试班组甲");
await editor.getByRole("textbox", { name: "位置", exact: true }).fill("联调测试点西侧");
await editor.getByRole("textbox", { name: "场景编号" }).fill("APPOINTMENT-TEST-001");
await editor.getByRole("textbox", { name: "责任班组" }).fill("测试班组甲");
await editor.getByRole("textbox", { name: "处置位置" }).fill("联调测试点西侧");
await editor.getByRole("textbox", { name: "阶段说明", exact: true }).first().fill("确认班组甲和西侧作业窗口。");
await editor.getByRole("button", { name: "创建预约工单" }).click();
await expect(panel.getByText("合成预约维修", { exact: true })).toBeVisible();
expect(writes[0]).toMatchObject({
work_order_type: "general",
assignee: "测试班组甲",
location: "联调测试点西侧",
scenario_code: "APPOINTMENT-TEST-001",
subtasks: expect.arrayContaining([expect.objectContaining({
assignee: "测试班组甲",
location: "联调测试点西侧",
stages: expect.arrayContaining([expect.objectContaining({ description: "确认班组甲和西侧作业窗口。" })])
})])
});
await panel.getByRole("button", { name: "编辑工单 合成预约维修" }).click();
editor = panel.getByRole("region", { name: "编辑工单 合成预约维修" });
await editor.getByRole("textbox", { name: "标题" }).fill("合成预约维修(已对齐)");
await editor.getByRole("textbox", { name: "负责人", exact: true }).fill("测试班组乙");
await editor.getByRole("textbox", { name: "位置", exact: true }).fill("联调测试点东侧");
await editor.getByRole("textbox", { name: "责任班组" }).fill("测试班组乙");
await editor.getByRole("textbox", { name: "处置位置" }).fill("联调测试点东侧");
await editor.getByRole("textbox", { name: "阶段说明", exact: true }).first().fill("确认班组乙和东侧作业窗口,等待真实反馈。");
await editor.getByRole("combobox", { name: "工单状态" }).click();
await page.getByRole("option", { name: "待复令", exact: true }).click();
await editor.getByRole("button", { name: "保存工单与过程" }).click();
await expect(panel.getByText("合成预约维修(已对齐)", { exact: true })).toBeVisible();
expect(writes[1]).toMatchObject({
status: "awaiting_reply",
assignee: "测试班组乙",
location: "联调测试点东侧",
clear_assignee: false,
clear_location: false,
clear_subtasks: false,
subtasks: expect.arrayContaining([expect.objectContaining({
assignee: "测试班组乙",
location: "联调测试点东侧",
stages: expect.arrayContaining([expect.objectContaining({ description: "确认班组乙和东侧作业窗口,等待真实反馈。" })])
})])
});
expect(workOrders[0]).toMatchObject({ work_order_type: "general", scenario_code: "APPOINTMENT-TEST-001" });
workOrders = [{ ...workOrders[0], title: "外部系统更新后的预约维修", revision: 3 }];
await panel.getByRole("button", { name: "刷新外围工单" }).click();
await expect(panel.getByText("外部系统更新后的预约维修", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "编辑工单 外部系统更新后的预约维修" }).click();
const refreshedEditor = panel.getByRole("region", { name: "编辑工单 外部系统更新后的预约维修" });
await expect(refreshedEditor.getByRole("textbox", { name: "负责人", exact: true })).toHaveValue("测试班组乙");
await expect(refreshedEditor.getByRole("textbox", { name: "处置位置" })).toHaveValue("联调测试点东侧");
await expect(refreshedEditor.getByRole("textbox", { name: "阶段说明", exact: true }).first()).toHaveValue("确认班组乙和东侧作业窗口,等待真实反馈。");
for (const width of [1280, 375, 320]) {
await page.setViewportSize({ width, height: 780 });
await refreshedEditor.scrollIntoViewIfNeeded();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await testInfo.attach(`peripheral-work-order-process-${width}`, {
body: await page.screenshot({ path: `/tmp/tjwater-operational-ui-review/peripheral-work-order-edit-${width}.png` }),
contentType: "image/png"
});
}
await page.setViewportSize({ width: 1280, height: 780 });
await refreshedEditor.getByRole("button", { name: "关闭编辑表单" }).click();
await panel.getByRole("button", { name: "删除工单 外部系统更新后的预约维修" }).click();
await expect(panel.getByText("外部系统更新后的预约维修", { exact: true })).toBeHidden();
await page.route("http://server.test/api/v1/operational-tasks", (route) =>
route.fulfill({ status: 503, json: { detail: "operational task test stub" } })
);
await panel.getByRole("button", { name: "投诉与爆管上报" }).click();
await panel.getByRole("combobox", { name: "事件类型" }).click();
await page.getByRole("option", { name: "爆管上报", exact: true }).click();
await panel.getByRole("textbox", { name: "事件位置" }).fill("演示路与样板路交叉口");
await panel.getByRole("textbox", { name: "现场描述" }).fill("路面积水并伴随压力突降");
await panel.getByRole("button", { name: "创建事件工单" }).click();
await expect(panel.getByText("爆管上报", { exact: true }).last()).toBeVisible();
expect(workOrders[0]).toMatchObject({
work_order_type: "burst_report",
scenario_code: "BURST-REPORT-001",
title: "爆管上报:演示路与样板路交叉口",
status: "open",
priority: "urgent"
});
await page.setViewportSize({ width: 375, height: 720 });
await workOrderEditor.scrollIntoViewIfNeeded();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
await testInfo.attach("work-order-editor-375", {
body: await page.screenshot(),
contentType: "image/png"
});
await page.setViewportSize({ width: 1280, height: 720 });
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("创建水质投诉工单后进入结构化工况任务并由用户打开 Agent", async ({ page }, testInfo) => {
test.setTimeout(90_000);
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const workOrderId = "50000000-0000-4000-8000-000000000099";
const agentRequests: Array<Record<string, unknown>> = [];
let workOrders: Record<string, unknown>[] = [];
const plan = {
schema: "execution-plan@1", title: "水质投诉调查计划", reason: "先完成四路并行核查,汇合后再分两路核实",
nodes: [
{ id: "accept", title: "接收投诉工单", status: "completed", owner: "agent", detail: "已关联合成工单", evidence_refs: ["e-1"], action_refs: [] },
{ id: "upstream", title: "校验上游水质监测", status: "completed", owner: "agent", detail: "上游三个点位指标正常", evidence_refs: ["e-1"], action_refs: [] },
{ id: "upstream-review", title: "复核上游趋势", status: "completed", owner: "agent", detail: "过去两小时趋势稳定", evidence_refs: ["e-1"], action_refs: [] },
{ id: "nearby", title: "核对邻近投诉", status: "running", owner: "agent", detail: "检查投诉空间分布", evidence_refs: [], action_refs: [] },
{ id: "pressure", title: "比对压力波动", status: "completed", owner: "agent", detail: "泵房出水压力稳定", evidence_refs: [], action_refs: [] },
{ id: "hydraulic", title: "校核水力路径", status: "running", owner: "agent", detail: "追踪供水路径", evidence_refs: [], action_refs: [] },
{ id: "valve", title: "排查阀门操作", status: "pending", owner: "agent", detail: "等待水力路径结果", evidence_refs: [], action_refs: [] },
{ id: "maintenance", title: "核对运维记录", status: "waiting", owner: "human", detail: "等待近期冲洗记录", evidence_refs: [], action_refs: [] },
{ id: "stage-judgment", title: "阶段性判断", status: "completed", owner: "agent", detail: "疑似二次供水环节问题,待现场核验。", evidence_refs: ["e-1"], action_refs: [] },
{ id: "secondary", title: "核查二次供水", status: "running", owner: "agent", detail: "核对二次供水设施", evidence_refs: [], action_refs: [] },
{ id: "tank-record", title: "核对水箱记录", status: "waiting", owner: "human", detail: "等待水箱清洗记录", evidence_refs: [], action_refs: [] },
{ id: "sampling", title: "现场采样", status: "waiting", owner: "human", detail: "等待现场采样结果", evidence_refs: [], action_refs: [] },
{ id: "conclusion", title: "形成调查结论", status: "pending", owner: "agent", detail: "两路核验完成后汇总", evidence_refs: [], action_refs: [] }
],
edges: [
{ from: "accept", to: "upstream", condition: "上游监测" }, { from: "upstream", to: "upstream-review", condition: null }, { from: "upstream-review", to: "stage-judgment", condition: null },
{ from: "accept", to: "nearby", condition: "邻近投诉" }, { from: "nearby", to: "stage-judgment", condition: null },
{ from: "accept", to: "pressure", condition: "压力路径" }, { from: "pressure", to: "hydraulic", condition: null }, { from: "hydraulic", to: "valve", condition: null }, { from: "valve", to: "stage-judgment", condition: null },
{ from: "accept", to: "maintenance", condition: "运维记录" }, { from: "maintenance", to: "stage-judgment", condition: null },
{ from: "stage-judgment", to: "secondary", condition: "设施核验" }, { from: "secondary", to: "tank-record", condition: null }, { from: "tank-record", to: "conclusion", condition: null },
{ from: "stage-judgment", to: "sampling", condition: "现场采样" }, { from: "sampling", to: "conclusion", condition: null }
]
};
let operationalTask: Record<string, unknown> | null = null;
const summarizeTask = () => operationalTask
? Object.fromEntries(Object.entries(operationalTask).filter(([key]) => ![
"latest_plan", "latest_report", "evidence", "actions", "permissions", "work_order_snapshot"
].includes(key)))
: null;
page.on("request", (request) => {
if (
request.method() === "POST" &&
@@ -540,25 +464,107 @@ test.describe("外围样板管理", () => {
if (path.endsWith("/test-data")) return route.fulfill({ json: { items: [], next_cursor: null } });
return route.fulfill({ status: 404, json: { code: "NOT_FOUND", message: "not found", effect: "none", request_id: null } });
});
await page.route("http://server.test/api/v1/operational-task-notifications**", (route) =>
route.fulfill({ json: { items: [], limit: 100, offset: 0, next_offset: null } })
);
await page.route("http://server.test/api/v1/operational-tasks/schedules", (route) =>
route.fulfill({ json: { items: [] } })
);
await page.route("http://server.test/api/v1/workbench/sync-snapshot", (route) =>
route.fulfill({ json: {
schema: "workbench-sync-snapshot@1",
event_cursor: 0,
runtime: {
availability: "not_configured", source: "none",
scenario_availability: "not_configured", artifact_availability: "not_configured",
schedule_availability: "not_configured", work_order_availability: "not_configured",
alert_availability: "not_configured", generated_at: "2026-09-23T08:30:00Z",
conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: []
},
tasks: summarizeTask() ? [summarizeTask()] : [],
notifications: [],
generated_at: "2026-09-23T08:30:00Z"
} })
);
await page.route("http://server.test/api/v1/workbench/events**", (route) =>
route.fulfill({ contentType: "text/event-stream", body: ": test keepalive\n\n" })
);
await page.route("http://server.test/api/v1/operational-tasks**", async (route) => {
const request = route.request();
const url = new URL(request.url());
if (request.method() === "GET" && url.pathname.endsWith("/operational-tasks/schedules")) {
return route.fulfill({ json: { items: [] } });
}
if (request.method() === "POST" && url.pathname.endsWith("/operational-tasks")) {
const input = request.postDataJSON() as Record<string, unknown>;
operationalTask = {
project_id: projectId, task_id: "70000000-0000-4000-8000-000000000001",
task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null,
status: "waiting_human", priority: "normal",
revision: 2, execution_generation: 1, execution_request_id: "71000000-0000-4000-8000-000000000001",
title: "水质投诉调查", summary: input.summary, source: input.source, trigger: input.trigger,
scheduled_for: null, agent_session_id: "browser-session-1", agent_lease_expires_at: null,
current_plan_revision: 1, current_report_revision: 1,
created_at: "2026-09-23T08:20:00Z", updated_at: "2026-09-23T08:25:00Z",
latest_plan: { revision: 1, execution_generation: 1, source_session_id: "browser-session-1", published_at: "2026-09-23T01:08:00Z", published_by: "agent", plan },
latest_report: { revision: 1, execution_generation: 1, source_session_id: "browser-session-1", published_at: "2026-09-23T01:09:00Z", published_by: "agent", report: { title: "水质投诉调查报告", current_conclusion: "疑似二次供水环节问题,待现场核验。", conclusion_boundary: "现场采样和水箱清洗记录尚未返回。", evidence_refs: ["e-1"], recommendations: ["安排现场采样"], pending_items: ["确认水箱清洗记录"] } },
evidence: [{ evidence_id: "e-1", kind: "synthetic", summary: "上游余氯处于正常范围", source_label: "外围合成场景", source_ref: "WQ-DEMO-01", observed_at: "2026-09-23T00:55:00Z", recorded_at: "2026-09-23T01:08:00Z", recorded_by: "agent", has_snapshot: false }],
actions: [], permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false, can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false, can_execute_commands: false }
};
return route.fulfill({ status: 201, json: operationalTask });
}
if (request.method() === "GET" && url.pathname.endsWith("/operational-tasks")) {
const summary = summarizeTask();
return route.fulfill({ json: { items: summary ? [summary] : [], limit: 50, offset: 0, next_offset: null } });
}
if (request.method() === "GET" && operationalTask) return route.fulfill({ json: operationalTask });
return route.fulfill({ status: 404, json: {} });
});
await page.goto("/");
await page.getByRole("button", { name: "外围样板管理" }).click();
const panel = page.getByRole("complementary", { name: "外围样板管理" });
await panel.getByRole("button", { name: "水质投诉演示" }).click();
const panel = page.getByRole("complementary", { name: "外围工单测试" });
await panel.getByRole("button", { name: "投诉与爆管上报" }).click();
await panel.getByRole("textbox", { name: "事件位置" }).fill("演示小区");
await panel.getByRole("textbox", { name: "现场描述" }).fill("多户居民反映晨间用水有异味");
await panel.getByRole("button", { name: "创建事件工单" }).click();
await expect(panel).toBeHidden();
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeHidden();
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(conditionPanel).toBeVisible();
await conditionPanel.getByRole("button", { name: /调查任务/ }).click();
await expect(conditionPanel.getByRole("heading", { name: "水质投诉调查", exact: true })).toBeVisible();
await expect(conditionPanel.getByText("演示执行流程 · 水质投诉调查流程", { exact: true })).toBeVisible();
await expect(conditionPanel.getByText("接收投诉工单", { exact: true }).last()).toBeVisible();
await expect(conditionPanel.getByText("校验上游水质监测", { exact: true }).last()).toBeVisible();
await expect(conditionPanel.getByText("研判供水链路", { exact: true }).last()).toBeVisible();
await expect(conditionPanel.getByText("核查二次供水", { exact: true }).last()).toBeVisible();
await expect(conditionPanel.getByText("汇总结论与处置", { exact: true }).last()).toBeVisible();
await expect(conditionPanel.getByText("调查处理流程", { exact: true })).toBeVisible();
await expect(conditionPanel.getByText(/已完成 5 项 · 执行中 3 项 · 待人工或资料 3 项/)).toBeVisible();
await expect(conditionPanel.locator('[data-execution-node]')).toHaveCount(13);
await expect(conditionPanel.getByLabel("并行分枝 4 项")).toBeVisible();
await expect(conditionPanel.getByLabel("并行分枝 2 项")).toBeVisible();
await expect(conditionPanel.getByRole("button", { name: "完整工程图" })).toHaveCount(0);
await expect(conditionPanel.getByText("节点与执行记录", { exact: true })).toHaveCount(0);
await expect(conditionPanel.getByLabel("计划版本", { exact: true })).toHaveCount(0);
await expect(conditionPanel.locator('[data-execution-node]')).toHaveCount(13);
await expect(conditionPanel.getByText("等待水箱清洗记录", { exact: true }).first()).toBeVisible();
const report = conditionPanel.getByRole("region", { name: "任务报告" });
await expect(report.getByText(/疑似二次供水环节问题,待现场核验/)).toBeVisible();
await expect(report.getByText("完整调查报告", { exact: true })).toHaveCount(0);
await expect(report.getByText(/结论边界/)).toBeVisible();
await expect(conditionPanel.getByText("TJWater operational task", { exact: true })).toHaveCount(0);
await expect(conditionPanel.getByText("browser-session-1", { exact: true })).toHaveCount(0);
await conditionPanel.getByText("调查处理流程", { exact: true }).scrollIntoViewIfNeeded();
await expect(conditionPanel.getByText("前置工作与条件", { exact: true })).toHaveCount(0);
await conditionPanel.getByText("调查处理流程", { exact: true }).scrollIntoViewIfNeeded();
await expect(page.getByText("已建立水质投诉调查", { exact: true })).toBeHidden({ timeout: 10_000 });
await page.addStyleTag({ content: "[data-sonner-toaster] { display: none !important; }" });
await page.evaluate(() => document.fonts.ready);
await testInfo.attach("water-quality-condition-task-1280", {
body: await conditionPanel.screenshot(),
body: await conditionPanel.screenshot({ path: testInfo.outputPath("execution-plan-desktop.png") }),
contentType: "image/png"
});
await conditionPanel.getByLabel("并行分枝 2 项").scrollIntoViewIfNeeded();
await testInfo.attach("water-quality-condition-task-second-fork-1280", {
body: await conditionPanel.screenshot({ path: testInfo.outputPath("execution-plan-second-fork-desktop.png") }),
contentType: "image/png"
});
for (const width of [375, 320]) {
@@ -572,16 +578,26 @@ test.describe("外围样板管理", () => {
if (await expandResponsiveTask.isVisible()) {
await expandResponsiveTask.click();
}
await responsiveConditionPanel.getByRole("button", { name: /调查任务/ }).click();
await expect(responsiveConditionPanel.getByRole("heading", { name: "水质投诉调查" })).toBeVisible();
const responsiveWorkflowStep = responsiveConditionPanel
.getByText("校验上游水质监测", { exact: true })
.last();
const responsiveWorkflowStep = responsiveConditionPanel.locator('[data-execution-node="upstream"]');
await responsiveWorkflowStep.scrollIntoViewIfNeeded();
await expect(responsiveWorkflowStep).toBeVisible();
await responsiveConditionPanel.getByText("调查处理流程", { exact: true }).scrollIntoViewIfNeeded();
await expect(responsiveConditionPanel.locator('[data-execution-node]')).toHaveCount(13);
await expect(responsiveConditionPanel.getByLabel("并行分枝 4 项")).toBeVisible();
await expect(responsiveConditionPanel.getByLabel("并行分枝 2 项")).toBeVisible();
await expect(responsiveConditionPanel.getByRole("button", { name: "放大流程图" })).toHaveCount(0);
await page.waitForTimeout(350);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await testInfo.attach(`water-quality-condition-task-${width}`, {
body: await page.screenshot(),
body: await page.screenshot({ path: testInfo.outputPath(`execution-plan-${width}.png`) }),
contentType: "image/png"
});
await responsiveConditionPanel.getByLabel("并行分枝 2 项").scrollIntoViewIfNeeded();
await page.evaluate(() => document.fonts.ready);
await testInfo.attach(`water-quality-condition-task-second-fork-${width}`, {
body: await page.screenshot({ path: testInfo.outputPath(`execution-plan-second-fork-${width}.png`) }),
contentType: "image/png"
});
}
@@ -590,9 +606,11 @@ test.describe("外围样板管理", () => {
if (!await restoredConditionPanel.isVisible()) {
await page.getByRole("button", { name: "工况任务", exact: true }).click();
}
const agentCollaborationButton = restoredConditionPanel.getByRole("button", { name: "与 Agent 协同" });
const agentCollaborationButton = restoredConditionPanel.getByRole("button", { name: "Agent 协作" });
if (!await agentCollaborationButton.isVisible()) {
await restoredConditionPanel.getByRole("button", { name: "展开工况任务" }).click();
const expandRestoredTask = restoredConditionPanel.getByRole("button", { name: "展开工况任务" });
if (await expandRestoredTask.isVisible()) await expandRestoredTask.click();
await restoredConditionPanel.getByRole("button", { name: /调查任务/ }).click();
}
await agentCollaborationButton.click();
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
@@ -601,13 +619,21 @@ test.describe("外围样板管理", () => {
scenario_code: "WQ-SECONDARY-SUPPLY-001",
status: "open"
});
const triggerRequests = agentRequests.filter((body) => "trigger_context" in body);
expect(triggerRequests).toHaveLength(2);
expect(new Set(triggerRequests.map((body) =>
(body.trigger_context as { trigger_id: string }).trigger_id
))).toEqual(new Set([
`peripheral.work_order.created:${projectId}:${workOrderId}:1`
]));
expect(agentRequests.filter((body) => "trigger_context" in body)).toHaveLength(0);
await page.route("**/api/v1/agent/chat/sessions", (route) => route.fulfill({ json: { sessions: [{
id: "browser-session-1", title: "水质投诉调查", created_at: "2026-09-23T01:00:00Z", updated_at: "2026-09-23T01:00:00Z",
trigger: { subject_id: workOrderId, workflow: "water_quality_complaint", occurred_at: "2026-09-23T01:00:00Z" }
}] } }));
await page.reload();
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const recovered = page.getByRole("region", { name: "工况任务", exact: true });
await recovered.getByRole("button", { name: "展开工况任务" }).click();
await recovered.getByRole("button", { name: /调查任务/ }).click();
const taskRow = recovered.getByRole("button", { name: /水质投诉调查/ }).first();
await taskRow.click();
await expect(recovered.getByLabel("并行分枝 4 项")).toBeVisible();
await expect(recovered.getByLabel("并行分枝 2 项")).toBeVisible();
expect(agentRequests.filter((body) => "trigger_context" in body)).toHaveLength(0);
});
test("在 375px 视口内完整呈现面板", async ({ page }) => {
@@ -616,7 +642,7 @@ test.describe("外围样板管理", () => {
const launcher = page.getByRole("button", { name: "外围样板管理" });
await launcher.focus();
await page.keyboard.press("Enter");
const panel = page.getByRole("complementary", { name: "外围样板管理" });
const panel = page.getByRole("complementary", { name: "外围工单测试" });
await expect(panel).toBeVisible();
const box = await panel.boundingBox();
expect(box).not.toBeNull();
@@ -701,6 +727,10 @@ function createSourceWorkOrders(projectId: string) {
priority,
assignee: null,
scheduled_at: "2026-09-17T08:00:00Z",
work_order_type: "general",
location: null,
scenario_code: null,
subtasks: null,
origin: "synthetic",
created_at: "2026-09-17T00:00:00Z",
updated_at: "2026-09-17T00:00:00Z",
@@ -1,12 +1,13 @@
import { expect, test } from "@playwright/test";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { TEST_PROJECT_ID } from "./support/mock-scada-api";
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",
project_id: TEST_PROJECT_ID,
name: "北区供水项目",
code: "north-supply",
gs_workspace: "tjwater_next",
@@ -22,7 +23,7 @@ test("uses backend project identity, role and map extent with the existing fallb
username: "north.operator",
system_role: "user",
is_system_admin: false,
project_id: "project-1",
project_id: TEST_PROJECT_ID,
project_role: "member",
permissions: ["webgis.view"]
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
import { mockScadaApi } from "./support/mock-scada-api";
import { mockScadaApi, TEST_PROJECT_ID } 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 });
@@ -35,7 +35,7 @@ test("loads SCADA from the backend API and aligns map features by device_id", as
await page.goto("/", { waitUntil: "domcontentloaded" });
const deviceRequest = await deviceRequestPromise;
expect(deviceRequest.headers()["x-project-id"]).toBe("project-1");
expect(deviceRequest.headers()["x-project-id"]).toBe(TEST_PROJECT_ID);
await expect.poll(async () => page.evaluate(() => {
const map = globalThis.__waterNetworkMap;
if (!map) return [];
@@ -131,7 +131,7 @@ test("loads required map layers while optional layer probes are still pending",
await expect.poll(() => page.evaluate(() => ({
pipes: Boolean(globalThis.__waterNetworkMap?.getLayer("pipes-casing")),
pumps: Boolean(globalThis.__waterNetworkMap?.getLayer("pumps-symbol"))
}))).toEqual({ pipes: true, pumps: false });
})), { timeout: 15_000 }).toEqual({ pipes: true, pumps: false });
} finally {
releaseProbe();
}
@@ -180,10 +180,6 @@ test("keeps execution spinners without breathing badges after selecting timeline
.filter({ hasText: "SCADA 数据诊断" })
.filter({ hasText: "执行中" })
.first();
const executingWorkOrder = timeline
.locator('button[aria-pressed]')
.filter({ hasText: "演示工单:北辰分区阀门开度调整" });
await expectRunningStatusAnimations(runningCondition);
await runningCondition.click();
await expectRunningStatusAnimations(runningCondition);
@@ -194,6 +190,11 @@ test("keeps execution spinners without breathing badges after selecting timeline
.locator(".status-badge .status-dot")
).toHaveCount(0);
await panel.getByRole("button", { name: /预约任务/ }).click();
const executingWorkOrder = timeline
.locator('button[aria-pressed]')
.filter({ hasText: "演示工单:北辰分区阀门开度调整" })
.last();
await expectRunningStatusAnimations(executingWorkOrder);
await expect(executingWorkOrder.getByText("执行中", { exact: true })).toBeVisible();
await executingWorkOrder.click();
@@ -214,10 +215,12 @@ test("shows completed field work as a static waiting-for-reply state", async ({
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click();
await panel.getByRole("button", { name: /预约任务/ }).click();
const awaitingReplyWorkOrder = panel
.getByTestId("scheduled-condition-timeline")
.locator('button[aria-pressed]')
.filter({ hasText: "演示工单:泵站边界复核复令" });
.filter({ hasText: "演示工单:泵站边界复核复令" })
.last();
await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible();
await expect(awaitingReplyWorkOrder.locator(".status-icon")).toHaveAttribute(
+45 -4
View File
@@ -11,6 +11,7 @@ type MockAgentApiOptions = {
};
export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}) {
const corsHeaders = { "Access-Control-Allow-Origin": "*" };
const model = options.model ?? {
id: "test/model",
label: "测试模型",
@@ -19,10 +20,41 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
};
const triggeredSessions = new Map<string, { sessionId: string; triggerId: string }>();
await page.route("**/api/v1/agent/chat/**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: {
"Access-Control-Allow-Headers": "Authorization, Content-Type, X-Project-Id",
"Access-Control-Allow-Methods": "DELETE, GET, PATCH, POST, OPTIONS",
"Access-Control-Allow-Origin": "*"
}
});
return;
}
const pathname = new URL(route.request().url()).pathname;
const collaborationMatch = pathname.match(/\/task\/([^/]+)\/collaboration\/session$/);
if (collaborationMatch && route.request().method() === "POST") {
await route.fulfill({
status: 201,
headers: corsHeaders,
json: {
id: `collaboration-${collaborationMatch[1]}`,
session_id: `collaboration-${collaborationMatch[1]}`,
title: "任务协作问答",
purpose: "task_collaboration",
task_id: collaborationMatch[1],
status: "active",
created_at: "2026-09-23T01:11:00Z",
updated_at: "2026-09-23T01:11:00Z"
}
});
return;
}
if (pathname.endsWith("/session") && route.request().method() === "POST") {
const body = route.request().postDataJSON() as {
const body = (route.request().postDataJSON() ?? {}) as {
trigger_context?: { trigger_id?: string };
};
const triggerId = body.trigger_context?.trigger_id;
@@ -31,6 +63,7 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
if (triggerId && !existing) triggeredSessions.set(triggerId, { sessionId, triggerId });
await route.fulfill({
status: existing ? 200 : 201,
headers: corsHeaders,
json: {
session_id: sessionId,
title: "水质投诉事件调查",
@@ -47,11 +80,15 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
const sessionMatch = pathname.match(/\/session\/([^/]+)$/);
if (sessionMatch && route.request().method() === "GET") {
const collaboration = sessionMatch[1].startsWith("collaboration-");
await route.fulfill({
headers: corsHeaders,
json: {
id: sessionMatch[1],
session_id: sessionMatch[1],
title: "水质投诉事件调查",
title: collaboration ? "任务协作问答" : "水质投诉事件调查",
purpose: collaboration ? "task_collaboration" : "ordinary_chat",
task_id: collaboration ? sessionMatch[1].slice("collaboration-".length) : undefined,
status: "active",
created_at: "2026-09-22T02:00:00Z",
updated_at: "2026-09-22T02:00:00Z",
@@ -66,6 +103,7 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
await route.fulfill({
status: 200,
headers: {
...corsHeaders,
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream",
"X-Vercel-AI-UI-Message-Stream": "v1"
@@ -84,12 +122,13 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
}
if (pathname.endsWith("/sessions")) {
await route.fulfill({ json: { sessions: options.sessions ?? [] } });
await route.fulfill({ headers: corsHeaders, json: { sessions: options.sessions ?? [] } });
return;
}
if (pathname.endsWith("/models")) {
await route.fulfill({
headers: corsHeaders,
json: {
default_model: model.id,
models: [model]
@@ -100,6 +139,7 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
if (pathname.endsWith("/ui-registry")) {
await route.fulfill({
headers: corsHeaders,
json: {
schema_version: "agent-ui-registry@1",
chart_grammars: [],
@@ -112,6 +152,7 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
if (pathname.endsWith("/frontend-action-registry")) {
await route.fulfill({
headers: corsHeaders,
json: {
schema_version: "frontend-action-registry@1",
actions: []
@@ -120,6 +161,6 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
return;
}
await route.fulfill({ json: {} });
await route.fulfill({ headers: corsHeaders, json: {} });
});
}
+54 -2
View File
@@ -16,6 +16,8 @@ const emptyRuntimeSnapshot = {
work_orders: []
};
export const TEST_PROJECT_ID = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
export async function mockScadaApi(
page: Page,
devices: unknown[] = [],
@@ -25,13 +27,27 @@ export async function mockScadaApi(
analysisRuns: Record<string, unknown> = {},
analysisHistory: unknown[] = []
) {
const corsHeaders = { "Access-Control-Allow-Origin": "*" };
await page.route("https://tjwater-api.invalid/**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: {
...corsHeaders,
"Access-Control-Allow-Headers": "Authorization, Content-Type, Last-Event-ID, X-Project-Id",
"Access-Control-Allow-Methods": "DELETE, GET, PATCH, POST, OPTIONS"
}
});
return;
}
const url = new URL(route.request().url());
if (url.pathname === "/api/v1/projects") {
await route.fulfill({
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify({
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
items: [{ project_id: TEST_PROJECT_ID, gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
@@ -42,6 +58,7 @@ export async function mockScadaApi(
if (url.pathname === "/api/v1/scada-devices") {
await route.fulfill({
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify({ items: devices, total: devices.length, limit: 1000, offset: 0 })
});
return;
@@ -49,6 +66,7 @@ export async function mockScadaApi(
if (url.pathname === "/api/v1/projects/current/metadata" && projectPresentation) {
await route.fulfill({
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify(projectPresentation.metadata)
});
return;
@@ -56,6 +74,7 @@ export async function mockScadaApi(
if (url.pathname === "/api/v1/access-context" && projectPresentation) {
await route.fulfill({
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify(projectPresentation.accessContext)
});
return;
@@ -63,6 +82,7 @@ export async function mockScadaApi(
if (url.pathname === "/api/v1/timeseries/scada-readings") {
await route.fulfill({
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify(readings)
});
return;
@@ -70,16 +90,41 @@ export async function mockScadaApi(
if (url.pathname === "/api/v1/workbench/runtime") {
await route.fulfill({
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify(runtimeSnapshot)
});
return;
}
if (url.pathname === "/api/v1/workbench/sync-snapshot") {
await route.fulfill({
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify({
schema: "workbench-sync-snapshot@1",
event_cursor: 0,
runtime: runtimeSnapshot,
tasks: [],
notifications: [],
generated_at: "2026-09-17T02:30:00Z"
})
});
return;
}
if (url.pathname === "/api/v1/workbench/events") {
await route.fulfill({
contentType: "text/event-stream",
headers: { ...corsHeaders, "Cache-Control": "no-cache" },
body: ": test keepalive\n\n"
});
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",
headers: corsHeaders,
body: JSON.stringify({
items,
limit,
@@ -96,17 +141,24 @@ export async function mockScadaApi(
if (analysisRun) {
await route.fulfill({
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify(analysisRun)
});
} else {
await route.fulfill({
status: 404,
contentType: "application/json",
headers: corsHeaders,
body: JSON.stringify({ detail: "Analysis run not found" })
});
}
return;
}
await route.fulfill({ status: 404, contentType: "application/json", body: "{}" });
await route.fulfill({
status: 404,
contentType: "application/json",
headers: corsHeaders,
body: "{}"
});
});
}
@@ -79,9 +79,9 @@ test("history mode opens on the latest day with backend work orders", async ({ p
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 panel.getByRole("button", { name: /预约任务/ }).click();
await expect(panel).toContainText("历史工单:阀门复核");
await expect(panel).not.toContainText("历史 SCADA 诊断计划");
});
@@ -132,7 +132,6 @@ test("keeps mock conditions while retaining explicit backend alerts", async ({ p
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("演示 合成低压调查");
@@ -298,7 +297,6 @@ test("opens the latest persisted analysis as the active visualization", async ({
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 数据诊断");
@@ -475,10 +473,10 @@ test("keeps mock conditions while using backend work orders and alerts", async (
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 panel.getByRole("button", { name: /预约任务/ }).click();
await expect(panel).toContainText("后端工单:北区阀门复核");
await expect(panel).not.toContainText("工单演示回退");
await panel.getByRole("button", { name: /后端工单:北区阀门复核/ }).click();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 98 KiB