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