feat(canvas): 画布同步 - 新增操作事务、预演保存与并发恢复能力

This commit is contained in:
ddcat666
2026-10-11 22:19:20 +08:00
parent 6dedc5973c
commit 5f359f2b1d
63 changed files with 3235 additions and 6193 deletions

View File

@@ -1,344 +0,0 @@
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();
}
});

View File

@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test";
import { buildCanvasOperations, repairPendingCanvasMutationRequest } from "@/services/user-data-sync";
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
function canvas(patch: Partial<CanvasProject> = {}): CanvasProject {
return {
id: "canvas", title: "自由画布 1", createdAt: "", updatedAt: "", nodes: [], connections: [], chatSessions: [], activeChatId: null,
backgroundMode: "dots" as CanvasProject["backgroundMode"], showImageInfo: false, viewport: { x: 0, y: 0, k: 1 }, previsScenes: [], ...patch,
};
}
describe("画布 operations 生成", () => {
test("新画布只发 create_canvas,不追加会与创建字段冲突的 set_field", () => {
const operations = buildCanvasOperations(undefined, canvas());
expect(operations[0]?.type).toBe("create_canvas");
expect(operations.some((operation) => operation.type === "set_field")).toBe(false);
});
test("已有画布改标题时带上 beforeValue", () => {
const operations = buildCanvasOperations(canvas({ title: "旧" }), canvas({ title: "新" }));
expect(operations).toEqual([{ type: "set_field", field: "title", value: "新", beforeValue: "旧" }]);
});
test("修复旧 journal:移除新画布中重复的 set_field,保留 mutationId", () => {
const legacy = JSON.parse(JSON.stringify({
mutationId: "m-1",
operations: [
{ type: "create_canvas", id: "canvas", patch: { title: "自由画布 1" } },
{ type: "set_field", field: "title", value: "自由画布 1", beforeValue: undefined },
],
}));
const repaired = repairPendingCanvasMutationRequest(legacy, undefined, canvas());
expect(repaired.mutationId).toBe("m-1");
expect(repaired.operations).toEqual([{ type: "create_canvas", id: "canvas", patch: { title: "自由画布 1" } }]);
});
test("修复旧 journal:移除空 projectId 到缺省值的语义 no-op", () => {
const legacy = { mutationId: "m-3", operations: [
{ type: "create_node", node: { id: "node-1" } },
{ type: "set_field", field: "projectId", value: null, beforeValue: "" },
] } as never;
const repaired = repairPendingCanvasMutationRequest(legacy, canvas({ projectId: "" }), canvas({ projectId: undefined }));
expect(repaired.mutationId).toBe("m-3");
expect(repaired.operations).toEqual([{ type: "create_node", node: { id: "node-1" } }]);
});
test("修复旧 journal:缺失的可选字段基线编码为 null", () => {
const legacy = JSON.parse(JSON.stringify({ mutationId: "m-2", operations: [{ type: "set_field", field: "timeline", value: { tracks: [] }, beforeValue: undefined }] }));
const repaired = repairPendingCanvasMutationRequest(legacy, canvas(), canvas());
expect(repaired.operations).toEqual([{ type: "set_field", field: "timeline", value: { tracks: [] }, beforeValue: null }]);
});
});

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

View File

