diff --git a/src/features/workbench/hooks/use-workbench-sync.test.ts b/src/features/workbench/hooks/use-workbench-sync.test.ts index 2770719..03e8498 100644 --- a/src/features/workbench/hooks/use-workbench-sync.test.ts +++ b/src/features/workbench/hooks/use-workbench-sync.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { shouldApplyWorkbenchSnapshot } from "./use-workbench-sync"; +import type { WorkbenchEvent } from "../api/workbench-sync-client"; +import { shouldApplyWorkbenchSnapshot, shouldRefreshWorkbenchRuntime } from "./use-workbench-sync"; describe("workbench snapshot ordering", () => { it("rejects an older concurrent response even when its cursor is valid", () => { @@ -29,3 +30,24 @@ describe("workbench snapshot ordering", () => { })).toBe(true); }); }); + +describe("work-order projection readiness", () => { + const event: WorkbenchEvent = { + schema: "workbench-event@1", + event_seq: 1, + event_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e", + project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e", + topic: "workbench.work_order.changed", + aggregate_type: "peripheral_work_order", + aggregate_id: "source-order", + aggregate_revision: 1, + occurred_at: "2026-10-08T00:00:00Z", + metadata: { source: "peripheral_sample" } + }; + + it("waits for authoritative projection events instead of fetching a premature snapshot", () => { + expect(shouldRefreshWorkbenchRuntime(event)).toBe(false); + expect(shouldRefreshWorkbenchRuntime({ ...event, aggregate_type: "operational_action" })).toBe(true); + expect(shouldRefreshWorkbenchRuntime({ ...event, topic: "workbench.alert.changed", aggregate_type: "workbench_alert" })).toBe(true); + }); +}); diff --git a/src/features/workbench/hooks/use-workbench-sync.ts b/src/features/workbench/hooks/use-workbench-sync.ts index d8631d6..a918710 100644 --- a/src/features/workbench/hooks/use-workbench-sync.ts +++ b/src/features/workbench/hooks/use-workbench-sync.ts @@ -14,6 +14,12 @@ const RUNTIME_TOPICS = new Set([ "analysis.run.changed" ]); +export function shouldRefreshWorkbenchRuntime(event: WorkbenchEvent) { + // Source changes wake the backend synchronizer. Its committed task events + // refresh the UI after the authoritative projection is ready. + return RUNTIME_TOPICS.has(event.topic) && event.aggregate_type !== "peripheral_work_order"; +} + export type WorkbenchSyncState = { snapshot: WorkbenchSyncSnapshot | null; events: WorkbenchEvent[]; @@ -99,7 +105,7 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc if (unseen.length === 0) return; cursorRef.current = unseen.at(-1)!.event_seq; setEvents((current) => [...current, ...unseen]); - if (unseen.some((event) => RUNTIME_TOPICS.has(event.topic))) { + if (unseen.some(shouldRefreshWorkbenchRuntime)) { if (runtimeRefreshRef.current !== undefined) window.clearTimeout(runtimeRefreshRef.current); runtimeRefreshRef.current = window.setTimeout(() => { void loadSnapshot().catch((nextError) => { diff --git a/tests/browser/peripheral-work-order-push.e2e.ts b/tests/browser/peripheral-work-order-push.e2e.ts new file mode 100644 index 0000000..bae07d6 --- /dev/null +++ b/tests/browser/peripheral-work-order-push.e2e.ts @@ -0,0 +1,91 @@ +import { expect, test } from "@playwright/test"; +import { mockWorkbenchContext } from "./support/mock-workbench-context"; +import { TEST_PROJECT_ID } from "./support/mock-scada-api"; + +test("源工单推送等待投影完成后自动进入预约任务,无需刷新快照", async ({ page }) => { + await mockWorkbenchContext(page); + const base = "https://tjwater-api.invalid/api/v1"; + const taskId = "72000000-0000-4000-8000-000000000041"; + const orderId = "73000000-0000-4000-8000-000000000041"; + const now = new Date().toISOString(); + const title = "主动推送工单:现场阀门复核"; + const 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: now, + conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] + }; + const task = { + project_id: TEST_PROJECT_ID, task_id: taskId, task_type: "external_work_order", + workflow: "external-work-order", lifecycle_status: "open", active_run: null, + status: "queued", priority: "normal", revision: 2, execution_generation: 1, + execution_request_id: "74000000-0000-4000-8000-000000000041", title, + summary: "收到新工单后自动同步,无需手动刷新", + source: { kind: "external_system", external_id: `peripheral_sample:${orderId}`, external_revision: 1, event_kind: "general" }, + trigger: { kind: "external_sync", occurred_at: now, detail: "来源工单事件" }, + scheduled_for: now, agent_session_id: null, agent_lease_expires_at: null, + current_plan_revision: null, current_report_revision: null, created_at: now, updated_at: now, + latest_plan: null, latest_report: null, evidence: [], 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 + }, + work_order_snapshot: { + work_order_id: orderId, revision: 1, title, summary: "现场阀门复核", status: "open", + priority: "normal", assignee: "现场班组", scheduled_at: now, work_order_type: "general", + location: "测试阀门", scenario_code: null, feedback_requirements: [], activity: [], + origin: "synthetic", updated_at: now + } + }; + let snapshotRequests = 0; + let detailRequests = 0; + let releaseProjection!: () => void; + const projectionReady = new Promise((resolve) => { releaseProjection = resolve; }); + const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*" }; + await page.route(`${base}/workbench/sync-snapshot`, (route) => { + snapshotRequests += 1; + return route.fulfill({ headers, json: { + schema: "workbench-sync-snapshot@1", event_cursor: 0, runtime, + tasks: [], notifications: [], generated_at: now + } }); + }); + await page.route(`${base}/operational-tasks/${taskId}`, (route) => { + detailRequests += 1; + return route.fulfill({ headers, json: task }); + }); + await page.route(`${base}/workbench/events**`, async (route) => { + const after = new URL(route.request().url()).searchParams.get("after"); + if (after !== "0" && after !== "1") { + return route.fulfill({ headers, contentType: "text/event-stream", body: ": keepalive\n\n" }); + } + if (after === "1") await projectionReady; + const sequence = after === "0" ? 1 : 2; + const event = { + schema: "workbench-event@1", event_seq: sequence, + event_id: `75000000-0000-4000-8000-00000000000${sequence}`, project_id: TEST_PROJECT_ID, + topic: sequence === 1 ? "workbench.work_order.changed" : "operational.task.changed", + aggregate_type: sequence === 1 ? "peripheral_work_order" : "operational_task", + aggregate_id: sequence === 1 ? orderId : taskId, aggregate_revision: sequence, + occurred_at: now, + metadata: sequence === 1 ? { source: "peripheral_sample", work_order_id: orderId } : { task_id: taskId } + }; + return route.fulfill({ headers, contentType: "text/event-stream", body: `id: ${sequence}\nevent: workbench_event\ndata: ${JSON.stringify(event)}\n\n` }); + }); + const waitingForProjection = page.waitForRequest((request) => + request.url().includes("/workbench/events?after=1") + ); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByRole("button", { name: "工况任务", exact: true }).click(); + const panel = page.getByRole("region", { name: "工况任务", exact: true }); + await panel.getByRole("button", { name: "展开工况任务" }).click(); + await panel.getByRole("button", { name: /^预约任务/ }).click(); + await waitingForProjection; + expect(snapshotRequests).toBe(1); + await expect(panel).not.toContainText(title); + releaseProjection(); + await expect(panel).toContainText(title); + expect(detailRequests).toBe(1); + expect(snapshotRequests).toBe(1); +});