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[] = []; 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[] = []; let testData: Record[] = []; 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" })); }