@@ -1,531 +0,0 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import localforage from "localforage";
import { apiClient } from "../src/services/api/request";
import { canvasContentHash } from "../src/lib/canvas/canvas-content";
import { rebaseCanvasProjects, parseCanvasStorageDocument } from "../src/lib/canvas/canvas-storage-revision";
import { readCanvasSyncDrafts } from "../src/services/canvas-sync-drafts";
import {
applyAgentCanvasPatches,
deleteCanvasProjectsWithRemoteSync,
refreshCanvasAfterAgent,
initializeRemoteUserDataSession,
installRemoteUserDataAutoSync,
loadCanvasProjectForEditing,
resetRemoteUserDataSync,
saveRemoteUserDataNow,
syncRemoteUserData,
overwriteRemoteCanvasProject,
} from "../src/services/user-data-sync";
import { createAgentCanvasSync } from "../src/services/agent-canvas-sync";
import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "../src/stores/canvas/use-canvas-store";
import { flushAssetStorePersistence, useAssetStore } from "../src/stores/use-asset-store";
import { useSyncProgressStore } from "../src/stores/use-sync-progress-store";
import { CanvasNodeType } from "../src/types/canvas";
const originalWindow = globalThis.window;
const originalAdapter = apiClient.defaults.adapter;
const originalGet = localforage.getItem;
const originalSet = localforage.setItem;
const indexed = new Map<string, string>();
let scope = "";
let sequence = 0;
let autoSave: (() => void) | undefined;
let remote = new Map<string, CanvasProject>();
let requests: Array<{ method: string; id: string; project?: CanvasProject }> = [];
let beforePut: (() => Promise<void>) | undefined;
let deleteFailureId: string | undefined;
let temporaryPutFailures = 0;
function canvas(id = "canvas"): CanvasProject {
return {
id,
revision: 1,
title: id,
createdAt: "2026-09-01T00:00:00Z",
updatedAt: "2026-09-01T00:00:00Z",
nodes: [{ id: "old-image", type: CanvasNodeType.Image, title: "image", position: { x: 0, y: 0 }, width: 100, height: 100 }],
connections: [],
chatSessions: [],
activeChatId: null,
backgroundMode: "dots",
showImageInfo: false,
viewport: { x: 0, y: 0, k: 1 },
directorScenes: [],
};
}
function addNode(project: CanvasProject, id: string) {
return { ...project, nodes: [...project.nodes, { ...project.nodes[0], id, type: CanvasNodeType.Video }] };
}
beforeEach(async () => {
resetRemoteUserDataSync();
scope = `canvas-revision-test-${++sequence}`;
indexed.clear();
requests = [];
autoSave = undefined;
beforePut = undefined;
deleteFailureId = undefined;
temporaryPutFailures = 0;
remote = new Map([
["canvas", canvas()],
["other", canvas("other")],
]);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
setTimeout: (callback: () => void) => {
autoSave = callback;
return 1;
},
clearTimeout: () => {
autoSave = undefined;
},
localStorage: { getItem: () => scope, setItem: () => undefined },
},
});
localforage.getItem = (async (key: string) => indexed.get(key) ?? null) as typeof localforage.getItem;
localforage.setItem = (async (key: string, value: string) => {
indexed.set(key, value);
return value;
}) as typeof localforage.setItem;
apiClient.defaults.adapter = async (config) => {
const method = config.method || "get";
const id = String(config.url).split("/").at(-1)!;
const body = config.data ? JSON.parse(config.data) : undefined;
requests.push({ method, id, project: body?.project });
let data: unknown;
let status = 200;
if (id === "snapshot") data = { projects: structuredClone([...remote.values()]), assets: [] };
else if (method === "delete") {
if (id === deleteFailureId) status = 403;
else {
remote.delete(id);
data = { id };
}
} else if (id === "batch" && method === "post") {
data = {
assets: [
{
id: "invalid-mime-asset",
kind: "image",
title: "历史素材",
coverUrl: "",
tags: [],
createdAt: "2026-09-01",
updatedAt: "2026-09-01",
data: { dataUrl: "https://example.com/image.png", width: 100, height: 100, bytes: 1, mimeType: "image/*" },
},
],
};
} else if (id === "restore" && method === "post") {
const current = remote.get("canvas")!;
if (body.revision !== current.revision) status = 409;
else {
const saved = { ...canvas(), title: "historical content", revision: current.revision! + 1 };
remote.set("canvas", saved);
data = { project: saved };
}
} else if (method === "put") {
await beforePut?.();
const project = body.project as CanvasProject;
const current = remote.get(id);
if (temporaryPutFailures > 0) {
temporaryPutFailures -= 1;
status = 503;
} else if (project.revision !== (current?.revision ?? 0)) status = 409;
else {
const saved = { ...structuredClone(project), revision: project.revision! + 1 };
remote.set(id, saved);
data = { project: saved };
}
} else data = { project: structuredClone(remote.get(id)) };
return { config, status, statusText: "", headers: {}, data: { code: status === 200 ? 0 : status, data, msg: status === 409 ? "版本冲突" : "ok" } };
};
useCanvasStore.setState({ projects: [] });
useAssetStore.setState({ assets: [] });
installRemoteUserDataAutoSync();
await syncRemoteUserData(scope);
});
async function waitForSyncState(predicate: () => boolean) {
const deadline = Date.now() + 1_000;
while (!predicate()) {
if (Date.now() >= deadline) throw new Error("同步测试状态等待超时");
await new Promise<void>((resolve) => globalThis.setTimeout(resolve, 0));
}
}
afterEach(async () => {
resetRemoteUserDataSync();
await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
apiClient.defaults.adapter = originalAdapter;
localforage.getItem = originalGet;
localforage.setItem = originalSet;
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
});
test("deletion skips editing and invalid MIME assets, including an uncached canvas", async () => {
const project = canvas();
project.nodes[0].metadata = { assetId: "invalid-mime-asset" };
remote.set(project.id, project);
await initializeRemoteUserDataSession(scope);
await expect(loadCanvasProjectForEditing(project.id)).rejects.toThrow("具体 MIME");
useCanvasStore.getState().deleteProjects(["other"]);
await flushCanvasStorePersistence();
requests = [];
await deleteCanvasProjectsWithRemoteSync([" canvas ", "canvas", "other"]);
expect(requests.map(({ method, id }) => ({ method, id }))).toEqual([
{ method: "delete", id: "canvas" },
{ method: "delete", id: "other" },
]);
expect(remote.size).toBe(0);
expect(useCanvasStore.getState().projects).toEqual([]);
await useCanvasStore.persist.rehydrate();
expect(useCanvasStore.getState().projects).toEqual([]);
});
test("batch deletion retains failed canvases and persists successful deletions", async () => {
await initializeRemoteUserDataSession(scope);
deleteFailureId = "other";
await expect(deleteCanvasProjectsWithRemoteSync(["canvas", "other"])).rejects.toThrow();
expect([...remote.keys()]).toEqual(["other"]);
expect(useCanvasStore.getState().projects.map((project) => project.id)).toEqual(["other"]);
await useCanvasStore.persist.rehydrate();
expect(useCanvasStore.getState().projects.map((project) => project.id)).toEqual(["other"]);
});
test("stale viewport, no-op restore and open do not submit old content", async () => {
const old = useCanvasStore.getState().openProject("canvas")!;
remote.set("canvas", { ...addNode(canvas(), "afternoon-video"), revision: 2 });
useCanvasStore.getState().updateProject("canvas", { viewport: { x: 100, y: 40, k: 2 } });
useCanvasStore.getState().updateProject("canvas", { nodes: structuredClone(old.nodes), connections: [] });
expect(autoSave).toBeUndefined();
expect(useCanvasStore.getState().openProject("canvas")!.updatedAt).toBe(old.updatedAt);
await flushCanvasStorePersistence();
await saveRemoteUserDataNow();
const latest = await loadCanvasProjectForEditing("canvas");
expect(latest.nodes.map((node) => node.id)).toEqual(["old-image", "afternoon-video"]);
expect(latest.revision).toBe(2);
expect(latest.viewport).toEqual({ x: 100, y: 40, k: 2 });
await saveRemoteUserDataNow();
expect(requests.filter((request) => request.method === "put")).toHaveLength(0);
});
test("explicit local overwrite replaces only the selected cloud canvas after a revision conflict", async () => {
remote.set("canvas", { ...canvas(), title: "cloud edit", revision: 2 });
useCanvasStore.getState().renameProject("canvas", "local edit");
await overwriteRemoteCanvasProject("canvas");
expect(remote.get("canvas")).toMatchObject({ title: "local edit", revision: 3 });
expect(remote.get("other")).toMatchObject({ title: "other", revision: 1 });
expect(useSyncProgressStore.getState().syncingProjects.canvas).toMatchObject({ phase: "done", cloudRevision: 3, pendingChanges: 0 });
});
test("load latest, save and reload stay synced when Agent history replays an unversioned delta", async () => {
remote.set("canvas", { ...canvas(), revision: 9 });
await loadCanvasProjectForEditing("canvas", { latest: true });
useCanvasStore.getState().renameProject("canvas", "已保存的标题");
await saveRemoteUserDataNow("canvas");
expect(remote.get("canvas")!.revision).toBe(10);
for (let reload = 0; reload < 3; reload++) {
resetRemoteUserDataSync();
await useCanvasStore.persist.rehydrate();
await initializeRemoteUserDataSession(scope);
await loadCanvasProjectForEditing("canvas");
let resolve!: () => void;
let reject!: (error: unknown) => void;
const reconciled = new Promise<void>((done, fail) => {
resolve = done;
reject = fail;
});
const sync = createAgentCanvasSync({
canvasId: "canvas",
batchMs: 0,
applyPatches: (patches) => applyAgentCanvasPatches("canvas", patches),
refresh: async () => {
await refreshCanvasAfterAgent("canvas");
resolve();
},
onError: reject,
});
try {
sync.receive({
eventId: "historical-event",
runId: "completed-run",
seq: 1,
type: "canvas_updated",
createdAt: "2026-09-17",
payload: {
canvasId: "canvas",
canvasPatch: { canvasId: "canvas", updatedAt: "2026-09-17", nodes: [{ before: null, after: { ...canvas().nodes[0], id: "stale-node" } }], connections: [] },
},
});
await reconciled;
expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done");
await saveRemoteUserDataNow("canvas");
expect(useCanvasStore.getState().openProject("canvas")).toMatchObject({ revision: 10, title: "已保存的标题", nodes: canvas().nodes });
expect(await readCanvasSyncDrafts("canvas")).toHaveLength(0);
} finally {
sync.dispose();
}
}
expect(requests.filter((request) => request.method === "put")).toHaveLength(1);
});
test("a stale save auto-reconciles when the cloud already contains the identical local content", async () => {
useCanvasStore.getState().renameProject("canvas", "same content saved elsewhere");
beforePut = async () => {
const current = useCanvasStore.getState().openProject("canvas")!;
remote.set("canvas", { ...structuredClone(current), revision: current.revision! + 1 });
};
await expect(saveRemoteUserDataNow("canvas")).resolves.toBeUndefined();
expect(useCanvasStore.getState().openProject("canvas")).toMatchObject({ title: "same content saved elsewhere", revision: 2 });
expect(useSyncProgressStore.getState().syncingProjects.canvas).toMatchObject({ phase: "done", message: "云端内容一致,已自动校准版本" });
expect(await readCanvasSyncDrafts("canvas")).toHaveLength(0);
});
test("a stale save rebases disjoint Agent edits and keeps saving", async () => {
remote.set("canvas", { ...addNode(canvas(), "remote-video"), revision: 2 });
useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "local-video").nodes });
useCanvasStore.getState().renameProject("other", "other edit");
await expect(saveRemoteUserDataNow()).resolves.toBeUndefined();
expect(remote.get("canvas")!.nodes.map((node) => node.id)).toEqual(["old-image", "local-video", "remote-video"]);
expect(remote.get("canvas")!.revision).toBe(3);
expect(remote.get("other")!.title).toBe("other edit");
expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done");
useCanvasStore.getState().renameProject("canvas", "more local edits");
await expect(saveRemoteUserDataNow("canvas")).resolves.toBeUndefined();
expect(remote.get("canvas")!.title).toBe("more local edits");
expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done");
});
test("transient cloud failures schedule a bounded automatic retry", async () => {
temporaryPutFailures = 1;
useCanvasStore.getState().renameProject("canvas", "retry me");
expect(autoSave).toBeDefined();
autoSave?.();
await waitForSyncState(() => requests.filter((request) => request.method === "put" && request.id === "canvas").length === 1);
expect(autoSave).toBeDefined();
expect(useSyncProgressStore.getState().syncingProjects.canvas.message).toContain("自动重试");
autoSave?.();
await waitForSyncState(() => remote.get("canvas")?.title === "retry me");
expect(requests.filter((request) => request.method === "put" && request.id === "canvas")).toHaveLength(2);
expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done");
});
test("edits during a save retain the right ancestor revision and are saved next", async () => {
let release!: () => void;
let started!: () => void;
const waiting = new Promise<void>((resolve) => {
release = resolve;
});
const entered = new Promise<void>((resolve) => {
started = resolve;
});
beforePut = async () => {
started();
await waiting;
};
useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "first-video").nodes });
const saving = saveRemoteUserDataNow();
await entered;
useCanvasStore.getState().updateProject("canvas", { nodes: addNode(useCanvasStore.getState().openProject("canvas")!, "second-video").nodes });
release();
await saving;
expect(requests.filter((request) => request.method === "put").map((request) => request.project!.revision)).toEqual([1, 2]);
expect(remote.get("canvas")!.revision).toBe(3);
expect(remote.get("canvas")!.nodes.map((node) => node.id)).toEqual(["old-image", "first-video", "second-video"]);
expect(useCanvasStore.getState().openProject("canvas")!.remoteContentHash).toBe(await canvasContentHash(useCanvasStore.getState().openProject("canvas")!));
expect(requests.filter((request) => request.method === "put").every((request) => !("viewport" in request.project!) && !("remoteContentHash" in request.project!))).toBe(true);
});
test("login archives unsynced changes before replacing cache and keeps user scopes separate", async () => {
useCanvasStore.getState().renameProject("canvas", "unsaved draft");
await flushCanvasStorePersistence();
resetRemoteUserDataSync();
await useCanvasStore.persist.rehydrate();
await syncRemoteUserData(scope);
expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("canvas");
expect((await readCanvasSyncDrafts("canvas"))[0].project.title).toBe("unsaved draft");
expect(await readCanvasSyncDrafts("canvas", "another-user")).toEqual([]);
expect(requests.some((request) => request.method === "put")).toBe(false);
});
test("failed draft storage prevents replacement by the latest server content", async () => {
const working = localforage.setItem;
useCanvasStore.getState().renameProject("canvas", "must survive");
localforage.setItem = (async (key: string, value: unknown) => {
if (key.includes("sync-drafts")) throw new Error("disk full");
return working(key, value);
}) as typeof localforage.setItem;
await expect(loadCanvasProjectForEditing("canvas", { latest: true })).rejects.toThrow("disk full");
expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("must survive");
});
test("local storage rebase cannot pair old edits with another tab's newer revision", () => {
const base = canvas();
const local = addNode(base, "unsaved-local");
const durable = { ...addNode(base, "remote-video"), revision: 2 };
const document = parseCanvasStorageDocument(null, [durable]);
const rebased = rebaseCanvasProjects({ document, baseProjects: [base], localProjects: [local], baseRevision: 0 }).document.state.projects[0];
expect(rebased.revision).toBe(1);
expect(rebased.nodes.at(-1)!.id).toBe("unsaved-local");
});
test("loading aligns live editor content before publishing its server revision", async () => {
remote.set("canvas", { ...addNode(canvas(), "new-cloud-node"), revision: 2 });
let applied = false;
await loadCanvasProjectForEditing("canvas", {
onLoad: (project) => {
expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(1);
expect(project.revision).toBe(2);
expect(project.nodes.at(-1)!.id).toBe("new-cloud-node");
applied = true;
},
});
expect(applied).toBe(true);
expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(2);
});
test("viewport-only updates during latest load still adopt cloud content", async () => {
remote.set("canvas", { ...addNode(canvas(), "remote-video"), revision: 2 });
useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "local-video").nodes });
const working = localforage.setItem;
let release!: () => void;
let started!: () => void;
const waiting = new Promise<void>((resolve) => {
release = resolve;
});
const entered = new Promise<void>((resolve) => {
started = resolve;
});
localforage.setItem = (async (key: string, value: unknown) => {
if (key.includes("sync-drafts:")) {
started();
await waiting;
}
return working(key, value);
}) as typeof localforage.setItem;
const loading = loadCanvasProjectForEditing("canvas", { latest: true });
await entered;
useCanvasStore.getState().updateProject("canvas", { viewport: { x: 40, y: 12, k: 1.5 } });
release();
const loaded = await loading;
expect(loaded.nodes.at(-1)!.id).toBe("remote-video");
expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(2);
expect(useCanvasStore.getState().openProject("canvas")!.viewport).toEqual({ x: 40, y: 12, k: 1.5 });
});
test("edits arriving during draft backup prevent replacement of the live canvas", async () => {
useCanvasStore.getState().renameProject("canvas", "first local edit");
const working = localforage.setItem;
let release!: () => void;
let started!: () => void;
const waiting = new Promise<void>((resolve) => {
release = resolve;
});
const entered = new Promise<void>((resolve) => {
started = resolve;
});
localforage.setItem = (async (key: string, value: unknown) => {
if (key.includes("sync-drafts:")) {
started();
await waiting;
}
return working(key, value);
}) as typeof localforage.setItem;
const loading = loadCanvasProjectForEditing("canvas", { latest: true });
await entered;
useCanvasStore.getState().renameProject("canvas", "second local edit");
release();
await expect(loading).rejects.toThrow("画布仍在更新");
expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("second local edit");
});
test("unknown revision cannot borrow the latest revision to submit stale content", async () => {
const local = useCanvasStore.getState().openProject("canvas")!;
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === local.id ? { ...local, revision: undefined, title: "legacy draft" } : project)) }));
await expect(saveRemoteUserDataNow()).rejects.toThrow("缺少画布版本");
expect(requests.filter((request) => request.method === "put")).toHaveLength(0);
expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("conflict");
expect((await readCanvasSyncDrafts("canvas"))[0].project.title).toBe("legacy draft");
});
test("HTTP environments without Web Crypto still distinguish unsaved content", async () => {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto")!;
Object.defineProperty(globalThis, "crypto", { configurable: true, value: undefined });
try {
const baseline = await canvasContentHash(canvas());
expect(baseline).toBe(await canvasContentHash({ ...canvas(), viewport: { x: 100, y: 0, k: 2 } }));
expect(baseline).not.toBe(await canvasContentHash(addNode(canvas(), "new-video")));
} finally {
Object.defineProperty(globalThis, "crypto", descriptor);
}
});
test("history restore preserves the draft and installs a new revision without an extra content PUT", async () => {
useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "unsaved-video").nodes });
let applied = false;
const restored = await loadCanvasProjectForEditing("canvas", {
historyRestore: { snapshotId: "historical", revision: 1 },
onLoad: (project) => {
expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(1);
expect(project.revision).toBe(2);
applied = true;
},
});
expect(applied).toBe(true);
expect(restored.title).toBe("historical content");
expect((await readCanvasSyncDrafts("canvas")).some((draft) => draft.project.nodes.some((node) => node.id === "unsaved-video"))).toBe(true);
await saveRemoteUserDataNow("canvas");
expect(requests.filter((request) => request.method === "post")).toHaveLength(1);
expect(requests.filter((request) => request.method === "put")).toHaveLength(0);
});
test("a stale history restore preserves both current cloud content and local edits", async () => {
remote.set("canvas", { ...addNode(canvas(), "new-cloud-video"), revision: 2 });
useCanvasStore.getState().renameProject("canvas", "local draft");
await expect(loadCanvasProjectForEditing("canvas", { historyRestore: { snapshotId: "historical", revision: 1 } })).rejects.toThrow("版本冲突");
expect(remote.get("canvas")!.nodes.at(-1)!.id).toBe("new-cloud-video");
expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("local draft");
expect((await readCanvasSyncDrafts("canvas"))[0].project.title).toBe("local draft");
});
test("reopened dirty cache with the same ancestor stays pending until actually saved", async () => {
useCanvasStore.getState().renameProject("canvas", "offline draft");
await flushCanvasStorePersistence();
await initializeRemoteUserDataSession(scope);
const loaded = await loadCanvasProjectForEditing("canvas");
expect(loaded.title).toBe("offline draft");
expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("pending");
expect(remote.get("canvas")!.title).toBe("canvas");
await saveRemoteUserDataNow("canvas");
expect(remote.get("canvas")!.title).toBe("offline draft");
expect(remote.get("canvas")!.revision).toBe(2);
});
test("failed draft backup prevents a history restore request", async () => {
const working = localforage.setItem;
localforage.setItem = (async (key: string, value: unknown) => {
if (key.includes("sync-drafts:")) throw new Error("disk full");
return working(key, value);
}) as typeof localforage.setItem;
await expect(loadCanvasProjectForEditing("canvas", { historyRestore: { snapshotId: "historical", revision: 1 } })).rejects.toThrow("disk full");
expect(requests.some((request) => request.method === "post")).toBe(false);
expect(remote.get("canvas")!.revision).toBe(1);
});

