113 lines
5.2 KiB
TypeScript
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);
|
|
});
|