Files
ttstd-infinite-canvas/web/test/director-save-wiring.test.ts
echoD886 ad0775605d fix(canvas): 3D 导演台 - 完成 Issue #305 P0 稳定性治理 (#324)
* fix(canvas): 3D 导演台 - 修复状态回弹与保存恢复生命周期

* style(canvas): 3D 导演台 - 对齐 CI 格式门禁
2026-08-27 10:32:45 +08:00

265 lines
11 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import type { DirectorScene } from "../src/types/director";
import { createDirectorSaveCoordinator, type DirectorDraftEnvelope, type DirectorSaveSnapshot } from "../src/lib/canvas/director/director-save";
import {
describeDirectorSaveStatus,
directorSaveProgress,
idleDirectorSaveProgress,
resolveDirectorCloseOutcome,
resolveDirectorDraftStorage,
shouldBlockDirectorUnload,
shouldOfferDirectorDraftRecovery,
unavailableDirectorDraftStorage,
} from "../src/lib/canvas/director/director-save-wiring";
const makeScene = (overrides: Partial<DirectorScene> = {}): DirectorScene => ({
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<DirectorSaveSnapshot> = {}): DirectorSaveSnapshot => ({
scene: makeScene(),
revision: 0,
confirmedRevision: 0,
status: "saved",
draftStored: false,
...overrides,
});
const makeEnvelope = (overrides: Partial<DirectorDraftEnvelope> = {}): DirectorDraftEnvelope => {
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("describeDirectorSaveStatus", () => {
it("should report a quiet idle state only when everything is confirmed", () => {
const indicator = describeDirectorSaveStatus(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 = describeDirectorSaveStatus(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 = describeDirectorSaveStatus(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 = describeDirectorSaveStatus(makeSnapshot({ status: "error", revision: 2, confirmedRevision: 1, draftStored: true }));
const withoutDraft = describeDirectorSaveStatus(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 = describeDirectorSaveStatus(makeSnapshot({ status: "dirty", revision: 1, confirmedRevision: 0, draftStored: true }));
expect(indicator.label).toBe("有未保存修改");
expect(indicator.retryable).toBe(true);
expect(indicator.busy).toBe(false);
});
});
describe("shouldOfferDirectorDraftRecovery", () => {
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(shouldOfferDirectorDraftRecovery({ 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(shouldOfferDirectorDraftRecovery({ 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(shouldOfferDirectorDraftRecovery({ 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(shouldOfferDirectorDraftRecovery({ candidate, authoritativeScene })).toBe(false);
});
it("should never prompt without both a candidate and an authoritative scene", () => {
expect(shouldOfferDirectorDraftRecovery({ candidate: null, authoritativeScene: makeScene() })).toBe(false);
expect(shouldOfferDirectorDraftRecovery({ candidate: makeEnvelope(), authoritativeScene: null })).toBe(false);
});
});
describe("resolveDirectorCloseOutcome", () => {
it("should close outright on a clean decision", () => {
expect(resolveDirectorCloseOutcome("close")).toEqual({ kind: "close" });
});
it("should require confirmation and name the safe draft on offer-draft-exit", () => {
const outcome = resolveDirectorCloseOutcome("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 = resolveDirectorCloseOutcome("stay");
expect(outcome.kind).toBe("blocked");
if (outcome.kind !== "blocked") return;
expect(outcome.message).toContain("重试");
});
});
describe("shouldBlockDirectorUnload", () => {
it("should block while any revision is unconfirmed", () => {
expect(shouldBlockDirectorUnload(makeSnapshot({ revision: 2, confirmedRevision: 1 }))).toBe(true);
});
it("should block an error state that has no safe draft", () => {
expect(shouldBlockDirectorUnload(makeSnapshot({ status: "error", revision: 1, confirmedRevision: 1, draftStored: false }))).toBe(true);
});
it("should let an error state with a safe draft through", () => {
expect(shouldBlockDirectorUnload(makeSnapshot({ status: "error", revision: 1, confirmedRevision: 1, draftStored: true }))).toBe(false);
});
it("should not block a fully confirmed session", () => {
expect(shouldBlockDirectorUnload(makeSnapshot({ revision: 5, confirmedRevision: 5 }))).toBe(false);
});
});
describe("directorSaveProgress", () => {
it("should project every progress field and drop the scene", () => {
const progress = directorSaveProgress(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(idleDirectorSaveProgress).toEqual({
status: "saved",
revision: 0,
confirmedRevision: 0,
draftStored: false,
});
expect(describeDirectorSaveStatus(idleDirectorSaveProgress).retryable).toBe(false);
expect(shouldBlockDirectorUnload(idleDirectorSaveProgress)).toBe(false);
});
});
describe("resolveDirectorDraftStorage", () => {
it("should delegate to the provided storage when it is usable", () => {
const values = new Map<string, string>();
const storage = resolveDirectorDraftStorage(
() =>
({
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 = resolveDirectorDraftStorage(() => {
throw new Error("localStorage blocked");
});
expect(storage).toBe(unavailableDirectorDraftStorage);
});
it("should fall back to the unavailable implementation when there is no storage", () => {
expect(resolveDirectorDraftStorage(() => null)).toBe(unavailableDirectorDraftStorage);
expect(resolveDirectorDraftStorage(() => undefined)).toBe(unavailableDirectorDraftStorage);
});
});
describe("unavailableDirectorDraftStorage", () => {
it("should read empty and refuse to write, so no draft is ever claimed as safe", () => {
expect(unavailableDirectorDraftStorage.getItem("draft")).toBeNull();
expect(() => unavailableDirectorDraftStorage.setItem("draft", "payload")).toThrow();
expect(() => unavailableDirectorDraftStorage.removeItem("draft")).toThrow();
});
it("should drive the coordinator to draftStored false and a stay decision", async () => {
const coordinator = createDirectorSaveCoordinator({
initialScene: makeScene(),
scope: "user-a",
storage: unavailableDirectorDraftStorage,
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");
});
});