View File

@@ -1,151 +0,0 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { recordPrevisDiagnostic, resetPrevisDiagnosticDedupe } from "../src/lib/canvas/previs/previs-diagnostics-recorder";
import { getClientDiagnosticEvents } from "../src/services/diagnostics/client-diagnostics";
/** 只看预演台自己的稳定码事件,避免与其它测试写入的诊断流互相干扰。 */
function previsEvents() {
return getClientDiagnosticEvents().filter((event) => (event.code || "").startsWith("PREVIS_"));
}
function newestPrevisEvent() {
return previsEvents().at(-1);
}
const LEAK_PROBES = [
"https://cdn.example.com/actor.glb?token=abc123#frag",
"authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig",
"cookie=session=deadbeef; Path=/",
"api_key=sk-live-0000",
"Error: boom\n at PrevisModel (previs-viewport.tsx:571:29)",
"演员 1 的镜头意图正文",
];
beforeEach(() => {
resetPrevisDiagnosticDedupe();
});
describe("记录进统一诊断缓冲区", () => {
test("写入的事件带稳定码、固定文案与 runtime 分类", () => {
const before = previsEvents().length;
expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_RENDER_FAILED")).toBe(true);
expect(previsEvents().length).toBe(before + 1);
const event = newestPrevisEvent();
expect(event?.code).toBe("PREVIS_VIEWPORT_RENDER_FAILED");
expect(event?.level).toBe("error");
expect(event?.category).toBe("runtime");
expect(event?.message).toBe("预演台 3D 视口渲染失败");
// 绝不落 stack。
expect(event?.stack || "").toBe("");
});
test("安全字段以枚举后缀进入 code,便于检索", () => {
expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-1", objectKind: "model", attempt: 2 })).toBe(true);
const event = newestPrevisEvent();
expect(event?.code).toBe("PREVIS_MODEL_LOAD_FAILED object=obj-1 kind=model attempt=2");
});
test("sceneId 只进 code 后缀,不冒充 canvasId", () => {
expect(recordPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { sceneId: "repro-scene-1", revision: 3 })).toBe(true);
const event = newestPrevisEvent();
// PrevisScene.id 不是 canvas id,绝不写入既有后端契约字段。
expect(event?.canvasId).toBeUndefined();
expect(event?.code).toBe("PREVIS_SAVE_FLUSH_FAILED scene=repro-scene-1 revision=3");
});
test("三个保存相关码都能写入且级别正确", () => {
recordPrevisDiagnostic("PREVIS_SAVE_RETRY_RECOVERED", { revision: 1 });
expect(newestPrevisEvent()?.level).toBe("info");
recordPrevisDiagnostic("PREVIS_SAVE_DRAFT_UNAVAILABLE", { revision: 1 });
expect(newestPrevisEvent()?.level).toBe("error");
recordPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { saveOutcome: "stay" });
expect(newestPrevisEvent()?.level).toBe("warning");
});
});
describe("去重与噪声控制", () => {
test("同一 code+字段在窗口内只记一次", () => {
const before = previsEvents().length;
expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_LOST")).toBe(true);
expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_LOST")).toBe(false);
expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_LOST")).toBe(false);
expect(previsEvents().length).toBe(before + 1);
});
test("字段不同视为不同事件,不会被误去重", () => {
const before = previsEvents().length;
expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectKind: "model", attempt: 1 })).toBe(true);
expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectKind: "model", attempt: 2 })).toBe(true);
expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectKind: "actor", attempt: 1 })).toBe(true);
expect(previsEvents().length).toBe(before + 3);
});
test("不同 objectId 在窗口内不互相吞掉", () => {
const before = previsEvents().length;
expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-1", objectKind: "model" })).toBe(true);
expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-2", objectKind: "model" })).toBe(true);
expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-3", objectKind: "model" })).toBe(true);
// 同一 objectId 重复仍要被去重。
expect(recordPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-1", objectKind: "model" })).toBe(false);
expect(previsEvents().length).toBe(before + 3);
});
test("不同 sceneId 在窗口内不互相吞掉", () => {
const before = previsEvents().length;
expect(recordPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { sceneId: "scene-1", revision: 1 })).toBe(true);
expect(recordPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { sceneId: "scene-2", revision: 1 })).toBe(true);
expect(recordPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { sceneId: "scene-1", revision: 1 })).toBe(false);
expect(previsEvents().length).toBe(before + 2);
});
test("所有写入的 code 都以稳定 PREVIS_ 前缀开头", () => {
resetPrevisDiagnosticDedupe();
recordPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { sceneId: "scene-1", saveOutcome: "stay" });
for (const event of previsEvents()) {
expect((event.code || "").startsWith("PREVIS_")).toBe(true);
}
});
test("重置去重状态后可再次记录同一事件", () => {
expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_RESTORED")).toBe(true);
expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_RESTORED")).toBe(false);
resetPrevisDiagnosticDedupe();
expect(recordPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_RESTORED")).toBe(true);
});
});
describe("不泄漏敏感内容", () => {
test("伪造的敏感字段不会进入缓冲区任何字段", () => {
for (const probe of LEAK_PROBES) {
resetPrevisDiagnosticDedupe();
recordPrevisDiagnostic("PREVIS_MODEL_ADOPT_FAILED", {
objectId: probe,
sceneId: probe,
// @ts-expect-error 故意传入非法枚举,验证运行时白名单而非仅靠类型。
objectKind: probe,
// @ts-expect-error 故意传入非法数值类型。
attempt: probe,
});
const event = newestPrevisEvent();
const serialized = JSON.stringify(event);
expect(serialized).not.toContain("cdn.example.com");
expect(serialized).not.toContain("token=abc123");
expect(serialized).not.toContain("sk-live-0000");
expect(serialized).not.toContain("deadbeef");
expect(serialized).not.toContain("Bearer");
expect(serialized).not.toContain("演员");
expect(serialized).not.toContain("previs-viewport.tsx");
expect(event?.canvasId).toBeUndefined();
expect(event?.code).toBe("PREVIS_MODEL_ADOPT_FAILED");
}
});
test("未知 code 既不写入也不抛错", () => {
const before = previsEvents().length;
// @ts-expect-error 故意传入白名单外的 code。
expect(recordPrevisDiagnostic("PREVIS_NOT_A_REAL_CODE", { objectId: "obj-1" })).toBe(false);
expect(previsEvents().length).toBe(before);
});
});

