feat(canvas): 画布同步 - 新增操作事务、预演保存与并发恢复能力
This commit is contained in:
112
web/test/canvas-persistence-loss.test.ts
Normal file
112
web/test/canvas-persistence-loss.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
import { rebaseCanvasProjects, parseCanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision";
|
||||
import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result";
|
||||
import { buildCanvasOperations, mergeRemoteAssetsWithLocalPending, remoteUserDataRetryDelay } from "@/services/user-data-sync";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
const node = (id: string, title = id): CanvasNodeData => ({
|
||||
id,
|
||||
type: CanvasNodeType.Image,
|
||||
title,
|
||||
position: { x: 0, y: 0 },
|
||||
width: 100,
|
||||
height: 100,
|
||||
metadata: { content: title },
|
||||
});
|
||||
|
||||
const project = (nodes: CanvasNodeData[], revision = 1): CanvasProject => ({
|
||||
id: "canvas",
|
||||
revision,
|
||||
title: "Canvas",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
nodes,
|
||||
connections: [],
|
||||
chatSessions: [],
|
||||
activeChatId: null,
|
||||
backgroundMode: "dots",
|
||||
showImageInfo: false,
|
||||
viewport: { x: 0, y: 0, k: 1 },
|
||||
previsScenes: [],
|
||||
});
|
||||
|
||||
test("storage rebase keeps durable-only and local-only nodes when project revisions differ", () => {
|
||||
const base = project([node("base")], 1);
|
||||
const local = project([node("base"), node("local")], 1);
|
||||
const durable = project([node("base"), node("remote")], 2);
|
||||
const document = parseCanvasStorageDocument(null, [durable]);
|
||||
|
||||
const rebased = rebaseCanvasProjects({ document, baseProjects: [base], localProjects: [local], baseRevision: 1 });
|
||||
const result = rebased.document.state.projects[0]!;
|
||||
|
||||
expect(result.nodes.map((item) => item.id).sort()).toEqual(["base", "local", "remote"]);
|
||||
});
|
||||
|
||||
test("storage rebase preserves a local node recreated after a concurrent deletion", () => {
|
||||
const base = project([node("same", "before")]);
|
||||
const local = project([node("same", "recreated")]);
|
||||
const document = parseCanvasStorageDocument(null, [project([], 2)]);
|
||||
document.tombstones.nodes.canvas = { same: 2 };
|
||||
|
||||
const rebased = rebaseCanvasProjects({ document, baseProjects: [base], localProjects: [local], baseRevision: 1 });
|
||||
const result = rebased.document.state.projects[0]!;
|
||||
|
||||
expect(result.nodes).toHaveLength(1);
|
||||
expect(result.nodes[0]).toMatchObject({ id: "same", title: "recreated" });
|
||||
expect(rebased.conflicts.some((conflict) => conflict.id === "same")).toBe(true);
|
||||
});
|
||||
|
||||
test("storage rebase keeps a durable edit when a stale local delete races with it", () => {
|
||||
const base = project([node("same", "before")]);
|
||||
const local = project([]);
|
||||
const durable = project([node("same", "remote edit")], 2);
|
||||
const document = parseCanvasStorageDocument(null, [durable]);
|
||||
|
||||
const rebased = rebaseCanvasProjects({ document, baseProjects: [base], localProjects: [local], baseRevision: 1 });
|
||||
const result = rebased.document.state.projects[0]!;
|
||||
|
||||
expect(result.nodes).toHaveLength(1);
|
||||
expect(result.nodes[0]).toMatchObject({ id: "same", title: "remote edit" });
|
||||
expect(rebased.conflicts.some((conflict) => conflict.id === "same")).toBe(true);
|
||||
});
|
||||
|
||||
test("generation result refreshes an existing additional node without losing its local presentation edits", () => {
|
||||
const before = node("root", "Root");
|
||||
const child = { ...node("child", "Renamed"), position: { x: 900, y: 700 }, metadata: { content: "old" } };
|
||||
const result = { ...before, metadata: { content: "root-result" } };
|
||||
const refreshedChild = { ...child, metadata: { content: "new" } };
|
||||
|
||||
const committed = commitCanvasGenerationResult([before, child], before, result, "task", [refreshedChild]);
|
||||
|
||||
expect(committed).toHaveLength(2);
|
||||
expect(committed[1]).toMatchObject({ id: "child", title: "Renamed", position: { x: 900, y: 700 }, metadata: { content: "new" } });
|
||||
});
|
||||
|
||||
test("login keeps local asset edits and new assets while retaining remote assets", () => {
|
||||
const remoteAsset = { id: "shared", kind: "text", title: "remote", coverUrl: "", tags: [], createdAt: "2026-01-01", updatedAt: "2026-01-02", data: { content: "remote" } } as Asset;
|
||||
const localEdit = { ...remoteAsset, title: "local edit", updatedAt: "2026-01-03" };
|
||||
const localNew = { ...remoteAsset, id: "local-only", title: "local only" };
|
||||
const merged = mergeRemoteAssetsWithLocalPending([remoteAsset], [localEdit, localNew]);
|
||||
|
||||
expect(merged).toHaveLength(2);
|
||||
expect(merged.find((asset) => asset.id === "shared")?.title).toBe("local edit");
|
||||
expect(merged.find((asset) => asset.id === "local-only")?.title).toBe("local only");
|
||||
});
|
||||
|
||||
test("remote sync retry keeps polling at the capped backoff instead of stopping", () => {
|
||||
expect(remoteUserDataRetryDelay(0)).toBe(2_000);
|
||||
expect(remoteUserDataRetryDelay(4)).toBe(30_000);
|
||||
expect(remoteUserDataRetryDelay(20)).toBe(30_000);
|
||||
expect(remoteUserDataRetryDelay(20, 45_000)).toBe(45_000);
|
||||
});
|
||||
|
||||
test("new revision-zero canvas produces an explicit create mutation instead of an implicit update", () => {
|
||||
const draft = project([node("local")], 0);
|
||||
const operations = buildCanvasOperations(undefined, draft);
|
||||
|
||||
expect(operations[0]).toMatchObject({ type: "create_canvas", id: "canvas" });
|
||||
expect(operations.some((operation) => operation.type === "create_node")).toBe(true);
|
||||
});
|
||||
Reference in New Issue
Block a user