Files
ttstd-infinite-canvas/web/test/agent-canvas-refresh.test.ts
ddcat666 cd4bfb9c54 fix(canvas): 预演合并 - 场景时间戳采用服务端快照,带 id 数组按条目合并
每次预演变更都会改 updatedAt,不再把它当成用户编辑冲突;带 id 的对象数组按条目合并,仅刷新场景时保留本地视口。
2026-10-08 10:54:41 +08:00

345 lines
18 KiB
TypeScript

import { afterEach, beforeEach, expect, spyOn, test } from "bun:test";
import localforage from "localforage";
import { canvasContentHash } from "@/lib/canvas/canvas-content";
import { readCanvasSyncDrafts } from "@/services/canvas-sync-drafts";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { http } from "@/services/api/request";
import { applyAgentCanvasPatches, initializeRemoteUserDataSession, refreshCanvasAfterAgent, resetRemoteUserDataSync, subscribeAgentCanvasRefresh, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { CanvasNodeType } from "@/types/canvas";
const initial: CanvasProject = { id: "agent-test", revision: 1, title: "测试", createdAt: "2026-01-01", updatedAt: "2026-01-01", nodes: [], connections: [], chatSessions: [], activeChatId: null, viewport: { x: 0, y: 0, k: 1 } };
const remote: CanvasProject = { ...initial, revision: 2, nodes: [{ id: "text-1", type: CanvasNodeType.Text, title: "剧本", position: { x: 20, y: 20 }, width: 320, height: 240, metadata: { content: "正文" } }] };
let unsubscribe = () => {};
let restore = () => {};
const originalGet = localforage.getItem;
const originalSet = localforage.setItem;
const originalWindow = globalThis.window;
const indexed = new Map<string, unknown>();
beforeEach(() => {
resetRemoteUserDataSync();
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
setTimeout: () => 1,
clearTimeout: () => {},
localStorage: { getItem: () => "agent-user", setItem: () => {} },
},
});
indexed.clear();
localforage.getItem = (async (key: string) => indexed.get(key) ?? null) as typeof localforage.getItem;
localforage.setItem = (async (key: string, value: unknown) => {
indexed.set(key, value);
return value;
}) as typeof localforage.setItem;
});
afterEach(async () => {
unsubscribe();
restore();
resetRemoteUserDataSync();
await flushCanvasStorePersistence();
localforage.getItem = originalGet;
localforage.setItem = originalSet;
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
});
async function setup(project: CanvasProject = remote) {
initial.remoteContentHash = await canvasContentHash(initial);
useCanvasStore.setState({ projects: [initial] });
await initializeRemoteUserDataSession("agent-user");
const mock = spyOn(http, "get").mockResolvedValue({ project });
restore = () => mock.mockRestore();
return mock;
}
test("successful Agent refresh projects nodes into the open editor and store", async () => {
await setup();
let editor = initial;
unsubscribe = subscribeAgentCanvasRefresh((project, previous) => {
expect(previous).toEqual(initial);
editor = project;
});
await refreshCanvasAfterAgent(initial.id);
expect(editor.nodes).toEqual(remote.nodes);
expect(useCanvasStore.getState().projects[0].nodes).toEqual(remote.nodes);
});
test("unsaved editor conflicts preserve store and acknowledged baseline", async () => {
await setup();
unsubscribe = subscribeAgentCanvasRefresh(() => {
throw new Error("本地编辑冲突");
});
await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("本地编辑冲突");
expect(useCanvasStore.getState().projects[0]).toEqual(initial);
unsubscribe();
await refreshCanvasAfterAgent(initial.id);
expect(useCanvasStore.getState().projects[0]).toMatchObject(remote);
});
test("dirty persisted edits are rebased when Agent changes different fields", async () => {
await setup();
useCanvasStore.setState({ projects: [{ ...initial, title: "本地修改" }] });
let notified = false;
unsubscribe = subscribeAgentCanvasRefresh(() => {
notified = true;
});
await expect(refreshCanvasAfterAgent(initial.id)).resolves.toMatchObject({ title: "本地修改", revision: 2, nodes: remote.nodes });
expect(notified).toBe(true);
expect(useCanvasStore.getState().projects[0]).toMatchObject({ title: "本地修改", revision: 2, nodes: remote.nodes });
expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("pending");
});
test("dirty persisted edits still conflict when Agent changes the same field", async () => {
const mock = await setup({ ...remote, title: "云端修改" });
useCanvasStore.setState({ projects: [{ ...initial, title: "本地修改" }] });
mock.mockResolvedValue({ project: { ...remote, title: "云端修改" } });
await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow();
expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("conflict");
expect(useCanvasStore.getState().projects[0].title).toBe("本地修改");
});
test("automatic focus and panning do not block Agent updates or reset the local viewport", async () => {
await setup();
const viewport = { x: -800, y: -400, k: 0.7 };
useCanvasStore.setState({ projects: [{ ...initial, viewport, updatedAt: "2026-09-13" }] });
let editor = initial;
unsubscribe = subscribeAgentCanvasRefresh((project) => {
editor = project;
});
await refreshCanvasAfterAgent(initial.id);
expect(editor.nodes).toEqual(remote.nodes);
expect(editor.viewport).toEqual(viewport);
expect(useCanvasStore.getState().projects[0].viewport).toEqual(viewport);
});
test("refresh-only Previs mutations do not conflict with an editor-only viewport change", async () => {
const scene = {
id: "scene-1",
version: 1 as const,
title: "镜头",
background: "#111111",
environmentIntensity: 0.7,
environment: { mode: "color" as const },
gridVisible: true,
objects: [],
cameras: [],
lights: [],
shots: [],
activeShotId: "shot-1",
createdAt: "2026-09-13T00:00:00Z",
updatedAt: "2026-09-13T00:00:00Z",
};
const local = { ...initial, previsScenes: [scene] };
local.remoteContentHash = await canvasContentHash(local);
const remoteWithPrevis = {
...local,
revision: 2,
previsScenes: [{ ...scene, updatedAt: "2026-09-13T12:00:00Z", objects: [{ id: "actor-1", name: "Agent 演员" }] }],
};
useCanvasStore.setState({ projects: [local] });
await initializeRemoteUserDataSession("agent-user");
const mock = spyOn(http, "get").mockResolvedValue({ project: remoteWithPrevis });
restore = () => mock.mockRestore();
const viewport = { x: -640, y: -280, k: 0.68 };
useCanvasStore.setState({ projects: [{ ...local, viewport, updatedAt: "2026-09-13T12:01:00Z" }] });
await refreshCanvasAfterAgent(local.id);
expect(useCanvasStore.getState().projects[0].viewport).toEqual(viewport);
expect(useCanvasStore.getState().projects[0].previsScenes).toEqual(remoteWithPrevis.previsScenes);
expect(useSyncProgressStore.getState().syncingProjects[local.id].phase).toBe("done");
});
test("replayed completion events do not reapply unchanged canvas nodes", async () => {
await setup();
let deliveries = 0;
unsubscribe = subscribeAgentCanvasRefresh(() => {
deliveries += 1;
});
await refreshCanvasAfterAgent(initial.id);
await refreshCanvasAfterAgent(initial.id);
expect(deliveries).toBe(1);
});
test("media admission and completion refresh the editor with references and result", async () => {
const video = { id: "video-1", type: CanvasNodeType.Video, title: "镜头1", position: { x: 500, y: 20 }, width: 360, height: 640, metadata: { taskId: "task-1", status: "loading" as const, referenceNodeIds: ["image-1"] } };
const project: CanvasProject = { ...remote, nodes: [...remote.nodes, { ...video, id: "image-1", type: CanvasNodeType.Image }, video], connections: [{ id: "edge-1", fromNodeId: "image-1", toNodeId: "video-1" }] };
const mock = await setup(project);
let editor = initial;
unsubscribe = subscribeAgentCanvasRefresh((updated) => {
editor = updated;
});
await refreshCanvasAfterAgent(initial.id);
expect(editor.connections).toEqual(project.connections);
expect(editor.nodes.find((node) => node.id === "video-1")?.metadata?.referenceNodeIds).toEqual(["image-1"]);
const complete: CanvasProject = { ...project, revision: 3, nodes: project.nodes.map((node) => (node.id === "video-1" ? { ...node, metadata: { ...node.metadata, status: "success", storageKey: "resource:video-result" } } : node)) };
mock.mockResolvedValue({ project: complete });
await refreshCanvasAfterAgent(initial.id);
expect(editor.nodes.find((node) => node.id === "video-1")?.metadata?.storageKey).toBe("resource:video-result");
expect(useCanvasStore.getState().projects[0].nodes).toEqual(complete.nodes);
});
test("incremental batches update the editor once without full canvas requests", async () => {
const get = await setup();
let deliveries = 0;
unsubscribe = subscribeAgentCanvasRefresh(() => {
deliveries++;
});
const patch = { canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] };
await applyAgentCanvasPatches(initial.id, [patch, patch]);
expect(get).not.toHaveBeenCalled();
expect(deliveries).toBe(1);
expect(useCanvasStore.getState().projects[0].nodes).toEqual(remote.nodes);
});
test("incremental batches sort and deduplicate revisions before applying", async () => {
await setup();
const first = { canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: [{ before: null, after: { ...remote.nodes[0], id: "agent-1" } }], connections: [] };
const second = { canvasId: initial.id, baseRevision: 2, revision: 3, updatedAt: remote.updatedAt, nodes: [{ before: null, after: { ...remote.nodes[0], id: "agent-2" } }], connections: [] };
await applyAgentCanvasPatches(initial.id, [second, first, first]);
expect(useCanvasStore.getState().projects[0].revision).toBe(3);
expect(useCanvasStore.getState().projects[0].nodes.map((node) => node.id)).toEqual(["agent-1", "agent-2"]);
});
test("already projected deltas still advance the remote baseline", async () => {
await setup();
useCanvasStore.setState({ projects: [{ ...remote, revision: 1 }] });
let deliveries = 0;
unsubscribe = subscribeAgentCanvasRefresh(() => {
deliveries++;
});
await applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] }]);
expect(deliveries).toBe(0);
await expect(refreshCanvasAfterAgent(initial.id)).resolves.toMatchObject(remote);
expect(deliveries).toBe(0);
});
test("editor rejection keeps delta projection atomic and retryable", async () => {
await setup();
const patch = { canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] };
unsubscribe = subscribeAgentCanvasRefresh(() => {
throw new Error("编辑冲突");
});
await expect(applyAgentCanvasPatches(initial.id, [patch])).rejects.toThrow("编辑冲突");
expect(useCanvasStore.getState().projects[0]).toEqual(initial);
unsubscribe();
await applyAgentCanvasPatches(initial.id, [patch]);
expect(useCanvasStore.getState().projects[0].nodes).toEqual(remote.nodes);
});
test("50 distinct media completions project once and keep the local viewport", async () => {
const get = await setup();
const nodes = Array.from({ length: 50 }, (_, index) => ({
id: `video-${index}`,
title: `动画${index}`,
type: CanvasNodeType.Video,
position: { x: index * 400, y: 100 },
width: 320,
height: 180,
metadata: { status: "loading" as const, taskId: `task-${index}`, taskStatus: "running" },
}));
const pending = { ...initial, nodes };
useCanvasStore.setState({ projects: [pending] });
pending.remoteContentHash = await canvasContentHash(pending);
await initializeRemoteUserDataSession("agent-user");
let deliveries = 0;
unsubscribe = subscribeAgentCanvasRefresh(() => {
deliveries++;
});
await applyAgentCanvasPatches(
initial.id,
nodes.map((before, index) => ({
canvasId: initial.id,
baseRevision: index + 1,
revision: index + 2,
updatedAt: initial.updatedAt,
nodes: [{ before, after: { ...before, metadata: { ...before.metadata, status: index % 2 ? ("error" as const) : ("success" as const), taskStatus: index % 2 ? "failed" : "succeeded" } } }],
connections: [],
})),
);
const result = useCanvasStore.getState().projects[0];
expect(result.nodes.filter((node) => node.metadata?.status === "success")).toHaveLength(25);
expect(result.nodes.filter((node) => node.metadata?.status === "error")).toHaveLength(25);
expect(result.viewport).toEqual(initial.viewport);
expect(deliveries).toBe(1);
expect(get).not.toHaveBeenCalled();
});
test("a delta gap reconciles against the cloud before declaring a conflict", async () => {
await setup();
await expect(applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, baseRevision: 2, revision: 3, nodes: [], connections: [] }])).rejects.toThrow("版本不连续");
expect(useCanvasStore.getState().projects[0].revision).toBe(1);
expect(useSyncProgressStore.getState().syncingProjects[initial.id]?.phase).not.toBe("conflict");
expect(await readCanvasSyncDrafts(initial.id)).toHaveLength(0);
await refreshCanvasAfterAgent(initial.id);
expect(useCanvasStore.getState().projects[0]).toMatchObject(remote);
expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("done");
});
test("reconciliation after a delta gap still protects genuinely conflicting edits", async () => {
await setup({ ...remote, title: "云端标题" });
useCanvasStore.setState({ projects: [{ ...initial, title: "未保存标题" }] });
await expect(applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, baseRevision: 2, revision: 3, nodes: [], connections: [] }])).rejects.toThrow("版本不连续");
await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("冲突");
expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("conflict");
expect((await readCanvasSyncDrafts(initial.id))[0].project.title).toBe("未保存标题");
await expect(saveRemoteUserDataNow(initial.id)).rejects.toThrow("云端画布已有更新");
});
test("an identical cloud snapshot clears a stale conflict without replaying editor nodes", async () => {
await setup(initial);
useSyncProgressStore.getState().setProjectProgress(initial.id, { phase: "conflict", draftCount: 9 });
let deliveries = 0;
unsubscribe = subscribeAgentCanvasRefresh(() => {
deliveries++;
});
await refreshCanvasAfterAgent(initial.id);
expect(deliveries).toBe(0);
expect(useSyncProgressStore.getState().syncingProjects[initial.id]).toMatchObject({ phase: "done", draftCount: 9 });
await expect(saveRemoteUserDataNow(initial.id)).resolves.toBeUndefined();
});
test("replayed events against an unchanged cloud ancestor preserve pending local edits", async () => {
await setup(initial);
await refreshCanvasAfterAgent(initial.id);
const edited = { ...initial, title: "等待保存的标题" };
useCanvasStore.setState({ projects: [edited] });
await refreshCanvasAfterAgent(initial.id);
expect(useCanvasStore.getState().projects[0]).toBe(edited);
expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("pending");
expect(await readCanvasSyncDrafts(initial.id)).toHaveLength(0);
});
test("an unverified dirty cache cannot become a save baseline merely because revisions match", async () => {
await setup(initial);
const edited = { ...initial, title: "离线标题" };
useCanvasStore.setState({ projects: [edited] });
await initializeRemoteUserDataSession("agent-user");
await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("缺少画布合并基线");
expect(useCanvasStore.getState().projects[0]).toBe(edited);
expect((await readCanvasSyncDrafts(initial.id))[0].project.title).toBe(edited.title);
});
test("Agent updates merge a local title edit and the next save uses the new revision", async () => {
await setup();
useCanvasStore.setState({ projects: [{ ...initial, title: "本地标题" }] });
const patch = { canvasId: initial.id, baseRevision: 1, revision: 2, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] };
await applyAgentCanvasPatches(initial.id, [patch]);
const merged = useCanvasStore.getState().projects[0];
expect(merged.title).toBe("本地标题");
expect(merged.revision).toBe(2);
expect(merged.nodes).toEqual(remote.nodes);
const put = spyOn(http, "put").mockImplementation(async (_url, body) => {
const project = (body as { project: CanvasProject }).project;
expect(project.revision).toBe(2);
return { project: { ...project, revision: 3 } } as never;
});
try {
await saveRemoteUserDataNow(initial.id);
expect(put).toHaveBeenCalledTimes(1);
await applyAgentCanvasPatches(initial.id, [patch]);
expect(useCanvasStore.getState().projects[0].revision).toBe(3);
expect(useCanvasStore.getState().projects[0].title).toBe("本地标题");
} finally {
put.mockRestore();
}
});