View File

@@ -1,186 +0,0 @@
import { describe, expect, test } from "bun:test";
import { PREVIS_DIAGNOSTIC_CODES, previsDiagnosticObjectKind, formatPrevisDiagnosticCode, isPrevisDiagnosticCode, projectPrevisDiagnostic } from "../src/lib/canvas/previs/previs-diagnostics";
/** 任何一个都不允许出现在投影输出里。 */
const LEAK_PROBES = [
"https://cdn.example.com/actor.glb?token=abc123",
"http://localhost:3000/api/tasks?authorization=Bearer%20xyz",
"Bearer eyJhbGciOiJIUzI1NiJ9.payload.signature",
"authorization: Bearer secret-token",
"cookie=session=deadbeef; Path=/",
"api_key=sk-live-0000",
"Error: adopt failed\n at PrevisModel (previs-viewport.tsx:571:29)",
"演员 1 的镜头意图正文",
"/Users/someone/secret/path/scene.glb",
];
function serialize(value: unknown) {
return JSON.stringify(value ?? {});
}
describe("code 白名单", () => {
test("白名单内的 code 全部被接受且有稳定文案与级别", () => {
for (const code of PREVIS_DIAGNOSTIC_CODES) {
expect(isPrevisDiagnosticCode(code)).toBe(true);
const event = projectPrevisDiagnostic(code, {});
expect(event).not.toBeNull();
expect(event?.code).toBe(code);
expect(event?.message.length).toBeGreaterThan(0);
expect(["info", "warning", "error"]).toContain(event?.level ?? "");
}
});
test("未知 code 一律拒绝,不透传", () => {
const rejected = ["", "previs_viewport_render_failed", "PREVIS_UNKNOWN", "SOME_OTHER_CODE", "PREVIS_VIEWPORT_RENDER_FAILED ", null, undefined, 42, {}, []];
for (const code of rejected) {
expect(isPrevisDiagnosticCode(code)).toBe(false);
expect(projectPrevisDiagnostic(code, {})).toBeNull();
}
});
});
describe("字段白名单", () => {
test("未知字段不会出现在输出中", () => {
const event = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", {
objectId: "obj-1",
sceneName: "演员 1 场景",
url: "https://cdn.example.com/a.glb",
stack: "Error: boom\n at x",
prompt: "镜头意图正文",
storageKey: "assets/secret.glb",
token: "sk-live-0000",
});
expect(event?.fields).toEqual({ objectId: "obj-1" });
expect(serialize(event)).not.toContain("cdn.example.com");
expect(serialize(event)).not.toContain("镜头意图正文");
expect(serialize(event)).not.toContain("sk-live-0000");
});
test("伪造的敏感输入无法通过任一字段进入输出", () => {
for (const probe of LEAK_PROBES) {
const event = projectPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", {
objectId: probe,
sceneId: probe,
objectKind: probe,
saveOutcome: probe,
attempt: probe,
revision: probe,
draftStored: probe,
userInitiated: probe,
});
expect(event).not.toBeNull();
// 所有字段都应被拒绝:probe 既不是 safe-id,也不在枚举内,也不是数值/布尔。
expect(event?.fields).toEqual({});
const text = serialize(event);
expect(text).not.toContain("http");
expect(text).not.toContain("token");
expect(text).not.toContain("cookie");
expect(text).not.toContain("Bearer");
expect(text).not.toContain("演员");
}
});
test("safe-id 只接受受限字符集", () => {
expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-1_a.b:c" })?.fields.objectId).toBe("obj-1_a.b:c");
for (const bad of ["obj 1", "obj/1", "obj?1", "obj#1", "a".repeat(97), "", " ", "obj\n1", "obj%201"]) {
expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: bad })?.fields.objectId).toBeUndefined();
}
});
test("枚举字段只接受白名单值", () => {
expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectKind: "actor" })?.fields.objectKind).toBe("actor");
expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectKind: "spaceship" })?.fields.objectKind).toBeUndefined();
expect(projectPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { saveOutcome: "stay" })?.fields.saveOutcome).toBe("stay");
expect(projectPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { saveOutcome: "explode" })?.fields.saveOutcome).toBeUndefined();
});
test("数值字段有界且拒绝非有限值", () => {
expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_RETRY", { attempt: 3 })?.fields.attempt).toBe(3);
expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_RETRY", { attempt: 2.6 })?.fields.attempt).toBe(3);
for (const bad of [-1, 1000, Number.NaN, Number.POSITIVE_INFINITY, "3", null]) {
expect(projectPrevisDiagnostic("PREVIS_MODEL_LOAD_RETRY", { attempt: bad })?.fields.attempt).toBeUndefined();
}
expect(projectPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { revision: 0 })?.fields.revision).toBe(0);
expect(projectPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { revision: -1 })?.fields.revision).toBeUndefined();
});
test("布尔字段只接受真布尔", () => {
expect(projectPrevisDiagnostic("PREVIS_SAVE_RETRY_FAILED", { draftStored: false })?.fields.draftStored).toBe(false);
expect(projectPrevisDiagnostic("PREVIS_SAVE_RETRY_FAILED", { draftStored: true })?.fields.draftStored).toBe(true);
for (const bad of ["true", 1, 0, null, {}]) {
expect(projectPrevisDiagnostic("PREVIS_SAVE_RETRY_FAILED", { draftStored: bad })?.fields.draftStored).toBeUndefined();
}
});
test("非对象 fields 不抛错且产出空字段", () => {
for (const fields of [null, undefined, 42, "objectId=1", [1, 2, 3]]) {
const event = projectPrevisDiagnostic("PREVIS_VIEWPORT_CONTEXT_LOST", fields);
expect(event?.fields).toEqual({});
}
});
});
describe("稳定码格式化", () => {
test("只拼接安全枚举与数值", () => {
const event = projectPrevisDiagnostic("PREVIS_SAVE_RETRY_FAILED", { sceneId: "scene-1", revision: 4, draftStored: true, userInitiated: true });
expect(event).not.toBeNull();
if (!event) return;
const formatted = formatPrevisDiagnosticCode(event);
expect(formatted).toBe("PREVIS_SAVE_RETRY_FAILED scene=scene-1 revision=4 draft=1 user=1");
// scene/object 后缀只可能是 safe-id 投影后的值。
expect(formatted.startsWith("PREVIS_")).toBe(true);
});
test("scene 与 object 后缀都进入 code,顺序确定", () => {
const event = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { sceneId: "scene-1", objectId: "obj-9", objectKind: "model", attempt: 2 });
expect(event).not.toBeNull();
if (!event) return;
expect(formatPrevisDiagnosticCode(event)).toBe("PREVIS_MODEL_LOAD_FAILED scene=scene-1 object=obj-9 kind=model attempt=2");
});
test("不同 objectId / sceneId 产出不同签名", () => {
const a = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-1" });
const b = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: "obj-2" });
const c = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { sceneId: "scene-1" });
const d = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { sceneId: "scene-2" });
expect(a && b && c && d).toBeTruthy();
if (!a || !b || !c || !d) return;
const signatures = [a, b, c, d].map(formatPrevisDiagnosticCode);
expect(new Set(signatures).size).toBe(4);
});
test("非法 id 不进入 code 后缀", () => {
for (const bad of ["https://cdn.example.com/a.glb?token=abc", "obj 1", "obj/1", "a".repeat(97)]) {
const event = projectPrevisDiagnostic("PREVIS_MODEL_LOAD_FAILED", { objectId: bad, sceneId: bad });
expect(event).not.toBeNull();
if (!event) continue;
const formatted = formatPrevisDiagnosticCode(event);
expect(formatted).toBe("PREVIS_MODEL_LOAD_FAILED");
expect(formatted).not.toContain("scene=");
expect(formatted).not.toContain("object=");
expect(formatted).not.toContain("cdn.example.com");
}
});
test("无字段时只有 code 本身", () => {
const event = projectPrevisDiagnostic("PREVIS_VIEWPORT_RENDER_FAILED", {});
expect(event).not.toBeNull();
if (!event) return;
expect(formatPrevisDiagnosticCode(event)).toBe("PREVIS_VIEWPORT_RENDER_FAILED");
});
});
describe("对象种类推导", () => {
test("按形态映射,不读取名称或地址", () => {
expect(previsDiagnosticObjectKind({ kind: "actor" })).toBe("actor");
expect(previsDiagnosticObjectKind({ kind: "model" })).toBe("model");
expect(previsDiagnosticObjectKind({ kind: "billboard" })).toBe("billboard");
expect(previsDiagnosticObjectKind({ primitive: "box" })).toBe("primitive");
expect(previsDiagnosticObjectKind({})).toBe("unknown");
expect(previsDiagnosticObjectKind(null)).toBe("unknown");
expect(previsDiagnosticObjectKind(undefined)).toBe("unknown");
});
});

