Files
ttstd-infinite-canvas/web/test/agent-canvas-sync.test.ts
2026-09-14 01:11:56 +08:00

74 lines
4.2 KiB
TypeScript

import { expect, mock, test } from "bun:test";
import { createAgentCanvasSync } from "@/services/agent-canvas-sync";
import type { AgentEvent } from "@/services/api/agent";
const sleep = (ms = 15) => new Promise((resolve) => setTimeout(resolve, ms));
const event = (type: string, payload: Record<string, unknown> = {}): AgentEvent => ({ eventId: type, runId: "run", seq: 1, type, payload, createdAt: "2026-09-13" });
const delta = event("canvas_updated", { canvasId: "canvas", canvasPatch: { canvasId: "canvas", nodes: [], connections: [], updatedAt: "2026-09-13" } });
test("50 concurrent node completions are one local batch with zero full-canvas GETs", async () => {
const applyPatches = mock(async () => {}), refresh = mock(async () => {}), onError = mock(() => {});
const sync = createAgentCanvasSync({ canvasId: "canvas", applyPatches, refresh, onError, batchMs: 0 });
for (let i = 0; i < 50; i++) { sync.receive(delta); sync.receive(event("tool_failed", { toolName: "generate_media" })); sync.receive(event("run_failed")); }
await sleep(); sync.dispose();
expect(applyPatches).toHaveBeenCalledTimes(1);
expect((applyPatches.mock.calls[0] as unknown as [unknown[]])[0]).toHaveLength(50);
expect(refresh).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
});
test("legacy success/failure bursts coalesce into one snapshot", async () => {
const refresh = mock(async () => {});
const sync = createAgentCanvasSync({ canvasId: "canvas", applyPatches: async () => {}, refresh, onError: () => {}, batchMs: 0 });
for (let i = 0; i < 50; i++) { sync.receive(event("tool_failed")); sync.receive(event("run_failed")); sync.receive(event("tool_completed", { toolName: "generate_media" })); }
await sleep(); sync.dispose();
expect(refresh).toHaveBeenCalledTimes(1);
});
test("in-flight refreshes never overlap; later invalidations produce one trailing refresh", async () => {
let release!: () => void;
let active = 0, peak = 0;
const refresh = mock(async () => { peak = Math.max(peak, ++active); await new Promise<void>((resolve) => { release = resolve; }); active--; });
const sync = createAgentCanvasSync({ canvasId: "canvas", applyPatches: async () => {}, refresh, onError: () => {}, batchMs: 0, refreshIntervalMs: 0 });
sync.reconcile(); await sleep();
for (let i = 0; i < 50; i++) sync.reconcile();
expect(refresh).toHaveBeenCalledTimes(1);
release(); await sleep();
expect(refresh).toHaveBeenCalledTimes(2);
release(); await sleep(); sync.dispose();
expect(peak).toBe(1);
});
test("conflicting/out-of-order patches reconcile once and surface unresolved local conflicts", async () => {
const error = new Error("本地存在未同步编辑");
const refresh = mock(async () => { throw error; }), onError = mock(() => {});
const sync = createAgentCanvasSync({ canvasId: "canvas", applyPatches: async () => { throw new Error("delta gap"); }, refresh, onError, batchMs: 0 });
sync.receive(delta); await sleep(); sync.dispose();
expect(refresh).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(error);
});
test("cleanup and other-canvas events cannot update the new canvas", async () => {
const applyPatches = mock(async () => {}), refresh = mock(async () => {});
const sync = createAgentCanvasSync({ canvasId: "canvas", applyPatches, refresh, onError: () => {}, batchMs: 0 });
sync.receive(event("canvas_updated", { ...delta.payload, canvasId: "other" }));
sync.receive(delta); sync.dispose();
await sleep();
expect(applyPatches).not.toHaveBeenCalled(); expect(refresh).not.toHaveBeenCalled();
});
test("fallback snapshots obey the configured minimum refresh interval", async () => {
const times: number[] = [];
const sync = createAgentCanvasSync({ canvasId: "canvas", applyPatches: async () => {}, refresh: async () => { times.push(Date.now()); }, onError: () => {}, batchMs: 0, refreshIntervalMs: 100 });
sync.reconcile();
await sleep(15);
for (let index = 0; index < 50; index++) sync.reconcile();
await sleep(15);
expect(times).toHaveLength(1);
await sleep(120);
sync.dispose();
expect(times).toHaveLength(2);
expect(times[1] - times[0]).toBeGreaterThanOrEqual(100);
});