Files
ttstd-infinite-canvas/web/test/canvas-persistence-loss.test.ts

113 lines
5.2 KiB
TypeScript

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);
});