Files
ttstd-infinite-canvas/web/test/director-session-authority.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

203 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from "bun:test";
import { createDirectorTransaction } from "../src/lib/canvas/director/director-gesture-transaction";
import { shouldReinitializeDirectorSession, upsertDirectorSceneById } from "../src/lib/canvas/director/director-session";
import { createDirectorScene } from "../src/lib/canvas/director/director-scene";
import type { DirectorScene } from "../src/types/director";
function scene(id: string, title = id): DirectorScene {
return { ...createDirectorScene(title), id };
}
describe("会话初始化判定:只认 scene id(A1 回归)", () => {
test("首次挂载会初始化", () => {
expect(shouldReinitializeDirectorSession({ initializedSceneId: null, nextSceneId: "s1" })).toBe(true);
});
test("同 id 的父级镜像回流不重建会话", () => {
expect(shouldReinitializeDirectorSession({ initializedSceneId: "s1", nextSceneId: "s1" })).toBe(false);
});
test("切换到不同 scene id 才重建", () => {
expect(shouldReinitializeDirectorSession({ initializedSceneId: "s1", nextSceneId: "s2" })).toBe(true);
});
test("没有 scene 时不初始化", () => {
expect(shouldReinitializeDirectorSession({ initializedSceneId: "s1", nextSceneId: null })).toBe(false);
expect(shouldReinitializeDirectorSession({ initializedSceneId: null, nextSceneId: null })).toBe(false);
});
test("镜像回流序列中只有 id 变化的那一次会重建", () => {
let initialized: string | null = null;
const rebuilds: string[] = [];
["s1", "s1", "s1", "s2", "s2"].forEach((nextSceneId) => {
if (!shouldReinitializeDirectorSession({ initializedSceneId: initialized, nextSceneId })) return;
initialized = nextSceneId;
rebuilds.push(nextSceneId);
});
expect(rebuilds).toEqual(["s1", "s2"]);
});
});
describe("upsertDirectorSceneById:连续保存不丢 scene(A6 回归)", () => {
test("连续保存 A/B 后两者都在", () => {
const a = scene("a");
const b = scene("b");
const afterA = upsertDirectorSceneById([], a);
const afterB = upsertDirectorSceneById(afterA, b);
expect(afterB.map((item) => item.id)).toEqual(["a", "b"]);
});
test("同 id 是替换而不是追加", () => {
const first = scene("a", "旧标题");
const second = { ...first, title: "新标题" };
const next = upsertDirectorSceneById([first], second);
expect(next).toHaveLength(1);
expect(next[0].title).toBe("新标题");
});
test("替换时不影响其他 scene 的顺序与内容", () => {
const list = [scene("a"), scene("b"), scene("c")];
const next = upsertDirectorSceneById(list, { ...list[1], title: "改过的 b" });
expect(next.map((item) => item.id)).toEqual(["a", "b", "c"]);
expect(next[0]).toBe(list[0]);
expect(next[2]).toBe(list[2]);
expect(next[1].title).toBe("改过的 b");
});
test("以最新数组为基准的连续保存不会用旧快照覆盖", () => {
// 模拟缺陷:第二次保存若以「第一次之前的数组」为基准,就会丢掉 a。
const a = scene("a");
const b = scene("b");
const stale: DirectorScene[] = [];
const authoritative = upsertDirectorSceneById(stale, a);
expect(upsertDirectorSceneById(stale, b).map((item) => item.id)).toEqual(["b"]);
expect(upsertDirectorSceneById(authoritative, b).map((item) => item.id)).toEqual(["a", "b"]);
});
});
describe("预览与持久变更的发布边界(A2/A3/A4 回归)", () => {
/** 最小 session controller:与 workbench 的接线同构,但不依赖 React 挂载。 */
function harness(initial: DirectorScene) {
const published: DirectorScene[] = [];
const history: DirectorScene[] = [];
const flushes: number[] = [];
let draft = initial;
const publish = () => published.push(draft);
const staged = createDirectorTransaction<DirectorScene>({
read: () => draft,
restore: (snapshot) => {
draft = snapshot;
},
commit: (from) => {
history.push(from);
publish();
},
setActive: () => undefined,
});
return {
published,
history,
current: () => draft,
/** 普通持久提交:先终结暂存手势,再发布。 */
commit: (next: DirectorScene) => {
staged.end("commit");
history.push(draft);
draft = next;
publish();
},
/** 进行中预览:写草稿但不发布。 */
stage: (next: DirectorScene) => {
staged.begin();
draft = next;
},
settle: () => staged.end("commit"),
cancel: () => staged.end("cancel"),
close: () => {
staged.end("cancel");
publish();
},
/** pagehide:active 由 commit 自己 publish,idle 才显式 publish,最后 flush。 */
pagehide: () => {
if (staged.active()) staged.end("commit");
else publish();
flushes.push(published.length);
},
flushes,
};
}
test("进行中预览不发布,成功终态只发布一次", () => {
const base = scene("s1", "起点");
const session = harness(base);
session.stage({ ...base, title: "拖动中 1" });
session.stage({ ...base, title: "拖动中 2" });
expect(session.published).toHaveLength(0);
session.settle();
expect(session.published).toHaveLength(1);
expect(session.published[0].title).toBe("拖动中 2");
});
test("取消的预览值绝不进入已发布 canonical", () => {
const base = scene("s1", "起点");
const session = harness(base);
session.stage({ ...base, title: "会被取消" });
session.cancel();
expect(session.published).toHaveLength(0);
expect(session.current().title).toBe("起点");
// 后续关闭发布的是恢复后的值。
session.close();
expect(session.published.map((item) => item.title)).toEqual(["起点"]);
});
test("关闭前先取消未结束预览,再发布恢复后的 draft", () => {
const base = scene("s1", "起点");
const session = harness(base);
session.stage({ ...base, title: "未结束" });
session.close();
expect(session.published).toHaveLength(1);
expect(session.published[0].title).toBe("起点");
});
test("普通提交前终结暂存手势,两段变更各自发布且历史不串", () => {
const base = scene("s1", "起点");
const session = harness(base);
session.stage({ ...base, title: "滑杆中" });
session.commit({ ...base, title: "另一个动作" });
expect(session.published.map((item) => item.title)).toEqual(["滑杆中", "另一个动作"]);
expect(session.history).toHaveLength(2);
});
test("离开页面且预览进行中:只发布一次(#6 回归)", () => {
const base = scene("s1", "起点");
const session = harness(base);
session.stage({ ...base, title: "离开前未结算" });
session.pagehide();
// 缺陷版本会 commit publish 一次 + 无条件 publishDraft 再一次。
expect(session.published).toHaveLength(1);
expect(session.published[0].title).toBe("离开前未结算");
// flush 必须发生在发布之后,否则会把旧快照写盘。
expect(session.flushes).toEqual([1]);
});
test("离开页面且无进行中预览:显式发布一次", () => {
const base = scene("s1", "起点");
const session = harness(base);
session.pagehide();
expect(session.published).toHaveLength(1);
expect(session.flushes).toEqual([1]);
});
test("离开页面后再次 pagehide 不会重复结算同一手势", () => {
const base = scene("s1", "起点");
const session = harness(base);
session.stage({ ...base, title: "一次拖动" });
session.pagehide();
session.pagehide();
// 第二次是 idle 分支:显式发布当前 canonical,仍然各自恰好一次。
expect(session.published.map((item) => item.title)).toEqual(["一次拖动", "一次拖动"]);
expect(session.history).toHaveLength(1);
expect(session.flushes).toEqual([1, 2]);
});
});