fix(workbench): refresh work orders after projection events
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,12 @@ const RUNTIME_TOPICS = new Set<WorkbenchEvent["topic"]>([
|
||||
"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) => {
|
||||
|
||||
@@ -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<void>((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);
|
||||
});
|
||||
Reference in New Issue
Block a user