feat(workbench): integrate runtime data and map tooling
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
export const MOCK_SPATIAL_ANALYSIS = {
|
||||
run_id: "analysis-layout-spatial",
|
||||
name: "北区爆管定位",
|
||||
run_type: "burst_location",
|
||||
run_type_label: "爆管定位",
|
||||
status: "completed",
|
||||
status_label: "已完成",
|
||||
created_by: "layout-test",
|
||||
created_at: "2026-09-18T02:20:00Z",
|
||||
started_at: "2026-09-18T02:10:00Z",
|
||||
results: [{
|
||||
result_type: "burst_location",
|
||||
created_at: "2026-09-18T02:20:00Z",
|
||||
facts: [{ label: "关联管段", value: "P-42" }]
|
||||
}],
|
||||
map_payload: {
|
||||
type: "FeatureCollection",
|
||||
features: [{
|
||||
type: "Feature",
|
||||
properties: { label: "后端爆管定位" },
|
||||
geometry: { type: "Point", coordinates: [121.5, 30.9] }
|
||||
}]
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const MOCK_NON_SPATIAL_ANALYSIS = {
|
||||
...MOCK_SPATIAL_ANALYSIS,
|
||||
run_id: "analysis-layout-table",
|
||||
name: "供水服务分区平衡分析",
|
||||
run_type: "pressure_analysis",
|
||||
run_type_label: "压力分析",
|
||||
results: [{
|
||||
result_type: "pressure_analysis",
|
||||
created_at: "2026-09-18T02:20:00Z",
|
||||
facts: [{ label: "结果状态", value: "已完成" }]
|
||||
}],
|
||||
map_payload: null
|
||||
} as const;
|
||||
|
||||
export function createAnalysisRuntimeSnapshot(latestAnalysis: unknown) {
|
||||
return {
|
||||
availability: "ready",
|
||||
source: "analysis_runs",
|
||||
scenario_availability: "ready",
|
||||
artifact_availability: "ready",
|
||||
schedule_availability: "not_configured",
|
||||
work_order_availability: "not_configured",
|
||||
alert_availability: "not_configured",
|
||||
generated_at: "2026-09-18T02:30:00Z",
|
||||
conditions: [],
|
||||
alerts: [],
|
||||
scenarios: [],
|
||||
latest_analysis: latestAnalysis,
|
||||
work_orders: []
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { mockWorkbenchContext } from "./mock-workbench-context";
|
||||
|
||||
type WorkOrderPhase = "execution" | "reply";
|
||||
|
||||
export async function mockHierarchicalWorkOrder(page: Page, phase: WorkOrderPhase) {
|
||||
const parentTitle = phase === "execution"
|
||||
? "演示工单:北辰分区阀门开度调整"
|
||||
: "演示工单:泵站边界复核复令";
|
||||
const parentStatus = phase === "execution" ? "running" : "awaiting_reply";
|
||||
const subtaskTitles = phase === "execution"
|
||||
? ["上传阀门操作前后照片", "记录最终开度", "复核北辰末端压力不低于 0.22MPa"]
|
||||
: ["上传泵站运行截图", "填写出水压力复核结果", "确认模型边界已同步更新"];
|
||||
const activeIndex = phase === "execution" ? 1 : 2;
|
||||
const scheduledAt = shanghaiTimestamp(10, 30);
|
||||
const workOrderId = `mock-${phase}-work-order`;
|
||||
|
||||
await mockWorkbenchContext(page, {
|
||||
availability: "not_configured",
|
||||
source: "none",
|
||||
scenario_availability: "not_configured",
|
||||
artifact_availability: "not_configured",
|
||||
schedule_availability: "not_configured",
|
||||
work_order_availability: "ready",
|
||||
alert_availability: "not_configured",
|
||||
generated_at: scheduledAt,
|
||||
conditions: [],
|
||||
alerts: [],
|
||||
scenarios: [],
|
||||
latest_analysis: null,
|
||||
work_orders: [{
|
||||
id: workOrderId,
|
||||
code: phase === "execution" ? "DEMO-EXEC-001" : "DEMO-REPLY-001",
|
||||
scheduled_at: scheduledAt,
|
||||
title: parentTitle,
|
||||
summary: phase === "execution"
|
||||
? "现场调整边界阀门并逐项复核。"
|
||||
: "完成泵站边界复核并等待调度复令。",
|
||||
status: parentStatus,
|
||||
risk_level: phase === "execution" ? "attention" : "normal",
|
||||
updated_at: scheduledAt,
|
||||
detail: "浏览器测试使用的后端工单投影。",
|
||||
recommendation: "按子任务逐项完成并回传。",
|
||||
duration_minutes: 45,
|
||||
source: "生产工单系统",
|
||||
dispatcher: "调度中心",
|
||||
priority: phase === "execution" ? "urgent" : "normal",
|
||||
subtasks: subtaskTitles.map((title, index) => {
|
||||
const position = index < activeIndex ? "before" : index > activeIndex ? "after" : "active";
|
||||
const stageStatuses = phase === "execution" && position === "active"
|
||||
? ["done", "current", "pending"]
|
||||
: phase === "reply" && position === "active"
|
||||
? ["done", "done", "current"]
|
||||
: position === "before"
|
||||
? ["done", "done", "done"]
|
||||
: ["pending", "pending", "pending"];
|
||||
|
||||
return {
|
||||
id: `${workOrderId}-subtask-${index + 1}`,
|
||||
title,
|
||||
summary: `完成“${title}”并保留阶段记录。`,
|
||||
status: position === "before" ? "completed" : position === "active" ? "running" : "pending",
|
||||
scheduled_at: shanghaiTimestamp(10, 30 + index * 15),
|
||||
location: phase === "execution" ? "北辰分区边界阀门 BV-07" : "北辰二级泵站 P-02",
|
||||
assignee: phase === "execution" ? "北辰抢修二组" : "泵站运行班组",
|
||||
duration_minutes: 15,
|
||||
reply_window_minutes: 20,
|
||||
stages: [
|
||||
{ id: "dispatch", sequence: 1, title: "派发", status: stageStatuses[0], owner: "调度中心", time_label: "10:20", description: "确认范围并派发。" },
|
||||
{ id: "execution", sequence: 2, title: "执行中", status: stageStatuses[1], owner: "现场班组", time_label: "10:30-10:45", description: "现场执行本项任务。" },
|
||||
{ id: "reply", sequence: 3, title: "待复令", status: stageStatuses[2], owner: "现场班组", time_label: "10:45-11:05", description: "回传结果并等待复令。" }
|
||||
],
|
||||
reply_requirements: [`回传“${title}”的执行结果`]
|
||||
};
|
||||
})
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
function shanghaiTimestamp(hour: number, minute: number) {
|
||||
const target = new Date();
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit"
|
||||
}).formatToParts(target);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
parts.find((part) => part.type === type)?.value ?? "";
|
||||
const normalizedHour = hour + Math.floor(minute / 60);
|
||||
return `${get("year")}-${get("month")}-${get("day")}T${String(normalizedHour).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}:00+08:00`;
|
||||
}
|
||||
@@ -1,6 +1,30 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
export async function mockScadaApi(page: Page, devices: unknown[] = []) {
|
||||
const emptyRuntimeSnapshot = {
|
||||
availability: "not_configured",
|
||||
source: "none",
|
||||
scenario_availability: "not_configured",
|
||||
artifact_availability: "not_configured",
|
||||
schedule_availability: "not_configured",
|
||||
work_order_availability: "not_configured",
|
||||
alert_availability: "not_configured",
|
||||
generated_at: "2026-09-17T02:30:00Z",
|
||||
conditions: [],
|
||||
alerts: [],
|
||||
scenarios: [],
|
||||
latest_analysis: null,
|
||||
work_orders: []
|
||||
};
|
||||
|
||||
export async function mockScadaApi(
|
||||
page: Page,
|
||||
devices: unknown[] = [],
|
||||
runtimeSnapshot: unknown = emptyRuntimeSnapshot,
|
||||
readings: unknown[] = [],
|
||||
projectPresentation?: { metadata: unknown; accessContext: unknown },
|
||||
analysisRuns: Record<string, unknown> = {},
|
||||
analysisHistory: unknown[] = []
|
||||
) {
|
||||
await page.route("https://tjwater-api.invalid/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname === "/api/v1/projects") {
|
||||
@@ -22,6 +46,67 @@ export async function mockScadaApi(page: Page, devices: unknown[] = []) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/v1/projects/current/metadata" && projectPresentation) {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(projectPresentation.metadata)
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/v1/access-context" && projectPresentation) {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(projectPresentation.accessContext)
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/v1/timeseries/scada-readings") {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(readings)
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/v1/workbench/runtime") {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(runtimeSnapshot)
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/v1/workbench/analysis-runs") {
|
||||
const limit = Number(url.searchParams.get("limit") ?? 10);
|
||||
const offset = Number(url.searchParams.get("offset") ?? 0);
|
||||
const items = analysisHistory.slice(offset, offset + limit);
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items,
|
||||
limit,
|
||||
offset,
|
||||
next_offset: offset + limit < analysisHistory.length ? offset + limit : null
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
const analysisRunMatch = url.pathname.match(/^\/api\/v1\/workbench\/analysis-runs\/([^/]+)$/);
|
||||
if (analysisRunMatch) {
|
||||
const runId = decodeURIComponent(analysisRunMatch[1]);
|
||||
const analysisRun = analysisRuns[runId];
|
||||
if (analysisRun) {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(analysisRun)
|
||||
});
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "Analysis run not found" })
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 404, contentType: "application/json", body: "{}" });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { mockScadaApi } from "./mock-scada-api";
|
||||
|
||||
// UI-only tests still need the authorized project lookup used by Agent API calls.
|
||||
export async function mockWorkbenchContext(
|
||||
page: Page,
|
||||
runtimeSnapshot?: unknown,
|
||||
projectPresentation?: { metadata: unknown; accessContext: unknown },
|
||||
analysisRuns: Record<string, unknown> = {},
|
||||
analysisHistory: unknown[] = [],
|
||||
options: { runtimeMode?: "auto" | "demo" | "history" | "backend" } = {}
|
||||
) {
|
||||
await mockScadaApi(
|
||||
page,
|
||||
[],
|
||||
runtimeSnapshot,
|
||||
[],
|
||||
projectPresentation,
|
||||
analysisRuns,
|
||||
analysisHistory
|
||||
);
|
||||
await page.route("**/runtime-config.js", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/javascript",
|
||||
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
|
||||
TJWATER_AUTH_MODE: "disabled",
|
||||
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
||||
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||
TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver",
|
||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
||||
TJWATER_WORKBENCH_RUNTIME_MODE: options.runtimeMode ?? "auto",
|
||||
TJWATER_ENABLE_MSW: "false"
|
||||
})};`
|
||||
})
|
||||
);
|
||||
await page.route("https://workbench-map.invalid/**", (route) => route.fulfill({ status: 204 }));
|
||||
}
|
||||
Reference in New Issue
Block a user