View File

@@ -1,995 +0,0 @@
import { describe, expect, it } from "bun:test";
import { scopedStorageKey } from "../src/lib/user-scope";
import type { PrevisScene } from "../src/types/previs";
import { createPrevisSaveCoordinator, type PrevisDraftEnvelope, type PrevisFlushRequest, type FlushFunction, type StorageLike } from "../src/lib/canvas/previs/previs-save";
class MemoryStorage implements StorageLike {
private readonly values = new Map<string, string>();
throwOnGet = false;
throwOnSet = false;
throwOnRemove = false;
getItem(key: string): string | null {
if (this.throwOnGet) throw new Error("get failed");
return this.values.get(key) ?? null;
}
setItem(key: string, value: string): void {
if (this.throwOnSet) throw new Error("set failed");
this.values.set(key, value);
}
removeItem(key: string): void {
if (this.throwOnRemove) throw new Error("remove failed");
this.values.delete(key);
}
keys(): string[] {
return [...this.values.keys()];
}
}
class ManualScheduler {
private callbacks = new Map<number, () => void>();
private id = 0;
schedule(callback: () => void, delay: number): unknown {
const handle = this.id++;
this.callbacks.set(handle, callback);
return handle;
}
cancel(handle: unknown): void {
this.callbacks.delete(handle as number);
}
runAll(): void {
for (const cb of this.callbacks.values()) {
cb();
}
this.callbacks.clear();
}
size(): number {
return this.callbacks.size;
}
}
function deferred() {
let res: (() => void) | null = null;
let rej: ((err: unknown) => void) | null = null;
const p = new Promise<void>((resolve, reject) => {
res = resolve;
rej = reject;
});
return {
promise: p,
resolve: () => res?.(),
reject: (err: unknown) => rej?.(err),
};
}
const makeScene = (overrides: Partial<PrevisScene> = {}): PrevisScene => ({
id: "scene-1",
version: 1,
title: "Test Scene",
background: "#000",
environmentIntensity: 0.5,
gridVisible: true,
objects: [],
cameras: [],
lights: [],
shots: [],
activeShotId: "shot-1",
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
...overrides,
});
const makeEnvelope = (overrides: Partial<PrevisDraftEnvelope> = {}): PrevisDraftEnvelope => {
const scene = overrides.scene ?? makeScene();
return {
schemaVersion: 1,
sceneId: scene.id,
baseUpdatedAt: scene.updatedAt,
revision: 1,
savedAt: 1000,
...overrides,
scene,
};
};
/** Serializes a complete valid envelope after breaking only the target fields. */
const corruptEnvelope = (corrupt: (envelope: Record<string, unknown>) => void, overrides: Partial<PrevisDraftEnvelope> = {}): string => {
const envelope: Record<string, unknown> = {
...makeEnvelope(overrides),
};
corrupt(envelope);
return JSON.stringify(envelope);
};
/**
* Serializes a complete valid envelope, splicing raw JSON number tokens that
* JSON.stringify cannot express (for example 1e999 parsing back as Infinity).
*/
const rawEnvelopeWith = (rawFields: Record<string, string>): string => {
const envelope: Record<string, unknown> = { ...makeEnvelope() };
const entries = Object.entries(envelope)
.filter(([field]) => !(field in rawFields))
.map(([field, value]) => JSON.stringify(field) + ":" + JSON.stringify(value));
const rawEntries = Object.entries(rawFields).map(([field, token]) => JSON.stringify(field) + ":" + token);
return "{" + [...entries, ...rawEntries].join(",") + "}";
};
type HarnessOptions = {
scope?: string;
storage?: MemoryStorage;
initialScene?: PrevisScene;
};
const createHarness = (options: HarnessOptions = {}) => {
const scope = options.scope ?? "user-a";
const storage = options.storage ?? new MemoryStorage();
const initialScene = options.initialScene ?? makeScene();
const scheduler = new ManualScheduler();
let now = 1000;
const requests: PrevisFlushRequest[] = [];
let flushBehavior: FlushFunction = async () => undefined;
const flushFn: FlushFunction = async (request) => {
requests.push(structuredClone(request));
await flushBehavior(request);
};
const coord = createPrevisSaveCoordinator({
initialScene,
scope,
storage,
flush: flushFn,
now: () => now++,
debounceMs: 25,
schedule: (callback) => scheduler.schedule(callback, 0),
cancelSchedule: (handle) => scheduler.cancel(handle),
});
const key = scopedStorageKey("previs-scene-draft:" + initialScene.id, scope);
const editedScene = (overrides: Partial<PrevisScene> = {}) => {
const scene = makeScene(overrides);
coord.edit(scene);
return scene;
};
const readEnvelope = (id: string = initialScene.id): PrevisDraftEnvelope | null => {
const readKey = id === initialScene.id ? key : scopedStorageKey("previs-scene-draft:" + id, scope);
const raw = storage.getItem(readKey);
return raw === null ? null : (JSON.parse(raw) as PrevisDraftEnvelope);
};
const writeRaw = (raw: string): void => {
storage.setItem(key, raw);
};
const setFlushBehavior = (next: FlushFunction): void => {
flushBehavior = next;
};
const flushMicrotasks = async (): Promise<void> => {
scheduler.runAll();
await Promise.resolve();
await Promise.resolve();
};
const dispose = (): void => {
coord.dispose();
scheduler.runAll();
};
return {
storage,
scheduler,
now: () => now,
requests,
coord,
scope,
key,
editedScene,
readEnvelope,
writeRaw,
setFlushBehavior,
flushMicrotasks,
dispose,
};
};
describe("PrevisSaveCoordinator", () => {
describe("01 initial", () => {
it("should be saved with revision 0, no draft, no recovery", () => {
const h = createHarness();
const snap = h.coord.getSnapshot();
expect(snap.status).toBe("saved");
expect(snap.revision).toBe(0);
expect(snap.confirmedRevision).toBe(0);
expect(snap.draftStored).toBe(false);
expect(h.readEnvelope("scene-1")).toBeNull();
});
});
describe("02 edit", () => {
it("should write to fixed scoped key with complete envelope", () => {
const h = createHarness();
const s = h.editedScene({ title: "Edited" });
expect(s).toBeDefined();
const env = h.readEnvelope("scene-1");
expect(env).toMatchObject({
schemaVersion: 1,
sceneId: "scene-1",
scene: expect.objectContaining({ title: "Edited" }),
});
expect(env.revision).toBe(1);
expect(env.savedAt).toBeDefined();
});
});
describe("03 continuous edit", () => {
it("should monotonically increase revision and reflect latest scene", () => {
const h = createHarness();
h.editedScene({ title: "v1" });
h.editedScene({ title: "v2" });
const snap = h.coord.getSnapshot();
expect(snap.revision).toBe(2);
expect(snap.status).toBe("dirty");
});
});
describe("04 invalid scene id", () => {
it("should reject edit with wrong scene id and leave status unchanged", () => {
const h = createHarness();
const originalStatus = h.coord.getSnapshot().status;
const result = h.coord.edit(makeScene({ id: "wrong" }));
expect(result).toBe(false);
expect(h.coord.getSnapshot().status).toBe(originalStatus);
});
});
describe("05 storage set fail", () => {
it("should keep memory edit, set error status, draftStored false", () => {
const h = createHarness();
h.storage.throwOnSet = true;
const scene = makeScene({ title: "memory-only" });
expect(h.coord.edit(scene)).toBe(true);
const snap = h.coord.getSnapshot();
expect(snap.scene.title).toBe("memory-only");
expect(snap.status).toBe("error");
expect(snap.draftStored).toBe(false);
});
});
describe("06 debounce", () => {
it("should debounce multiple edits and flush only the latest scene", async () => {
const h = createHarness();
h.editedScene({ title: "a" });
h.editedScene({ title: "b" });
expect(h.scheduler.size()).toBe(1);
await h.flushMicrotasks();
expect(h.requests).toHaveLength(1);
expect(h.requests[0]?.scene.title).toBe("b");
});
});
describe("07 concurrent flush", () => {
it("should return the same Promise for concurrent flushLatest calls", async () => {
const h = createHarness();
h.editedScene({ title: "dirty" });
const first = h.coord.flushLatest();
const second = h.coord.flushLatest();
expect(first).toBe(second);
expect(await first).toBe(true);
});
});
describe("08 concurrent edit during flush", () => {
it("should send second request in drain and update remaining draft base", async () => {
const h = createHarness();
const first = deferred();
const second = deferred();
let flushCall = 0;
h.setFlushBehavior(async () => {
const gate = flushCall++ === 0 ? first : second;
await gate.promise;
});
const firstScene = h.editedScene({
title: "first",
updatedAt: "2026-01-01T00:00:01.000Z",
});
const pending = h.coord.flushLatest();
expect(h.requests).toHaveLength(1);
expect(h.requests[0]?.scene.title).toBe("first");
h.editedScene({
title: "second",
updatedAt: "2026-01-01T00:00:02.000Z",
});
first.resolve();
await h.flushMicrotasks();
expect(h.requests).toHaveLength(2);
expect(h.requests[1]?.scene.title).toBe("second");
expect(h.coord.getSnapshot().status).not.toBe("saved");
expect(h.readEnvelope()?.baseUpdatedAt).toBe(firstScene.updatedAt);
second.resolve();
expect(await pending).toBe(true);
expect(h.coord.getSnapshot()).toMatchObject({
revision: 2,
confirmedRevision: 2,
status: "saved",
});
expect(h.readEnvelope()).toBeNull();
});
});
describe("09 remote fail", () => {
it("should keep safe draft and set error status", async () => {
const h = createHarness();
h.editedScene({ title: "remote-fail" });
h.setFlushBehavior(async () => {
throw new Error("remote failed");
});
expect(await h.coord.flushLatest()).toBe(false);
expect(h.requests).toHaveLength(1);
expect(h.coord.getSnapshot()).toMatchObject({
status: "error",
draftStored: true,
confirmedRevision: 0,
});
expect(h.readEnvelope()?.scene.title).toBe("remote-fail");
});
});
describe("10 retry", () => {
it("should re-call remote and clear draft on success", async () => {
const h = createHarness();
await h.coord.flushLatest();
const result = await h.coord.retry();
expect(result).toBe(true);
expect(h.readEnvelope("scene-1")).toBeNull();
});
});
describe("11 successful flush clears only matching draft", () => {
it("should clear only draft with confirmed revision", async () => {
const h = createHarness();
h.editedScene();
await h.coord.flushLatest();
expect(h.readEnvelope("scene-1")).toBeNull();
});
});
describe("12 remove fail keeps saved status", () => {
it("should stay saved but keep the draft when removeItem fails", async () => {
const h = createHarness();
h.editedScene({ title: "remove-fail" });
expect(h.coord.getSnapshot().draftStored).toBe(true);
expect(h.readEnvelope()?.revision).toBe(1);
h.storage.throwOnRemove = true;
expect(await h.coord.flushLatest()).toBe(true);
expect(h.coord.getSnapshot()).toMatchObject({
status: "saved",
confirmedRevision: 1,
draftStored: true,
});
h.storage.throwOnRemove = false;
expect(h.readEnvelope()?.revision).toBe(1);
});
});
describe("13 malformed JSON", () => {
it("should reject unparsable JSON stored under the scoped key", () => {
const h = createHarness();
h.writeRaw("invalid json");
expect(h.coord.restoreCandidate()).toBeNull();
});
});
describe("14 wrong schema", () => {
it("should reject a candidate whose schemaVersion is not 1", () => {
const h = createHarness();
h.writeRaw(JSON.stringify(makeEnvelope()));
expect(h.coord.restoreCandidate()).not.toBeNull();
h.writeRaw(
corruptEnvelope((envelope) => {
envelope.schemaVersion = 2;
}),
);
expect(h.coord.restoreCandidate()).toBeNull();
});
});
describe("15 wrong sceneId", () => {
it("should reject a candidate whose sceneId does not match the scene", () => {
const h = createHarness();
h.writeRaw(JSON.stringify(makeEnvelope()));
expect(h.coord.restoreCandidate()).not.toBeNull();
h.writeRaw(
corruptEnvelope((envelope) => {
envelope.sceneId = "scene-other";
}),
);
expect(h.coord.restoreCandidate()).toBeNull();
});
});
describe("16 missing baseUpdatedAt", () => {
it("should reject a candidate whose baseUpdatedAt is missing or not a string", () => {
const h = createHarness();
h.writeRaw(JSON.stringify(makeEnvelope()));
expect(h.coord.restoreCandidate()).not.toBeNull();
h.writeRaw(
corruptEnvelope((envelope) => {
delete envelope.baseUpdatedAt;
}),
);
expect(h.coord.restoreCandidate()).toBeNull();
h.writeRaw(
corruptEnvelope((envelope) => {
envelope.baseUpdatedAt = 20250101;
}),
);
expect(h.coord.restoreCandidate()).toBeNull();
});
});
describe("17 invalid scene shape", () => {
it("should reject a candidate whose scene fails PrevisScene validation", () => {
const h = createHarness();
h.writeRaw(JSON.stringify(makeEnvelope()));
expect(h.coord.restoreCandidate()).not.toBeNull();
h.writeRaw(
corruptEnvelope((envelope) => {
envelope.scene = { ...makeScene(), gridVisible: "yes" };
}),
);
expect(h.coord.restoreCandidate()).toBeNull();
h.writeRaw(
corruptEnvelope((envelope) => {
envelope.scene = { ...makeScene(), shots: "none" };
}),
);
expect(h.coord.restoreCandidate()).toBeNull();
h.writeRaw(
corruptEnvelope((envelope) => {
envelope.scene = makeScene({ id: "scene-2" });
}),
);
expect(h.coord.restoreCandidate()).toBeNull();
});
});
describe("18 invalid revision/savedAt", () => {
it("should reject negative, fractional, unsafe revision and non-finite savedAt", () => {
const h = createHarness();
h.writeRaw(JSON.stringify(makeEnvelope()));
expect(h.coord.restoreCandidate()).not.toBeNull();
h.writeRaw(
corruptEnvelope((envelope) => {
envelope.revision = -1;
}),
);
expect(h.coord.restoreCandidate()).toBeNull();
h.writeRaw(
corruptEnvelope((envelope) => {
envelope.revision = 1.5;
}),
);
expect(h.coord.restoreCandidate()).toBeNull();
h.writeRaw(
corruptEnvelope((envelope) => {
envelope.revision = Number.MAX_SAFE_INTEGER + 1;
}),
);
expect(h.coord.restoreCandidate()).toBeNull();
h.writeRaw(rawEnvelopeWith({ savedAt: "1e999" }));
expect(h.coord.restoreCandidate()).toBeNull();
});
});
describe("19 scope isolation", () => {
it("should only read the draft written under its own user scope", () => {
const storage = new MemoryStorage();
const userA = createHarness({ scope: "user-a", storage });
const userB = createHarness({ scope: "user-b", storage });
expect(userA.key).not.toBe(userB.key);
userB.writeRaw(
JSON.stringify(
makeEnvelope({
revision: 3,
scene: makeScene({ title: "user-b draft" }),
}),
),
);
expect(storage.keys()).toEqual([userB.key]);
expect(userA.coord.restoreCandidate()).toBeNull();
const candidate = userB.coord.restoreCandidate();
expect(candidate).not.toBeNull();
expect(candidate?.revision).toBe(3);
expect(candidate?.scene.title).toBe("user-b draft");
});
});
describe("20 explicitRestore success", () => {
it("should succeed and set dirty status", () => {
const h = createHarness();
const env = {
schemaVersion: 1,
sceneId: "scene-1",
baseUpdatedAt: "2025",
scene: makeScene(),
revision: 5,
savedAt: 100,
};
const result = h.coord.explicitRestore(env);
expect(result).toBe(true);
expect(h.coord.getSnapshot().status).toBe("dirty");
expect(h.coord.getSnapshot().revision).toBe(5);
});
});
describe("21 invalid explicitRestore", () => {
it("should reject older or invalid envelope", () => {
const h = createHarness();
h.editedScene();
const env = { ...h.readEnvelope("scene-1")!, revision: 0 };
const result = h.coord.explicitRestore(env);
expect(result).toBe(false);
});
});
describe("22 clean prepareClose", () => {
it("should immediately return close when confirmedRevision equals current", async () => {
const h = createHarness();
const result = await h.coord.prepareClose();
expect(result).toBe("close");
});
});
describe("23 dirty prepareClose", () => {
it("should reuse promise and return close on success", async () => {
const h = createHarness();
h.editedScene();
const p1 = h.coord.prepareClose();
const p2 = h.coord.prepareClose();
expect(p1).toBe(p2);
const result = await p1;
expect(result).toBe("close");
});
});
describe("24 prepareClose fail with draft", () => {
it("should return offer-draft-exit when remote fails but the draft is safe", async () => {
const h = createHarness();
h.editedScene({ title: "close-fail" });
expect(h.coord.getSnapshot().draftStored).toBe(true);
expect(h.readEnvelope()?.scene.title).toBe("close-fail");
h.setFlushBehavior(async () => {
throw new Error("remote rejected");
});
expect(await h.coord.prepareClose()).toBe("offer-draft-exit");
expect(h.coord.getSnapshot().status).toBe("error");
expect(h.readEnvelope()?.scene.title).toBe("close-fail");
});
});
describe("25 prepareClose full fail", () => {
it("should return stay when both the local draft and the remote flush fail", async () => {
const h = createHarness();
h.storage.throwOnSet = true;
h.setFlushBehavior(async () => {
throw new Error("remote rejected");
});
h.editedScene({ title: "no-draft" });
expect(h.coord.getSnapshot().draftStored).toBe(false);
expect(await h.coord.prepareClose()).toBe("stay");
expect(h.coord.getSnapshot().status).toBe("error");
expect(h.readEnvelope()).toBeNull();
});
});
describe("26 handlePageHide", () => {
it("should reuse one in-flight promise and flush the latest revision", async () => {
const h = createHarness();
const gate = deferred();
h.setFlushBehavior(async () => {
await gate.promise;
});
h.editedScene({ title: "hide-1" });
h.editedScene({ title: "hide-2" });
expect(h.coord.getSnapshot().revision).toBe(2);
const p1 = h.coord.handlePageHide();
const p2 = h.coord.handlePageHide();
expect(p1).toBe(p2);
expect(h.requests).toHaveLength(1);
expect(h.requests[0]?.revision).toBe(2);
expect(h.requests[0]?.scene.title).toBe("hide-2");
gate.resolve();
expect(await p1).toBe(true);
expect(h.coord.getSnapshot()).toMatchObject({
status: "saved",
confirmedRevision: 2,
});
});
});
describe("27 dispose", () => {
it("should cancel debounce, stop notifying, and reject later edits", () => {
const h = createHarness();
const seen: string[] = [];
h.coord.onStatusChange((status) => {
seen.push(status);
});
expect(h.coord.edit(makeScene({ title: "before-dispose" }))).toBe(true);
expect(seen).toEqual(["dirty"]);
expect(h.scheduler.size()).toBe(1);
h.coord.dispose();
expect(h.scheduler.size()).toBe(0);
expect(h.coord.edit(makeScene({ title: "after-dispose" }))).toBe(false);
expect(seen).toEqual(["dirty"]);
});
});
describe("28 dispose in-flight", () => {
it("should not mark saved or confirmed when disposed mid-flight", async () => {
const h = createHarness();
const gate = deferred();
h.setFlushBehavior(async () => {
await gate.promise;
});
h.editedScene({ title: "in-flight" });
const pending = h.coord.flushLatest();
expect(h.requests).toHaveLength(1);
expect(h.requests[0]?.revision).toBe(1);
h.coord.dispose();
gate.resolve();
expect(await pending).toBe(false);
const snap = h.coord.getSnapshot();
expect(snap.confirmedRevision).toBe(0);
expect(snap.status).not.toBe("saved");
});
});
describe("29 unsubscribe", () => {
it("should only remove the unsubscribed listener", () => {
const h = createHarness();
const first: string[] = [];
const second: string[] = [];
const unsubscribeFirst = h.coord.onStatusChange((status) => {
first.push(status);
});
h.coord.onStatusChange((status) => {
second.push(status);
});
unsubscribeFirst();
h.editedScene({ title: "notify-second" });
expect(first).toEqual([]);
expect(second).toEqual(["dirty"]);
});
});
describe("30 restoreCandidate on get fail", () => {
it("should return null when storage.getItem throws", () => {
const h = createHarness();
h.storage.throwOnGet = true;
expect(h.coord.restoreCandidate()).toBeNull();
});
});
describe("31 discardDraft", () => {
it("should really delete the scoped draft so the same candidate never returns", () => {
const h = createHarness();
h.editedScene({ title: "discard-me" });
expect(h.coord.restoreCandidate()?.scene.title).toBe("discard-me");
expect(h.coord.discardDraft()).toBe(true);
expect(h.readEnvelope()).toBeNull();
expect(h.storage.keys()).toEqual([]);
expect(h.coord.restoreCandidate()).toBeNull();
expect(h.coord.getSnapshot().draftStored).toBe(false);
});
it("should report false and keep the draft when removal fails", () => {
const h = createHarness();
h.editedScene({ title: "keep-me" });
h.storage.throwOnRemove = true;
expect(h.coord.discardDraft()).toBe(false);
h.storage.throwOnRemove = false;
expect(h.readEnvelope()?.scene.title).toBe("keep-me");
expect(h.coord.getSnapshot().draftStored).toBe(true);
});
it("should leave a later flush unaffected after discarding", async () => {
const h = createHarness();
h.editedScene({ title: "discard-then-save" });
expect(h.coord.discardDraft()).toBe(true);
expect(await h.coord.flushLatest()).toBe(true);
expect(h.requests).toHaveLength(1);
expect(h.requests[0]?.scene.title).toBe("discard-then-save");
expect(h.coord.getSnapshot()).toMatchObject({
status: "saved",
confirmedRevision: 1,
});
});
});
describe("32 restore then save", () => {
it("should adopt the restored revision and confirm it on the next flush", async () => {
const storage = new MemoryStorage();
const previousSession = createHarness({ storage });
previousSession.editedScene({
title: "recovered",
updatedAt: "2026-02-02T00:00:00.000Z",
});
previousSession.dispose();
const reopened = createHarness({ storage });
const candidate = reopened.coord.restoreCandidate();
expect(candidate?.scene.title).toBe("recovered");
expect(candidate).not.toBeNull();
if (!candidate) return;
expect(reopened.coord.explicitRestore(candidate)).toBe(true);
expect(reopened.coord.getSnapshot()).toMatchObject({
status: "dirty",
revision: candidate.revision,
confirmedRevision: 0,
draftStored: true,
});
expect(await reopened.coord.flushLatest()).toBe(true);
expect(reopened.requests).toHaveLength(1);
expect(reopened.requests[0]?.scene.title).toBe("recovered");
expect(reopened.requests[0]?.revision).toBe(candidate.revision);
expect(reopened.coord.getSnapshot()).toMatchObject({
status: "saved",
confirmedRevision: candidate.revision,
});
expect(reopened.readEnvelope()).toBeNull();
});
});
describe("33 status subscription and retry", () => {
it("should publish the full dirty/saving/error/saved sequence across a failed then retried save", async () => {
const h = createHarness();
const seen: string[] = [];
h.coord.onStatusChange((status) => {
seen.push(status);
});
let failNext = true;
h.setFlushBehavior(async () => {
if (failNext) throw new Error("remote rejected");
});
h.editedScene({ title: "retry-me" });
expect(seen).toEqual(["dirty"]);
expect(await h.coord.flushLatest()).toBe(false);
expect(seen).toEqual(["dirty", "saving", "error"]);
expect(h.coord.getSnapshot().draftStored).toBe(true);
failNext = false;
expect(await h.coord.retry()).toBe(true);
expect(seen).toEqual(["dirty", "saving", "error", "saving", "saved"]);
expect(h.requests).toHaveLength(2);
expect(h.requests[1]?.scene.title).toBe("retry-me");
expect(h.coord.getSnapshot()).toMatchObject({
status: "saved",
confirmedRevision: 1,
draftStored: false,
});
expect(h.readEnvelope()).toBeNull();
});
it("should stop publishing to a listener that unsubscribed mid-sequence", async () => {
const h = createHarness();
const seen: string[] = [];
const unsubscribe = h.coord.onStatusChange((status) => {
seen.push(status);
});
h.editedScene({ title: "unsub" });
expect(seen).toEqual(["dirty"]);
unsubscribe();
expect(await h.coord.flushLatest()).toBe(true);
expect(seen).toEqual(["dirty"]);
expect(h.coord.getSnapshot().status).toBe("saved");
});
});
describe("34 close decisions", () => {
it("should return close without any request when nothing is dirty", async () => {
const h = createHarness();
expect(await h.coord.prepareClose()).toBe("close");
expect(h.requests).toEqual([]);
});
it("should reuse one close promise while a slow save is in flight", async () => {
const h = createHarness();
const gate = deferred();
h.setFlushBehavior(async () => {
await gate.promise;
});
h.editedScene({ title: "slow-close" });
const first = h.coord.prepareClose();
const second = h.coord.prepareClose();
expect(first).toBe(second);
expect(h.requests).toHaveLength(1);
gate.resolve();
expect(await first).toBe("close");
expect(h.coord.getSnapshot().confirmedRevision).toBe(1);
});
it("should offer draft exit when the remote fails but the draft survives", async () => {
const h = createHarness();
h.editedScene({ title: "draft-exit" });
h.setFlushBehavior(async () => {
throw new Error("remote rejected");
});
expect(await h.coord.prepareClose()).toBe("offer-draft-exit");
expect(h.readEnvelope()?.scene.title).toBe("draft-exit");
// 放弃草稿后再次关闭仍要如实回报失败,不能因为草稿消失就假装可以安全退出。
expect(h.coord.discardDraft()).toBe(true);
expect(await h.coord.prepareClose()).toBe("stay");
});
it("should stay when neither the remote nor the local draft succeeded", async () => {
const h = createHarness();
h.storage.throwOnSet = true;
h.setFlushBehavior(async () => {
throw new Error("remote rejected");
});
h.editedScene({ title: "no-escape" });
expect(await h.coord.prepareClose()).toBe("stay");
expect(h.coord.getSnapshot().draftStored).toBe(false);
});
it("should stay after dispose instead of reporting a clean close", async () => {
const h = createHarness();
h.editedScene({ title: "disposed-close" });
h.coord.dispose();
expect(await h.coord.prepareClose()).toBe("stay");
});
});
describe("35 snapshot visible inside the status callback", () => {
it("should expose a fully confirmed, draft-free snapshot when saved fires", async () => {
const h = createHarness();
const observed: Array<{ status: string; revision: number; confirmedRevision: number; draftStored: boolean }> = [];
h.coord.onStatusChange((status) => {
// 订阅方只拿到 status 是不够的:必须能在回调内读到完整的新快照。
const snap = h.coord.getSnapshot();
observed.push({
status,
revision: snap.revision,
confirmedRevision: snap.confirmedRevision,
draftStored: snap.draftStored,
});
});
h.editedScene({ title: "observe-me" });
expect(await h.coord.flushLatest()).toBe(true);
expect(observed.map((entry) => entry.status)).toEqual(["dirty", "saving", "saved"]);
const saved = observed.at(-1);
expect(saved?.confirmedRevision).toBe(1);
expect(saved?.revision).toBe(1);
expect(saved?.confirmedRevision).toBe(saved?.revision);
expect(saved?.draftStored).toBe(false);
});
it("should expose the retained draft and unconfirmed revision when error fires", async () => {
const h = createHarness();
let seen: { confirmedRevision: number; revision: number; draftStored: boolean } | null = null;
h.setFlushBehavior(async () => {
throw new Error("remote rejected");
});
h.coord.onStatusChange((status) => {
if (status !== "error") return;
const snap = h.coord.getSnapshot();
seen = {
confirmedRevision: snap.confirmedRevision,
revision: snap.revision,
draftStored: snap.draftStored,
};
});
h.editedScene({ title: "error-observe" });
expect(await h.coord.flushLatest()).toBe(false);
expect(seen).not.toBeNull();
expect(seen?.revision).toBe(1);
expect(seen?.confirmedRevision).toBe(0);
expect(seen?.draftStored).toBe(true);
});
it("should reach a saved snapshot through the debounced schedule, not just explicit flush", async () => {
const h = createHarness();
let savedSnapshot: { confirmedRevision: number; revision: number; draftStored: boolean } | null = null;
h.coord.onStatusChange((status) => {
if (status !== "saved") return;
const snap = h.coord.getSnapshot();
savedSnapshot = {
confirmedRevision: snap.confirmedRevision,
revision: snap.revision,
draftStored: snap.draftStored,
};
});
h.editedScene({ title: "debounced" });
expect(h.scheduler.size()).toBe(1);
// 自动 debounce 保存完成后,订阅方看到的必须已经是确认态。
await h.flushMicrotasks();
expect(h.requests).toHaveLength(1);
expect(savedSnapshot).not.toBeNull();
expect(savedSnapshot?.confirmedRevision).toBe(1);
expect(savedSnapshot?.revision).toBe(1);
expect(savedSnapshot?.draftStored).toBe(false);
expect(h.readEnvelope()).toBeNull();
});
});
});

View File

@@ -1,264 +0,0 @@
import { describe, expect, it } from "bun:test";
import type { PrevisScene } from "../src/types/previs";
import { createPrevisSaveCoordinator, type PrevisDraftEnvelope, type PrevisSaveSnapshot } from "../src/lib/canvas/previs/previs-save";
import {
describePrevisSaveStatus,
previsSaveProgress,
idlePrevisSaveProgress,
resolvePrevisCloseOutcome,
resolvePrevisDraftStorage,
shouldBlockPrevisUnload,
shouldOfferPrevisDraftRecovery,
unavailablePrevisDraftStorage,
} from "../src/lib/canvas/previs/previs-save-wiring";
const makeScene = (overrides: Partial<PrevisScene> = {}): PrevisScene => ({
id: "scene-1",
version: 1,
title: "Test Scene",
background: "#000",
environmentIntensity: 0.5,
gridVisible: true,
objects: [],
cameras: [],
lights: [],
shots: [],
activeShotId: "shot-1",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
...overrides,
});
const makeSnapshot = (overrides: Partial<PrevisSaveSnapshot> = {}): PrevisSaveSnapshot => ({
scene: makeScene(),
revision: 0,
confirmedRevision: 0,
status: "saved",
draftStored: false,
...overrides,
});
const makeEnvelope = (overrides: Partial<PrevisDraftEnvelope> = {}): PrevisDraftEnvelope => {
const scene = overrides.scene ?? makeScene();
return {
schemaVersion: 1,
sceneId: scene.id,
baseUpdatedAt: "2025-01-01T00:00:00.000Z",
revision: 1,
savedAt: 1000,
...overrides,
scene,
};
};
describe("describePrevisSaveStatus", () => {
it("should report a quiet idle state only when everything is confirmed", () => {
const indicator = describePrevisSaveStatus(makeSnapshot({ status: "saved", revision: 3, confirmedRevision: 3 }));
expect(indicator).toEqual({ label: "已保存", tone: "idle", retryable: false, busy: false });
});
it("should stay retryable when the status is saved but a revision is unconfirmed", () => {
const indicator = describePrevisSaveStatus(makeSnapshot({ status: "saved", revision: 4, confirmedRevision: 3 }));
expect(indicator.label).toBe("有未保存修改");
expect(indicator.tone).toBe("pending");
expect(indicator.retryable).toBe(true);
});
it("should mark saving as busy and not retryable", () => {
const indicator = describePrevisSaveStatus(makeSnapshot({ status: "saving", revision: 2, confirmedRevision: 1 }));
expect(indicator).toEqual({ label: "正在保存…", tone: "pending", retryable: false, busy: true });
});
it("should distinguish an error with a safe draft from one without", () => {
const withDraft = describePrevisSaveStatus(makeSnapshot({ status: "error", revision: 2, confirmedRevision: 1, draftStored: true }));
const withoutDraft = describePrevisSaveStatus(makeSnapshot({ status: "error", revision: 2, confirmedRevision: 1, draftStored: false }));
expect(withDraft.label).toBe("保存失败 · 本地草稿已保留");
expect(withoutDraft.label).toBe("保存失败 · 本地草稿也未写入");
expect(withDraft.tone).toBe("danger");
expect(withoutDraft.tone).toBe("danger");
expect(withDraft.retryable).toBe(true);
expect(withoutDraft.retryable).toBe(true);
});
it("should surface dirty as pending and retryable", () => {
const indicator = describePrevisSaveStatus(makeSnapshot({ status: "dirty", revision: 1, confirmedRevision: 0, draftStored: true }));
expect(indicator.label).toBe("有未保存修改");
expect(indicator.retryable).toBe(true);
expect(indicator.busy).toBe(false);
});
});
describe("shouldOfferPrevisDraftRecovery", () => {
it("should offer recovery when the draft is newer than the authoritative scene", () => {
const authoritativeScene = makeScene({ updatedAt: "2025-01-01T00:00:00.000Z" });
const candidate = makeEnvelope({
baseUpdatedAt: "2025-01-01T00:00:00.000Z",
scene: makeScene({ updatedAt: "2025-06-01T00:00:00.000Z" }),
});
expect(shouldOfferPrevisDraftRecovery({ candidate, authoritativeScene })).toBe(true);
});
it("should not offer recovery when the project already holds that content", () => {
const authoritativeScene = makeScene({ updatedAt: "2025-06-01T00:00:00.000Z" });
const candidate = makeEnvelope({
baseUpdatedAt: "2025-06-01T00:00:00.000Z",
scene: makeScene({ updatedAt: "2025-06-01T00:00:00.000Z" }),
});
expect(shouldOfferPrevisDraftRecovery({ candidate, authoritativeScene })).toBe(false);
});
it("should not offer a stale draft whose base fell behind the authoritative scene", () => {
const authoritativeScene = makeScene({ updatedAt: "2025-09-01T00:00:00.000Z" });
const candidate = makeEnvelope({
baseUpdatedAt: "2025-01-01T00:00:00.000Z",
scene: makeScene({ updatedAt: "2025-06-01T00:00:00.000Z" }),
});
expect(shouldOfferPrevisDraftRecovery({ candidate, authoritativeScene })).toBe(false);
});
it("should reject a candidate belonging to another scene", () => {
const authoritativeScene = makeScene({ id: "scene-1" });
const candidate = makeEnvelope({
sceneId: "scene-2",
scene: makeScene({ id: "scene-2", updatedAt: "2025-06-01T00:00:00.000Z" }),
});
expect(shouldOfferPrevisDraftRecovery({ candidate, authoritativeScene })).toBe(false);
});
it("should never prompt without both a candidate and an authoritative scene", () => {
expect(shouldOfferPrevisDraftRecovery({ candidate: null, authoritativeScene: makeScene() })).toBe(false);
expect(shouldOfferPrevisDraftRecovery({ candidate: makeEnvelope(), authoritativeScene: null })).toBe(false);
});
});
describe("resolvePrevisCloseOutcome", () => {
it("should close outright on a clean decision", () => {
expect(resolvePrevisCloseOutcome("close")).toEqual({ kind: "close" });
});
it("should require confirmation and name the safe draft on offer-draft-exit", () => {
const outcome = resolvePrevisCloseOutcome("offer-draft-exit");
expect(outcome.kind).toBe("confirm-draft-exit");
if (outcome.kind !== "confirm-draft-exit") return;
expect(outcome.message).toContain("远端保存失败");
expect(outcome.message).toContain("本地草稿");
});
it("should block the exit and explain why on stay", () => {
const outcome = resolvePrevisCloseOutcome("stay");
expect(outcome.kind).toBe("blocked");
if (outcome.kind !== "blocked") return;
expect(outcome.message).toContain("重试");
});
});
describe("shouldBlockPrevisUnload", () => {
it("should block while any revision is unconfirmed", () => {
expect(shouldBlockPrevisUnload(makeSnapshot({ revision: 2, confirmedRevision: 1 }))).toBe(true);
});
it("should block an error state that has no safe draft", () => {
expect(shouldBlockPrevisUnload(makeSnapshot({ status: "error", revision: 1, confirmedRevision: 1, draftStored: false }))).toBe(true);
});
it("should let an error state with a safe draft through", () => {
expect(shouldBlockPrevisUnload(makeSnapshot({ status: "error", revision: 1, confirmedRevision: 1, draftStored: true }))).toBe(false);
});
it("should not block a fully confirmed session", () => {
expect(shouldBlockPrevisUnload(makeSnapshot({ revision: 5, confirmedRevision: 5 }))).toBe(false);
});
});
describe("previsSaveProgress", () => {
it("should project every progress field and drop the scene", () => {
const progress = previsSaveProgress(makeSnapshot({ status: "saving", revision: 4, confirmedRevision: 2, draftStored: true }));
expect(progress).toEqual({
status: "saving",
revision: 4,
confirmedRevision: 2,
draftStored: true,
});
expect("scene" in progress).toBe(false);
});
it("should start idle with nothing unconfirmed and no draft", () => {
expect(idlePrevisSaveProgress).toEqual({
status: "saved",
revision: 0,
confirmedRevision: 0,
draftStored: false,
});
expect(describePrevisSaveStatus(idlePrevisSaveProgress).retryable).toBe(false);
expect(shouldBlockPrevisUnload(idlePrevisSaveProgress)).toBe(false);
});
});
describe("resolvePrevisDraftStorage", () => {
it("should delegate to the provided storage when it is usable", () => {
const values = new Map<string, string>();
const storage = resolvePrevisDraftStorage(
() =>
({
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value);
},
removeItem: (key: string) => {
values.delete(key);
},
}) as unknown as Storage,
);
storage.setItem("draft", "payload");
expect(storage.getItem("draft")).toBe("payload");
storage.removeItem("draft");
expect(storage.getItem("draft")).toBeNull();
});
it("should fall back to the unavailable implementation when the getter throws", () => {
const storage = resolvePrevisDraftStorage(() => {
throw new Error("localStorage blocked");
});
expect(storage).toBe(unavailablePrevisDraftStorage);
});
it("should fall back to the unavailable implementation when there is no storage", () => {
expect(resolvePrevisDraftStorage(() => null)).toBe(unavailablePrevisDraftStorage);
expect(resolvePrevisDraftStorage(() => undefined)).toBe(unavailablePrevisDraftStorage);
});
});
describe("unavailablePrevisDraftStorage", () => {
it("should read empty and refuse to write, so no draft is ever claimed as safe", () => {
expect(unavailablePrevisDraftStorage.getItem("draft")).toBeNull();
expect(() => unavailablePrevisDraftStorage.setItem("draft", "payload")).toThrow();
expect(() => unavailablePrevisDraftStorage.removeItem("draft")).toThrow();
});
it("should drive the coordinator to draftStored false and a stay decision", async () => {
const coordinator = createPrevisSaveCoordinator({
initialScene: makeScene(),
scope: "user-a",
storage: unavailablePrevisDraftStorage,
flush: async () => {
throw new Error("remote rejected");
},
schedule: () => 0,
cancelSchedule: () => undefined,
});
expect(coordinator.edit(makeScene({ title: "no-draft" }))).toBe(true);
expect(coordinator.getSnapshot().draftStored).toBe(false);
expect(coordinator.restoreCandidate()).toBeNull();
// 没有安全草稿时必须留在工作台,不能谎称「下次可恢复」。
expect(await coordinator.prepareClose()).toBe("stay");
});
});