feat(canvas): 3D 导演台 - 补齐非 P0 分层交互与回归保护 (#333)

* feat(canvas): 3D 导演台 - 补齐分层交互与回归保护

* fix(canvas): 3D 导演台 - 过滤非法轨迹关键帧

* chore(canvas): 3D 导演台 - 对齐前端格式规范

* fix(canvas): 3D 导演台 - 保证故障重试入口可点击

* test(canvas): 3D 导演台 - 稳定真实点击验证
This commit is contained in:
echoD886
2026-08-27 20:47:10 +08:00
committed by GitHub
parent c76f378811
commit 0e33cc805e
34 changed files with 5049 additions and 141 deletions

View File

@@ -0,0 +1,212 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
/**
* 生产接线回归:领域函数正确不代表用户能删到关键帧。
* 这里锁住「时间轴入口 -> workbench -> 领域函数」这条链路真的接上了。
*/
const dock = readFileSync(resolve(import.meta.dir, "../src/components/canvas/director/director-viewport-dock.tsx"), "utf8");
const workbench = readFileSync(resolve(import.meta.dir, "../src/components/canvas/director/canvas-director-workbench.tsx"), "utf8");
const sequencer = readFileSync(resolve(import.meta.dir, "../src/components/canvas/director/director-sequencer.tsx"), "utf8");
const styles = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8");
function slice(source: string, from: string, to: string) {
const start = source.indexOf(from);
expect(start).toBeGreaterThanOrEqual(0);
const end = source.indexOf(to, start + from.length);
expect(end).toBeGreaterThan(start);
return source.slice(start, end);
}
describe("workbench 接入时间轴关键帧删除", () => {
test("DirectorSequencer 拿到的是 deleteKeyframe,而不是空实现", () => {
const element = slice(workbench, "<DirectorSequencer", "/>");
expect(element).toContain("onDeleteKeyframe={deleteKeyframe}");
expect(element).not.toContain("onDeleteKeyframe={() =>");
});
test("缓动更新通过 workbench commit 接入,未命中不制造历史", () => {
const element = slice(workbench, "<DirectorSequencer", "/>");
expect(element).toContain("onSetKeyframeEasing={setKeyframeEasing}");
const handler = slice(workbench, "const setKeyframeEasing = useCallback", "* 快捷键执行器");
expect(handler).toContain("setDirectorSceneKeyframeEasing(current, target, easing) === current");
expect(handler).toContain("commit((scene) => setDirectorSceneKeyframeEasing(scene, target, easing));");
});
test("deleteKeyframe 未命中时不进 commit:不记历史、不产生修订", () => {
const handler = slice(workbench, "const deleteKeyframe = useCallback", "* 快捷键执行器");
expect(handler).toContain("removeDirectorSceneKeyframe(current, target) === current) return;");
expect(handler).toContain("commit((scene) => removeDirectorSceneKeyframe(scene, target));");
});
test("删除走 commit,因此进历史、可撤销、并触发 canonical 保存", () => {
// commit 自身的语义:入历史 + writeAndPublish(coordinator 保存 + 镜像项目)。
const commit = slice(workbench, "const commit = useCallback", "/** 暂存型手势");
expect(commit).toContain("setHistory((items) => [...items.slice(-49), structuredClone(current)]);");
expect(commit).toContain("writeAndPublish(touchDirectorScene(updater(current)));");
});
});
describe("workbench 快捷键接线", () => {
const effect = slice(workbench, "// 导演台是全屏浮层,快捷键挂在 window", "/** 对象 transform 编辑的唯一入口");
test("监听挂在 window keydown,并随 open 装卸", () => {
expect(effect).toContain("if (!open) return;");
expect(effect).toContain('window.addEventListener("keydown", onKeyDown);');
expect(effect).toContain('window.removeEventListener("keydown", onKeyDown);');
});
test("交互控件语境由 blocksDirectorShortcut 判定后交给解析器", () => {
expect(effect).toContain("isInteractiveTarget: blocksDirectorShortcut(event.target),");
// 旧的 text-only 判定不得残留:它放过 BUTTON/A/role 型控件。
expect(workbench).not.toContain("isDirectorTextEntryTarget");
});
test("只有动作真的执行了才 preventDefault", () => {
expect(effect).toContain("if (runShortcutRef.current(action)) event.preventDefault();");
// 不得无条件吞掉按键。
expect(effect).not.toContain("event.preventDefault();\n if (!action)");
});
test("每个动作都落到已存在的真实执行路径", () => {
const runner = slice(workbench, "const runShortcut = (action: DirectorShortcutAction)", "const runShortcutRef");
expect(runner).toContain("setTransformMode(action.mode);");
expect(runner).toContain("removeObject(selectedObject.id);");
expect(runner).toContain("removeLight(selectedLight.id);");
expect(runner).toContain("updateObject(selectedObject.id, { visible: !selectedObject.visible });");
expect(runner).toContain("setPlaying(!playing);");
expect(runner).toContain("undo();");
expect(runner).toContain("redo();");
});
test("没有可操作对象/历史时返回 false,把按键交还浏览器", () => {
const runner = slice(workbench, "const runShortcut = (action: DirectorShortcutAction)", "const runShortcutRef");
expect(runner).toContain("if (!history.length) return false;");
expect(runner).toContain("if (!future.length) return false;");
expect(runner).toContain("if (!selectedObject) return false;");
expect(runner).toContain("if (!selectedObjectId && !selectedLightId && !selectedBone) return false;");
});
test("dock 与场景列表点选后释放焦点:否则点完就再按不动 W/E/R/Delete", () => {
// 全局快捷键是本轮新增的,交互控件守卫会吃掉聚焦按钮上的按键。
// dock 承载 W/E/R 变换工具;场景列表承载「点选对象 -> 按 Delete」主流程。
const dockButton = slice(dock, "function DockButton(", "function DockDivider(");
expect(dockButton).toContain("releaseDirectorFocusAfterPointer(event)");
const sceneRow = slice(workbench, "function SceneRow(", "function AddMenuButton(");
expect(sceneRow).toContain("releaseDirectorFocusAfterPointer(event)");
});
test("焦点释放规则集中在共享 helper,各按钮不自写 blur", () => {
// 判据(keyboard detail === 0 保留焦点)必须只有一处,否则迟早走偏。
const dockButton = slice(dock, "function DockButton(", "function DockDivider(");
expect(dockButton).not.toContain("event.currentTarget.blur()");
expect(workbench).not.toContain("if (event.detail !== 0) event.currentTarget.blur();");
});
test("时间轴轨道行点选后也释放焦点:与场景列表同源的 select-then-Delete 流程", () => {
const row = slice(sequencer, "function SequencerRow(", "* 关键帧渲染为真实 button");
expect(row).toContain("releaseDirectorFocusAfterPointer(event)");
});
test("关键帧按钮绝不释放焦点:它自己拥有 Enter/Space/Delete/Backspace", () => {
// 关键帧 blur 掉焦点会让键盘连续删除失效,且与它的 stopPropagation 设计冲突。
const trackKeys = slice(sequencer, "function TrackKeys(", "function TrackBar(");
expect(trackKeys).not.toContain("releaseDirectorFocusAfterPointer");
});
});
describe("时间轴关键帧入口可见、可选择、可键盘删除", () => {
const trackKeys = slice(sequencer, "function TrackKeys(", "function TrackBar(");
test("可编辑关键帧是真实 button,不是惰性 span", () => {
expect(trackKeys).toContain('type="button"');
expect(trackKeys).toContain("director-sequencer-key is-actionable");
expect(trackKeys).toContain('aria-label={`选择 ${key.label ?? "关键帧"} ${key.time.toFixed(2)}s 的关键帧`}');
expect(trackKeys).toContain("aria-pressed=");
});
test("普通点击与 Enter/Space 只选择定位,Delete/Backspace 才删除,且都不冒泡", () => {
expect(trackKeys).toContain('if (!["Enter", " ", "Delete", "Backspace"].includes(event.key)) return;');
expect(trackKeys).toContain("event.preventDefault();");
expect(trackKeys).toContain("event.stopPropagation();");
expect(trackKeys).toContain('if (event.key === "Delete" || event.key === "Backspace") onDeleteKey(target);');
expect(trackKeys).toContain("else onSelectKey?.(key);");
expect(trackKeys).toContain("onSelectKey?.(key);");
});
test("没有 target 的轨道保持只读,不渲染删除按钮", () => {
expect(trackKeys).toContain("const target = onDeleteKey && key.target;");
expect(trackKeys).toContain("if (!target) {");
});
test("命中区扩大且有 focus-visible 焦点环", () => {
expect(styles).toContain(".director-sequencer-key.is-actionable");
expect(styles).toContain(".director-sequencer-key.is-actionable::after");
expect(styles).toContain(".director-sequencer-key.is-actionable:focus-visible");
expect(styles).toContain(".director-sequencer-key.is-actionable.is-selected");
});
test("hover/focus 只用语义 token,不新增硬编码颜色", () => {
const block = slice(styles, "/* 可删除关键帧:", ".director-sequencer-clip {");
expect(block).toContain("outline: var(--stroke-2) solid var(--control-focus-ring);");
expect(block).toContain("color-mix(in srgb, var(--control-focus-ring) 55%, transparent)");
// rgba / 十六进制字面值一律不允许出现在新增块里。
expect(block).not.toMatch(/rgba?\(/);
expect(block).not.toMatch(/#[0-9a-fA-F]{3,8}/);
});
});
describe("三类轨道都有删除入口,概览轨保持只读", () => {
test("摄影机行可删除,Camera Cut 概览轨只读", () => {
const cameraCut = slice(sequencer, 'label="Camera Cut"', "</SequencerRow>");
expect(cameraCut).toContain("<TrackKeys duration={duration} keys={cameraKeys} />");
expect(cameraCut).not.toContain("onDeleteKey");
const cameraRow = slice(sequencer, "{camera ? <SequencerRow label={camera.name}", "</SequencerRow> : null}");
expect(cameraRow).toContain("onDeleteKey={deleteTrackKey}");
});
test("摄影机关键帧带 camera 删除目标", () => {
expect(sequencer).toContain('target: { track: "camera", cameraId: camera.id, keyframeId: key.id }');
});
test("对象 transform 与骨骼帧各自带正确目标", () => {
expect(sequencer).toContain('target: { track: "object-transform" as const, objectId: object.id, keyframeId: key.id }');
expect(sequencer).toContain('target: { track: "object-bone" as const, objectId: object.id, bone: track.bone, keyframeId: key.id }');
});
test("折叠状态下的对象汇总轨也可删除,否则收起后就删不掉", () => {
const summaryRow = slice(sequencer, "<SequencerRow label={object.name} icon={isExpanded", "</SequencerRow>");
expect(summaryRow).toContain("keys={[...transformKeys, ...boneTrackKeys]}");
expect(summaryRow).toContain("onDeleteKey={deleteTrackKey}");
});
test("展开后的 Transform 子轨与骨骼子轨都可删除", () => {
const transformRow = slice(sequencer, 'label="Transform" icon="◇"', "</SequencerRow>");
expect(transformRow).toContain("onDeleteKey={deleteTrackKey}");
const boneRow = slice(sequencer, "label={directorBoneLabel(track.bone)}", "</SequencerRow>");
expect(boneRow).toContain("onDeleteKey={deleteTrackKey}");
});
test("非演员对象(立方体/模型/立牌)的 transform 轨同样可删除", () => {
const plainRow = slice(sequencer, 'icon="□"', "</SequencerRow>");
expect(plainRow).toContain("onDeleteKey={deleteTrackKey}");
expect(sequencer).toContain('target: { track: "object-transform", objectId: object.id, keyframeId: key.id }');
});
test("所选关键帧提供 step/linear/smooth 缓动切换与显式删除按钮", () => {
expect(sequencer).toContain('aria-label="关键帧缓动"');
expect(sequencer).toContain('<option value="step">保持</option>');
expect(sequencer).toContain('<option value="linear">线性</option>');
expect(sequencer).toContain('<option value="smooth">平滑</option>');
expect(sequencer).toContain('aria-label="删除所选关键帧"');
});
test("切换摄影机或外部删帧后清除陈旧选择,顶部控件不能误改不可见轨道", () => {
expect(sequencer).toContain("directorKeyframeTargetExists(selectedKey.target, camera, objects)");
expect(sequencer).toContain("setSelectedKey((current) => current?.target && !directorKeyframeTargetExists(current.target, camera, objects) ? null : current);");
expect(sequencer).toContain("camera?.id === target.cameraId && camera.keyframes.some");
expect(sequencer).toContain("object.boneTracks?.some");
});
});

View File

@@ -0,0 +1,210 @@
import { describe, expect, test } from "bun:test";
import {
DIRECTOR_KEYFRAME_EPSILON,
createDirectorActor,
createDirectorCamera,
createDirectorScene,
interpolateDirectorTransform,
removeDirectorBoneKeyframe,
removeDirectorKeyframe,
removeDirectorSceneKeyframe,
resolveDirectorKeyframeProgress,
setDirectorSceneKeyframeEasing,
upsertDirectorBoneKeyframe,
upsertDirectorKeyframe,
} from "../src/lib/canvas/director/director-scene";
import type { DirectorKeyframe, DirectorQuat, DirectorScene, DirectorTransform } from "../src/types/director";
const transformAt = (x: number): DirectorTransform => ({ position: [x, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] });
function seededKeyframes(): DirectorKeyframe[] {
let keys: DirectorKeyframe[] = [];
keys = upsertDirectorKeyframe(keys, 0, transformAt(0));
keys = upsertDirectorKeyframe(keys, 1, transformAt(10));
keys = upsertDirectorKeyframe(keys, 2, transformAt(20));
return keys;
}
describe("删除 transform 关键帧", () => {
test("按 id 删除只移除目标帧,其余保持有序", () => {
const keys = seededKeyframes();
const next = removeDirectorKeyframe(keys, keys[1].id);
expect(next).toHaveLength(2);
expect(next.map((k) => k.time)).toEqual([0, 2]);
// 纯函数:不得改写入参。
expect(keys).toHaveLength(3);
});
test("id 不存在时返回原引用,避免无意义重渲染", () => {
const keys = seededKeyframes();
expect(removeDirectorKeyframe(keys, "missing")).toBe(keys);
});
test("删空后得到空数组,插值回落到 base", () => {
let keys = upsertDirectorKeyframe([], 1, transformAt(5));
keys = removeDirectorKeyframe(keys, keys[0].id);
expect(keys).toEqual([]);
expect(interpolateDirectorTransform(transformAt(99), keys, 1).position[0]).toBe(99);
});
test("upsert 使用同一容差,记录下的帧一定能按 id 删除", () => {
// 非整帧时间点最容易出现「记录能覆盖但删不掉」,这里锁住判据一致。
let keys = upsertDirectorKeyframe([], 0.75, transformAt(3));
keys = upsertDirectorKeyframe(keys, 0.75 + DIRECTOR_KEYFRAME_EPSILON / 2, transformAt(4));
expect(keys).toHaveLength(1);
expect(removeDirectorKeyframe(keys, keys[0].id)).toEqual([]);
});
});
describe("关键帧缓动", () => {
test("linear / step / smooth 产生不同且确定的区间进度", () => {
expect(resolveDirectorKeyframeProgress(0.25, "linear")).toBe(0.25);
expect(resolveDirectorKeyframeProgress(0.25, "step")).toBe(0);
expect(resolveDirectorKeyframeProgress(0.25, "smooth")).toBeCloseTo(0.15625, 8);
expect(resolveDirectorKeyframeProgress(-1, "linear")).toBe(0);
expect(resolveDirectorKeyframeProgress(2, "linear")).toBe(1);
});
test("插值读取前一枚关键帧的 easing,旧数据默认线性", () => {
const base = seededKeyframes().slice(0, 2);
expect(interpolateDirectorTransform(transformAt(0), base, 0.25).position[0]).toBeCloseTo(2.5, 8);
expect(interpolateDirectorTransform(transformAt(0), [{ ...base[0], easing: "step" }, base[1]], 0.25).position[0]).toBe(0);
expect(interpolateDirectorTransform(transformAt(0), [{ ...base[0], easing: "smooth" }, base[1]], 0.25).position[0]).toBeCloseTo(1.5625, 8);
expect(interpolateDirectorTransform(transformAt(0), [{ ...base[0], easing: "step" }, base[1]], 1).position[0]).toBe(10);
});
});
describe("删除骨骼关键帧", () => {
const rotation: DirectorQuat = [0, 0, 0, 1];
test("删除后轨道保留其余帧", () => {
let tracks = upsertDirectorBoneKeyframe([], "hips", 0, rotation);
tracks = upsertDirectorBoneKeyframe(tracks, "hips", 1, rotation);
const target = tracks[0].keyframes[0].id;
const next = removeDirectorBoneKeyframe(tracks, "hips", target);
expect(next).toHaveLength(1);
expect(next[0].keyframes).toHaveLength(1);
expect(next[0].keyframes[0].time).toBe(1);
});
test("轨道被删空后整条移除,不留空子轨道", () => {
const tracks = upsertDirectorBoneKeyframe([], "head", 0, rotation);
const next = removeDirectorBoneKeyframe(tracks, "head", tracks[0].keyframes[0].id);
expect(next).toEqual([]);
});
test("骨骼或 id 不存在时返回原引用", () => {
const tracks = upsertDirectorBoneKeyframe([], "head", 0, rotation);
expect(removeDirectorBoneKeyframe(tracks, "hips", tracks[0].keyframes[0].id)).toBe(tracks);
expect(removeDirectorBoneKeyframe(tracks, "head", "missing")).toBe(tracks);
});
test("只影响目标骨骼,其他轨道不动", () => {
let tracks = upsertDirectorBoneKeyframe([], "hips", 0, rotation);
tracks = upsertDirectorBoneKeyframe(tracks, "head", 0, rotation);
const next = removeDirectorBoneKeyframe(tracks, "hips", tracks[0].keyframes[0].id);
expect(next.map((t) => t.bone)).toEqual(["head"]);
});
});
describe("removeDirectorSceneKeyframe:时间轴删除的唯一分派入口", () => {
const rotation: DirectorQuat = [0, 0, 0, 1];
/** 一个带对象 transform 帧、骨骼帧和摄影机帧的完整场景。 */
function seededScene() {
const base = createDirectorScene("删除测试");
const actor = {
...createDirectorActor("演员 A"),
keyframes: upsertDirectorKeyframe(upsertDirectorKeyframe([], 0, transformAt(0)), 1, transformAt(10)),
boneTracks: upsertDirectorBoneKeyframe(upsertDirectorBoneKeyframe([], "hips", 0, rotation), "head", 0.5, rotation),
};
const camera = { ...createDirectorCamera("摄影机 A"), keyframes: upsertDirectorKeyframe([], 2, transformAt(5)) };
const scene: DirectorScene = { ...base, objects: [actor], cameras: [camera] };
return { scene, actor, camera };
}
test("删除对象 transform 帧,只动目标对象", () => {
const { scene, actor } = seededScene();
const next = removeDirectorSceneKeyframe(scene, { track: "object-transform", objectId: actor.id, keyframeId: actor.keyframes[0].id });
expect(next).not.toBe(scene);
expect(next.objects[0].keyframes.map((key) => key.time)).toEqual([1]);
// 骨骼轨道和摄影机不受影响,保持原引用。
expect(next.objects[0].boneTracks).toBe(actor.boneTracks);
expect(next.cameras).toBe(scene.cameras);
});
test("删除骨骼帧,删空的子轨道整条移除", () => {
const { scene, actor } = seededScene();
const hips = actor.boneTracks.find((track) => track.bone === "hips");
const next = removeDirectorSceneKeyframe(scene, { track: "object-bone", objectId: actor.id, bone: "hips", keyframeId: hips!.keyframes[0].id });
expect(next.objects[0].boneTracks?.map((track) => track.bone)).toEqual(["head"]);
expect(next.objects[0].keyframes).toBe(actor.keyframes);
});
test("删除摄影机帧,只动目标摄影机", () => {
const { scene, camera } = seededScene();
const next = removeDirectorSceneKeyframe(scene, { track: "camera", cameraId: camera.id, keyframeId: camera.keyframes[0].id });
expect(next.cameras[0].keyframes).toEqual([]);
expect(next.objects).toBe(scene.objects);
});
test("未命中一律返回同一 scene 引用:调用方据此跳过历史与保存", () => {
const { scene, actor, camera } = seededScene();
// 对象存在但帧 id 不存在。
expect(removeDirectorSceneKeyframe(scene, { track: "object-transform", objectId: actor.id, keyframeId: "missing" })).toBe(scene);
// 对象本身不存在。
expect(removeDirectorSceneKeyframe(scene, { track: "object-transform", objectId: "missing", keyframeId: actor.keyframes[0].id })).toBe(scene);
// 骨骼轨道不存在。
expect(removeDirectorSceneKeyframe(scene, { track: "object-bone", objectId: actor.id, bone: "leftHand", keyframeId: "missing" })).toBe(scene);
// 摄影机不存在。
expect(removeDirectorSceneKeyframe(scene, { track: "camera", cameraId: "missing", keyframeId: camera.keyframes[0].id })).toBe(scene);
});
test("对象没有 boneTracks 时删除骨骼帧不虚构空数组", () => {
const base = createDirectorScene("无骨骼");
const object = { ...createDirectorActor("演员 B"), boneTracks: undefined };
const scene: DirectorScene = { ...base, objects: [object] };
expect(removeDirectorSceneKeyframe(scene, { track: "object-bone", objectId: object.id, bone: "hips", keyframeId: "any" })).toBe(scene);
});
test("不改写入参:原 scene 与原数组保持完整", () => {
const { scene, actor } = seededScene();
removeDirectorSceneKeyframe(scene, { track: "object-transform", objectId: actor.id, keyframeId: actor.keyframes[0].id });
expect(scene.objects[0].keyframes).toHaveLength(2);
});
});
describe("setDirectorSceneKeyframeEasing:按轨道更新且保持纯函数", () => {
test("对象、骨骼和摄影机三类目标都能独立更新", () => {
const base = createDirectorScene("缓动测试");
const actor = {
...createDirectorActor("演员"),
keyframes: upsertDirectorKeyframe([], 0, transformAt(0)),
boneTracks: upsertDirectorBoneKeyframe([], "hips", 0, [0, 0, 0, 1]),
};
const camera = { ...createDirectorCamera("摄影机"), keyframes: upsertDirectorKeyframe([], 0, transformAt(1)) };
const scene: DirectorScene = { ...base, objects: [actor], cameras: [camera] };
const objectNext = setDirectorSceneKeyframeEasing(scene, { track: "object-transform", objectId: actor.id, keyframeId: actor.keyframes[0].id }, "smooth");
expect(objectNext.objects[0].keyframes[0].easing).toBe("smooth");
expect(objectNext.cameras).toBe(scene.cameras);
const boneNext = setDirectorSceneKeyframeEasing(scene, { track: "object-bone", objectId: actor.id, bone: "hips", keyframeId: actor.boneTracks[0].keyframes[0].id }, "step");
expect(boneNext.objects[0].boneTracks?.[0].keyframes[0].easing).toBe("step");
const cameraNext = setDirectorSceneKeyframeEasing(scene, { track: "camera", cameraId: camera.id, keyframeId: camera.keyframes[0].id }, "linear");
expect(cameraNext.cameras[0].keyframes[0].easing).toBe("linear");
expect(scene.objects[0].keyframes[0].easing).toBeUndefined();
});
test("目标不存在时返回原 scene 引用", () => {
const scene = createDirectorScene("missing");
expect(setDirectorSceneKeyframeEasing(scene, { track: "camera", cameraId: "missing", keyframeId: "missing" }, "smooth")).toBe(scene);
});
});

View File

@@ -0,0 +1,201 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { DIRECTOR_DEFAULT_MODE, DIRECTOR_MODES, directorModeCapabilities, resolveDirectorModeTransition, type DirectorMode } from "../src/lib/canvas/director/director-modes";
import { createDirectorScene } from "../src/lib/canvas/director/director-scene";
import { createDirectorSceneFromTemplate } from "../src/lib/canvas/director/director-templates";
import { useDirectorWorkbenchStore } from "../src/stores/canvas/use-director-workbench-store";
const ALL_MODES: DirectorMode[] = ["layout", "pose", "animate", "camera"];
describe("四模式骨架", () => {
test("恰好四个一级模式,顺序为摆场/姿态/动画/摄影机", () => {
expect(DIRECTOR_MODES.map((item) => item.mode)).toEqual(ALL_MODES);
expect(DIRECTOR_MODES.map((item) => item.label)).toEqual(["摆场", "姿态", "动画", "摄影机"]);
});
test("默认模式是摆场", () => {
expect(DIRECTOR_DEFAULT_MODE).toBe("layout");
expect(useDirectorWorkbenchStore.getState().mode).toBe("layout");
});
test("每个模式都有可读标签与提示,保证可发现", () => {
for (const item of DIRECTOR_MODES) {
expect(item.label.length).toBeGreaterThan(0);
expect(item.hint.length).toBeGreaterThan(0);
}
});
});
describe("能力矩阵", () => {
test("只有动画模式显示时间轴、允许关键帧与 Auto Key", () => {
for (const mode of ALL_MODES) {
const capabilities = directorModeCapabilities(mode);
expect(capabilities.timeline).toBe(mode === "animate");
expect(capabilities.keyframes).toBe(mode === "animate");
}
});
test("摆场隐藏骨骼;姿态与动画显示骨骼", () => {
expect(directorModeCapabilities("layout").bones).toBe(false);
expect(directorModeCapabilities("camera").bones).toBe(false);
expect(directorModeCapabilities("pose").bones).toBe(true);
expect(directorModeCapabilities("animate").bones).toBe(true);
});
test("只有摄影机模式突出 shot/camera 工具", () => {
for (const mode of ALL_MODES) {
expect(directorModeCapabilities(mode).cameraTools).toBe(mode === "camera");
}
});
test("摆场不提供深度/法线等高级视图;摄影机模式才提供", () => {
expect(directorModeCapabilities("layout").renderModes).toEqual(["beauty", "clay"]);
expect(directorModeCapabilities("layout").renderModes).not.toContain("depth");
expect(directorModeCapabilities("layout").renderModes).not.toContain("pose");
expect(directorModeCapabilities("camera").renderModes).toContain("depth");
expect(directorModeCapabilities("camera").renderModes).toContain("normal");
});
test("姿态模式提供骨骼视图", () => {
expect(directorModeCapabilities("pose").renderModes).toContain("pose");
});
test("未知模式回落到默认模式的能力,不返回 undefined", () => {
expect(directorModeCapabilities("nope" as DirectorMode)).toEqual(directorModeCapabilities("layout"));
});
});
describe("切换清理:离开动画不得残留后台动画写入", () => {
test("离开动画模式一定停播并关掉 Auto Key", () => {
for (const mode of ["layout", "pose", "camera"] as DirectorMode[]) {
const next = resolveDirectorModeTransition({ mode, playing: true, autoKey: true, renderMode: "beauty" });
expect(next.playing).toBe(false);
expect(next.autoKey).toBe(false);
}
});
test("留在动画模式时保留播放与 Auto Key", () => {
const next = resolveDirectorModeTransition({ mode: "animate", playing: true, autoKey: true, renderMode: "beauty" });
expect(next.playing).toBe(true);
expect(next.autoKey).toBe(true);
});
test("renderMode 被夹回目标模式允许的集合", () => {
// 摄影机模式的深度视图不能带进摆场。
expect(resolveDirectorModeTransition({ mode: "layout", playing: false, autoKey: false, renderMode: "depth" }).renderMode).toBe("beauty");
// 姿态模式的骨骼视图同样不属于摆场。
expect(resolveDirectorModeTransition({ mode: "layout", playing: false, autoKey: false, renderMode: "pose" }).renderMode).toBe("beauty");
// 合法组合保持不变。
expect(resolveDirectorModeTransition({ mode: "camera", playing: false, autoKey: false, renderMode: "normal" }).renderMode).toBe("normal");
expect(resolveDirectorModeTransition({ mode: "layout", playing: false, autoKey: false, renderMode: "clay" }).renderMode).toBe("clay");
});
});
describe("store 的 setMode", () => {
beforeEach(() => {
useDirectorWorkbenchStore.getState().reset();
});
test("切到动画再切回摆场:playing 与 autoKey 都被清掉", () => {
const store = useDirectorWorkbenchStore;
store.getState().setMode("animate");
store.getState().setPlaying(true);
store.getState().setAutoKey(true);
expect(store.getState().playing).toBe(true);
expect(store.getState().autoKey).toBe(true);
store.getState().setMode("layout");
expect(store.getState().mode).toBe("layout");
expect(store.getState().playing).toBe(false);
expect(store.getState().autoKey).toBe(false);
});
test("切模式不影响选择状态:draft 归属由 workbench 自己管", () => {
const store = useDirectorWorkbenchStore;
store.getState().setSelectedObjectId("obj-1");
store.getState().setMode("camera");
expect(store.getState().selectedObjectId).toBe("obj-1");
store.getState().setMode("pose");
expect(store.getState().selectedObjectId).toBe("obj-1");
});
test("切模式不重置 playhead:时间位置不因为换视图而丢", () => {
const store = useDirectorWorkbenchStore;
store.getState().setMode("animate");
store.getState().setPlayhead(2.5);
store.getState().setMode("pose");
expect(store.getState().playhead).toBe(2.5);
});
test("reset 回到默认摆场", () => {
const store = useDirectorWorkbenchStore;
store.getState().setMode("animate");
store.getState().reset();
expect(store.getState().mode).toBe("layout");
});
test("摆场模式拒绝 pose/depth/normal,只接受 beauty/clay", () => {
const store = useDirectorWorkbenchStore;
expect(store.getState().mode).toBe("layout");
store.getState().setRenderMode("clay");
expect(store.getState().renderMode).toBe("clay");
// 这三个不属于摆场:必须被拒绝,且不得把已有选择冲掉。
for (const rejected of ["pose", "depth", "normal"] as const) {
store.getState().setRenderMode(rejected);
expect(store.getState().renderMode).toBe("clay");
}
});
test("dock 的「骨骼视图」在摆场模式点了也无效:store 层夹住了所有路径", () => {
const store = useDirectorWorkbenchStore;
// dock 按钮等价于直接调 setRenderMode("pose")。
store.getState().setRenderMode("pose");
expect(store.getState().renderMode).toBe("beauty");
});
test("摄影机模式才接受 depth/normal", () => {
const store = useDirectorWorkbenchStore;
store.getState().setMode("camera");
store.getState().setRenderMode("depth");
expect(store.getState().renderMode).toBe("depth");
store.getState().setRenderMode("normal");
expect(store.getState().renderMode).toBe("normal");
});
test("姿态模式接受骨骼视图,但仍拒绝深度视图", () => {
const store = useDirectorWorkbenchStore;
store.getState().setMode("pose");
store.getState().setRenderMode("pose");
expect(store.getState().renderMode).toBe("pose");
store.getState().setRenderMode("depth");
expect(store.getState().renderMode).toBe("pose");
});
test("带着深度视图离开摄影机模式会被夹回 beauty,不残留非法组合", () => {
const store = useDirectorWorkbenchStore;
store.getState().setMode("camera");
store.getState().setRenderMode("depth");
expect(store.getState().renderMode).toBe("depth");
store.getState().setMode("layout");
expect(store.getState().renderMode).toBe("beauty");
});
});
describe("mode 不进 DirectorScene schema", () => {
test("兼容 factory 与模板生成的场景都没有 mode 字段", () => {
expect(Object.keys(createDirectorScene("s"))).not.toContain("mode");
expect(Object.keys(createDirectorSceneFromTemplate("dialogue"))).not.toContain("mode");
});
test("切模式不产生任何场景对象:mode 纯属工作台 UI 状态", () => {
const store = useDirectorWorkbenchStore;
const before = createDirectorSceneFromTemplate("monologue");
const snapshot = JSON.stringify(before);
store.getState().setMode("animate");
store.getState().setMode("layout");
expect(JSON.stringify(before)).toBe(snapshot);
});
});

View File

@@ -0,0 +1,581 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import localforage from "localforage";
import {
DIRECTOR_ONBOARDING_KEY,
DIRECTOR_ONBOARDING_STEPS,
DIRECTOR_ONBOARDING_VERSION,
advanceDirectorOnboarding,
createDirectorOnboardingGate,
directorOnboardingInitial,
loadDirectorOnboardingProgress,
parseDirectorOnboardingProgress,
reduceDirectorOnboarding,
resetDirectorOnboardingProgress,
resolveDirectorOnboardingView,
saveDirectorOnboardingProgress,
type DirectorOnboardingAction,
type DirectorOnboardingProgress,
type DirectorOnboardingStorage,
} from "../src/lib/canvas/director/director-onboarding";
const ACTIVE: DirectorOnboardingProgress = directorOnboardingInitial;
function progressAt(stepId: DirectorOnboardingProgress["stepId"], status: DirectorOnboardingProgress["status"] = "active"): DirectorOnboardingProgress {
return { version: DIRECTOR_ONBOARDING_VERSION, status, stepId };
}
/**
* 剥掉源码里的注释再做契约断言:文档注释里出于说明目的提到的反例词
* (比如「不是 role="dialog"」这句话本身)不该让断言把「提到」误判成「使用」。
*/
function stripComments(source: string) {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
}
/** 单 scope 的内存存储接缝。生产路径由 localForageStorageForScope 加 `:user:<scope>`,这里只验状态流转。 */
function memoryStorage(initial?: string) {
const writes: string[] = [];
let value = initial ?? null;
const storage: DirectorOnboardingStorage = {
getItem: async () => value,
setItem: async (_name, next) => {
value = next;
writes.push(next);
},
};
return {
storage,
writes,
current: () => value,
};
}
/**
* 真实持久路径的探针:接住 localforage 的默认实例,并记录任何 localStorage 触碰。
* 引导进度必须只走 IndexedDB —— localStorage 只允许存小型 UI 偏好。
*/
function localforageHarness(options?: { failReads?: boolean }) {
const originalGetItem = localforage.getItem.bind(localforage);
const originalSetItem = localforage.setItem.bind(localforage);
const originalWindow = (globalThis as { window?: unknown }).window;
const durable = new Map<string, string>();
const localStorageCalls: string[] = [];
const localStorageProbe = {
getItem: (key: string) => {
localStorageCalls.push(`get:${key}`);
return null;
},
setItem: (key: string) => {
localStorageCalls.push(`set:${key}`);
},
removeItem: (key: string) => {
localStorageCalls.push(`remove:${key}`);
},
};
Object.defineProperty(globalThis, "window", { configurable: true, value: { localStorage: localStorageProbe } });
localforage.getItem = (async (key: string) => {
if (options?.failReads) throw new Error("indexeddb unavailable");
return durable.get(key) ?? null;
}) as typeof localforage.getItem;
localforage.setItem = (async (key: string, value: string) => {
durable.set(key, value);
return value;
}) as typeof localforage.setItem;
return {
durable,
localStorageCalls,
restore() {
localforage.getItem = originalGetItem;
localforage.setItem = originalSetItem;
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
},
};
}
describe("用户隔离", () => {
test("两个账号的进度写进各自的 scope 键,互不可见", async () => {
const harness = localforageHarness();
try {
await saveDirectorOnboardingProgress("user-a", progressAt("pose"));
await saveDirectorOnboardingProgress("user-b", progressAt("apply", "completed"));
expect([...harness.durable.keys()].sort()).toEqual([`${DIRECTOR_ONBOARDING_KEY}:user:user-a`, `${DIRECTOR_ONBOARDING_KEY}:user:user-b`]);
expect(await loadDirectorOnboardingProgress("user-a")).toEqual(progressAt("pose"));
expect(await loadDirectorOnboardingProgress("user-b")).toEqual(progressAt("apply", "completed"));
// 没写过的账号拿到初始进度,而不是继承别人的已完成状态。
expect(await loadDirectorOnboardingProgress("user-c")).toEqual(ACTIVE);
} finally {
harness.restore();
}
});
test("进度只走 localforage,完全不碰 localStorage", async () => {
const harness = localforageHarness();
try {
await saveDirectorOnboardingProgress("user-a", progressAt("move"));
await loadDirectorOnboardingProgress("user-a");
expect(harness.localStorageCalls).toEqual([]);
} finally {
harness.restore();
}
});
test("空 scope 一律硬失败,绝不回落到默认账号", async () => {
await expect(loadDirectorOnboardingProgress("")).rejects.toThrow("缺少导演台引导的用户 scope");
await expect(loadDirectorOnboardingProgress(" ")).rejects.toThrow("缺少导演台引导的用户 scope");
await expect(saveDirectorOnboardingProgress("", ACTIVE)).rejects.toThrow("缺少导演台引导的用户 scope");
await expect(advanceDirectorOnboarding("", ACTIVE, "next")).rejects.toThrow("缺少导演台引导的用户 scope");
});
test("存储层失败向上抛,不伪装成初始进度", async () => {
const harness = localforageHarness({ failReads: true });
try {
await expect(loadDirectorOnboardingProgress("user-a")).rejects.toThrow("indexeddb unavailable");
} finally {
harness.restore();
}
});
});
describe("版本失效与脏数据", () => {
test("版本不符的旧进度整条丢弃,回到第一步", () => {
const stale = JSON.stringify({ version: DIRECTOR_ONBOARDING_VERSION - 1, status: "completed", stepId: "apply" });
expect(parseDirectorOnboardingProgress(stale)).toEqual(ACTIVE);
});
test("缺失、非 JSON、非对象、字段越界的持久值都回落到初始进度", () => {
const dirty = [
null,
undefined,
"",
"{",
"not json",
"[]",
'"active"',
"42",
"null",
JSON.stringify({ status: "active", stepId: "actor" }),
JSON.stringify({ version: DIRECTOR_ONBOARDING_VERSION, status: "paused", stepId: "actor" }),
JSON.stringify({ version: DIRECTOR_ONBOARDING_VERSION, status: "active", stepId: "lighting" }),
JSON.stringify({ version: "2", status: "active", stepId: "actor" }),
JSON.stringify([{ version: DIRECTOR_ONBOARDING_VERSION, status: "active", stepId: "actor" }]),
];
for (const raw of dirty) expect(parseDirectorOnboardingProgress(raw)).toEqual(ACTIVE);
});
test("脏持久值经由真实读路径也只会得到初始进度", async () => {
const harness = localforageHarness();
try {
harness.durable.set(`${DIRECTOR_ONBOARDING_KEY}:user:user-a`, "{ broken");
expect(await loadDirectorOnboardingProgress("user-a")).toEqual(ACTIVE);
} finally {
harness.restore();
}
});
test("落盘时只写受控字段,不回写未知字段", async () => {
const store = memoryStorage();
await saveDirectorOnboardingProgress("user-a", { ...progressAt("move"), extra: "x" } as DirectorOnboardingProgress, store.storage);
expect(JSON.parse(store.writes[0])).toEqual({ version: DIRECTOR_ONBOARDING_VERSION, status: "active", stepId: "move" });
});
test("状态机遇到不存在的步骤时回到初始进度,而不是越界", () => {
const bogus = { version: DIRECTOR_ONBOARDING_VERSION, status: "active", stepId: "lighting" } as unknown as DirectorOnboardingProgress;
expect(reduceDirectorOnboarding(bogus, "next")).toEqual(ACTIVE);
expect(reduceDirectorOnboarding(bogus, "back")).toEqual(ACTIVE);
expect(resolveDirectorOnboardingView(bogus)).toBeNull();
});
});
describe("步骤流转", () => {
test("next 依次走完六步,最后一步 next 即结束引导", () => {
let progress = ACTIVE;
const visited = [progress.stepId];
for (let i = 0; i < DIRECTOR_ONBOARDING_STEPS.length - 1; i += 1) {
progress = reduceDirectorOnboarding(progress, "next");
visited.push(progress.stepId);
}
expect(visited).toEqual(DIRECTOR_ONBOARDING_STEPS.map((step) => step.id));
expect(progress.status).toBe("active");
const done = reduceDirectorOnboarding(progress, "next");
expect(done.status).toBe("completed");
// 完成是终态:停在最后一步,且不再前进。
expect(done.stepId).toBe(DIRECTOR_ONBOARDING_STEPS[DIRECTOR_ONBOARDING_STEPS.length - 1].id);
expect(reduceDirectorOnboarding(done, "next")).toBe(done);
});
test("back 逐步回退,首步 back 原地不动且不关闭引导", () => {
const second = reduceDirectorOnboarding(ACTIVE, "next");
expect(reduceDirectorOnboarding(second, "back").stepId).toBe("actor");
expect(reduceDirectorOnboarding(ACTIVE, "back")).toBe(ACTIVE);
expect(reduceDirectorOnboarding(ACTIVE, "back").status).toBe("active");
});
test("dismiss 与 complete 都是终态,next/back/dismiss/complete 无法唤醒", () => {
for (const status of ["dismissed", "completed"] as const) {
const frozen = progressAt("move", status);
for (const action of ["next", "back", "dismiss", "complete"] as const) {
expect(reduceDirectorOnboarding(frozen, action)).toBe(frozen);
}
expect(resolveDirectorOnboardingView(frozen)).toBeNull();
}
});
test("reset 是唯一能让已跳过/已完成引导复活的动作", () => {
expect(reduceDirectorOnboarding(progressAt("apply", "dismissed"), "reset")).toEqual(ACTIVE);
expect(reduceDirectorOnboarding(progressAt("apply", "completed"), "reset")).toEqual(ACTIVE);
expect(reduceDirectorOnboarding(progressAt("pose"), "reset")).toEqual(ACTIVE);
});
test("dismiss 保留当前步骤,reset 之后才回到第一步", () => {
const dismissed = reduceDirectorOnboarding(progressAt("pose"), "dismiss");
expect(dismissed).toEqual(progressAt("pose", "dismissed"));
expect(reduceDirectorOnboarding(dismissed, "reset").stepId).toBe("actor");
});
test("展示态给出 1 起数的位置与首尾标记", () => {
const first = resolveDirectorOnboardingView(ACTIVE);
expect(first).toEqual({ step: DIRECTOR_ONBOARDING_STEPS[0], position: 1, total: DIRECTOR_ONBOARDING_STEPS.length, isFirst: true, isLast: false });
const last = resolveDirectorOnboardingView(progressAt("apply"));
expect(last?.position).toBe(DIRECTOR_ONBOARDING_STEPS.length);
expect(last?.isLast).toBe(true);
expect(last?.isFirst).toBe(false);
});
});
describe("推进即落盘", () => {
test("每次有效推进都写盘,返回值与磁盘一致", async () => {
const store = memoryStorage();
const second = await advanceDirectorOnboarding("user-a", ACTIVE, "next", store.storage);
expect(second.stepId).toBe("move");
expect(parseDirectorOnboardingProgress(store.current())).toEqual(second);
const dismissed = await advanceDirectorOnboarding("user-a", second, "dismiss", store.storage);
expect(dismissed.status).toBe("dismissed");
expect(parseDirectorOnboardingProgress(store.current())).toEqual(dismissed);
expect(store.writes).toHaveLength(2);
});
test("无变化的动作不写盘", async () => {
const store = memoryStorage();
expect(await advanceDirectorOnboarding("user-a", ACTIVE, "back", store.storage)).toBe(ACTIVE);
const frozen = progressAt("move", "completed");
expect(await advanceDirectorOnboarding("user-a", frozen, "next", store.storage)).toBe(frozen);
expect(store.writes).toEqual([]);
});
test("写盘失败向上抛,调用方保留旧进度", async () => {
const failing: DirectorOnboardingStorage = {
getItem: async () => null,
setItem: async () => {
throw new Error("indexeddb write failed");
},
};
await expect(advanceDirectorOnboarding("user-a", ACTIVE, "next", failing)).rejects.toThrow("indexeddb write failed");
});
test("重启后从磁盘恢复到同一步骤", async () => {
const harness = localforageHarness();
try {
const second = await advanceDirectorOnboarding("user-a", ACTIVE, "next");
const third = await advanceDirectorOnboarding("user-a", second, "next");
expect(await loadDirectorOnboardingProgress("user-a")).toEqual(third);
expect(resolveDirectorOnboardingView(third)?.step.id).toBe("pose");
} finally {
harness.restore();
}
});
});
describe("scope 归一化与并发写锁", () => {
test("回归:注入 storage 时空白 scope 依旧硬失败,不因为跳过默认 localforage 分支而漏检", async () => {
const store = memoryStorage();
await expect(loadDirectorOnboardingProgress(" ", store.storage)).rejects.toThrow("缺少导演台引导的用户 scope");
await expect(saveDirectorOnboardingProgress("", ACTIVE, store.storage)).rejects.toThrow("缺少导演台引导的用户 scope");
await expect(advanceDirectorOnboarding(" ", ACTIVE, "next", store.storage)).rejects.toThrow("缺少导演台引导的用户 scope");
await expect(resetDirectorOnboardingProgress("\t", store.storage)).rejects.toThrow("缺少导演台引导的用户 scope");
expect(store.writes).toEqual([]);
});
test("advance 对无变化的动作也无条件校验 scope,不因提前返回而漏检", async () => {
await expect(advanceDirectorOnboarding("", ACTIVE, "back")).rejects.toThrow("缺少导演台引导的用户 scope");
await expect(advanceDirectorOnboarding(" ", progressAt("apply", "completed"), "next")).rejects.toThrow("缺少导演台引导的用户 scope");
});
test("scope 前后空白归一化,与已裁剪的 scope 共用同一把生产键,互不分裂", async () => {
const harness = localforageHarness();
try {
await saveDirectorOnboardingProgress(" user-a ", progressAt("pose"));
expect([...harness.durable.keys()]).toEqual([`${DIRECTOR_ONBOARDING_KEY}:user:user-a`]);
expect(await loadDirectorOnboardingProgress("user-a")).toEqual(progressAt("pose"));
expect(await loadDirectorOnboardingProgress(" user-a")).toEqual(progressAt("pose"));
expect(await loadDirectorOnboardingProgress("user-a\t")).toEqual(progressAt("pose"));
} finally {
harness.restore();
}
});
test("resetDirectorOnboardingProgress 让已跳过/已完成的账号回到激活的第一步,并落盘", async () => {
const store = memoryStorage(JSON.stringify({ version: DIRECTOR_ONBOARDING_VERSION, status: "completed", stepId: "apply" }));
const result = await resetDirectorOnboardingProgress("user-a", store.storage);
expect(result).toEqual(ACTIVE);
expect(parseDirectorOnboardingProgress(store.current())).toEqual(ACTIVE);
});
test("resetDirectorOnboardingProgress 经真实持久层也能复活已跳过的引导", async () => {
const harness = localforageHarness();
try {
await saveDirectorOnboardingProgress("user-a", progressAt("apply", "dismissed"));
expect(await resetDirectorOnboardingProgress("user-a")).toEqual(ACTIVE);
expect(await loadDirectorOnboardingProgress("user-a")).toEqual(ACTIVE);
} finally {
harness.restore();
}
});
test("写锁:锁定期间的重复进入被拒绝,release 后可再次进入,release 本身幂等", () => {
const gate = createDirectorOnboardingGate();
expect(gate.tryEnter()).toBe(true);
expect(gate.tryEnter()).toBe(false);
expect(gate.tryEnter()).toBe(false);
gate.release();
expect(gate.tryEnter()).toBe(true);
gate.release();
expect(() => gate.release()).not.toThrow();
expect(gate.tryEnter()).toBe(true);
});
test("同一 tick 内的重复动作只产生一次写入(复刻 hook 用写锁挡重复调用的方式)", async () => {
const store = memoryStorage();
const gate = createDirectorOnboardingGate();
const progress = ACTIVE;
const fire = (action: DirectorOnboardingAction) => {
if (!gate.tryEnter()) return null;
return advanceDirectorOnboarding("user-a", progress, action, store.storage).finally(() => gate.release());
};
const first = fire("next");
const second = fire("next");
expect(second).toBeNull();
const resolved = await first;
expect(resolved?.stepId).toBe("move");
expect(store.writes).toHaveLength(1);
// 锁已经 release,后续调用不再被挡。
expect(gate.tryEnter()).toBe(true);
});
test("跨代隔离:release 旧一代捕获的锁,绝不能误放新一代仍在写入的锁", () => {
// 精确复刻 hook 的接线方式:换代时把 ref 换成全新的 gate 实例(不 release 旧实例),
// 每次调用在拿锁的同一刻把 ref 当前指向的实例捕获进局部变量,release 只作用在这份捕获上,
// 绝不在写入收尾时重新读一次 ref(那时 ref 可能已经转向了新一代的锁)。
let gateRef = createDirectorOnboardingGate();
function fire() {
const gate = gateRef; // 同步捕获调用时刻的实例,而不是稍后再读 gateRef
const entered = gate.tryEnter();
return { entered, release: () => gate.release() };
}
// 第一代发起一次写入,锁定 gen1 的 gate。
const gen1 = fire();
expect(gen1.entered).toBe(true);
// scope 切换:effect 把 ref 换成全新实例,不 release 旧实例 —— gen1 的写入仍在途、仍锁着自己那把。
gateRef = createDirectorOnboardingGate();
// 第二代应该立刻拿到全新的锁,不受 gen1 未完成写入的影响。
const gen2 = fire();
expect(gen2.entered).toBe(true);
// gen2 写入还没收尾时,gen2 的重复调用必须被自己的锁挡住。
expect(fire().entered).toBe(false);
// gen1 的写入这时才收尾:它释放的是自己捕获的 gen1 实例,对 gen2 的锁没有任何影响。
gen1.release();
expect(fire().entered).toBe(false); // gen2 依旧锁着,没有被 gen1 的 release 误放。
// gen2 的写入收尾,释放自己的锁,之后才能再次进入。
gen2.release();
expect(fire().entered).toBe(true);
});
});
describe("步骤内容契约", () => {
test("六个步骤覆盖添加演员、移动、调姿、轨迹、CAM 与应用,顺序固定", () => {
expect(DIRECTOR_ONBOARDING_STEPS.map((step) => step.id)).toEqual(["actor", "move", "pose", "path", "camera", "apply"]);
});
test("每个步骤都有标题与可执行说明,id 不重复", () => {
for (const step of DIRECTOR_ONBOARDING_STEPS) {
expect(step.title.length).toBeGreaterThan(0);
expect(step.detail.length).toBeGreaterThan(0);
}
expect(new Set(DIRECTOR_ONBOARDING_STEPS.map((step) => step.id)).size).toBe(DIRECTOR_ONBOARDING_STEPS.length);
});
test("每一步都给出对应一级模式,覆盖摆场、姿态、动画和摄影机", () => {
const byId = Object.fromEntries(DIRECTOR_ONBOARDING_STEPS.map((step) => [step.id, step]));
expect(byId.actor.mode).toBe("layout");
expect(byId.move.mode).toBe("layout");
expect(byId.pose.mode).toBe("pose");
expect(byId.path.mode).toBe("animate");
expect(byId.camera.mode).toBe("camera");
expect(byId.apply.mode).toBe("camera");
for (const step of DIRECTOR_ONBOARDING_STEPS) {
if (step.mode) expect(["layout", "pose", "animate", "camera"]).toContain(step.mode);
}
});
test("步骤文案指向真实入口,不把浏览步骤称为任务完成", () => {
const content = DIRECTOR_ONBOARDING_STEPS.map((step) => `${step.title} ${step.detail}`).join("\n");
for (const label of ["快速添加", "移动工具", "姿态模式", "Transform 关键帧", "CAM", "应用到镜头"]) expect(content).toContain(label);
});
});
/**
* 组件契约用源码断言:web 没有 jsdom/testing-library(见 package.json),
* 这里与 create-library-button.test.ts 同一手法 —— 守住无障碍与 token 边界,
* 而不是假装渲染过组件。
*/
describe("引导浮层契约", () => {
const componentPath = resolve(import.meta.dir, "../src/components/canvas/director/canvas-director-onboarding.tsx");
const source = readFileSync(componentPath, "utf8");
// 契约断言只看真正的代码:文档注释里为了解释「为什么不这样做」而提到的反例词
// (role="dialog"、getActiveUserScope、DirectorScene)不该让断言把「提到」误判成「使用」。
const code = stripComments(source);
test("非阻塞浮层:region 语义,不是 dialog,也没有遮罩与焦点陷阱", () => {
expect(code).toContain("<section");
expect(code).toContain('aria-label="导演台上手引导"');
expect(code).not.toContain('role="dialog"');
expect(code).not.toContain("aria-modal");
expect(code).not.toContain("Modal");
expect(code).not.toContain(".focus()");
});
test("三个动作都是原生 button,并有可见焦点环", () => {
const buttons = code.match(/<button\b/g) || [];
expect(buttons).toHaveLength(3);
expect(code.match(/type="button"/g) || []).toHaveLength(3);
expect(code).toContain("跳过引导");
expect(code).toContain("上一步");
expect(code).toContain("下一步");
expect(code).toContain("结束引导");
expect(code).not.toContain("完成引导");
expect(code).toContain("focus-visible:[outline:var(--stroke-2)_solid_var(--control-focus-ring)]");
});
test("进度既有文本又有播报,装饰性圆点对读屏隐藏", () => {
expect(code).toContain("第 ${position} 步 / 共 ${total} 步");
expect(code).toContain('aria-live="polite"');
expect(code).toContain("aria-hidden");
});
test("首步禁用上一步,末步把主按钮换成中性的结束文案", () => {
expect(code).toContain("disabled={busy || isFirst}");
expect(code).toContain('{isLast ? "结束引导" : "下一步"}');
});
test("只用既有 token,不写颜色/圆角/字号字面值", () => {
expect(code).not.toMatch(/#[0-9a-fA-F]{3,8}\b/);
expect(code).not.toMatch(/\b(rgba?|oklch|hsla?)\(/);
expect(code).not.toMatch(/:\s*"\d+px"/);
expect(code).not.toMatch(/(text|rounded|z|bg|text)-\[\d/);
for (const token of ["var(--space-3)", "var(--r-lg)", "var(--surface)", "var(--cn-text)", "var(--cn-muted)", "var(--fs-caption)", "var(--elevation-overlay)", "var(--border-semantic)"]) {
expect(code).toContain(token);
}
});
test("scope 由调用方显式传入,组件不读当前活跃 scope,也不自己碰 localStorage", () => {
expect(code).toContain("scope: string");
expect(code).not.toContain("getActiveUserScope");
expect(code).not.toMatch(/import\s*{[^}]*getActiveUserScope/);
expect(code).not.toContain("localStorage");
expect(code).not.toContain("localforage");
});
test("状态与持久化全部走 director-onboarding 模块,组件不复制状态机", () => {
expect(code).toContain('from "@/lib/canvas/director/director-onboarding"');
expect(code).toContain("loadDirectorOnboardingProgress");
expect(code).toContain("advanceDirectorOnboarding");
expect(code).toContain("resetDirectorOnboardingProgress");
expect(code).toContain("resolveDirectorOnboardingView");
expect(code).not.toContain("DIRECTOR_ONBOARDING_STEPS");
expect(code).not.toContain('"dismissed"');
expect(code).not.toContain('"completed"');
});
test("并发写保护走同步 gate:换代新建实例而不是 release 共享实例,释放的是调用时捕获的那把锁", () => {
expect(code).toContain("createDirectorOnboardingGate");
// 换代必须新建实例,绝不能对共享的旧实例调用 release —— 那会把旧一代还没收尾的写入
// 和新一代刚建好的锁混在一起判定。
expect(code).toContain("gateRef.current = createDirectorOnboardingGate()");
expect(code).not.toContain("gateRef.current.release()");
// run() 必须在拿锁的同一刻把 gateRef.current 同步捕获进局部变量,
// tryEnter/release 都作用在这份捕获上,绝不在写入收尾时重新读取 gateRef.current。
expect(code).toMatch(/const\s+gate\s*=\s*gateRef\.current;/);
expect(code).toContain("gate.tryEnter()");
expect(code).toContain("gate.release()");
});
test("外部重启信号在加载期间也能直接重置,并让旧读取结果失效", () => {
expect(code).toContain("restartSignal?: number");
expect(code).toContain("const previousRestartSignal = useRef(restartSignal)");
expect(code).toContain("previousRestartSignal.current === restartSignal");
expect(code).toContain("resetDirectorOnboardingProgress(normalizedScope, storage)");
expect(code).toContain("return loadDirectorOnboardingProgress(normalizedScope, storage)");
expect(code).not.toContain('run("reset")');
const generationAdvance = code.indexOf("generation.current += 1", code.indexOf("const reset = useCallback"));
const resetWrite = code.indexOf("resetDirectorOnboardingProgress(normalizedScope, storage)");
expect(generationAdvance).toBeGreaterThan(-1);
expect(resetWrite).toBeGreaterThan(generationAdvance);
});
test("模块不写场景内容,也不引入历史/撤销", () => {
const modelSource = readFileSync(resolve(import.meta.dir, "../src/lib/canvas/director/director-onboarding.ts"), "utf8");
const modelCode = stripComments(modelSource);
expect(modelCode).not.toContain("DirectorScene");
expect(modelCode).not.toContain("director-scene");
expect(modelCode).not.toMatch(/\bhistory\b/i);
expect(modelCode).not.toContain("window.localStorage");
expect(modelCode).toContain("localForageStorageForScope");
});
});
describe("引导产品接线", () => {
const projectCode = stripComments(readFileSync(resolve(import.meta.dir, "../src/pages/canvas/project.tsx"), "utf8"));
const workbenchCode = stripComments(readFileSync(resolve(import.meta.dir, "../src/components/canvas/director/canvas-director-workbench.tsx"), "utf8"));
test("页面只把已认证用户 id 传给引导,不使用 guest fallback 或未定义变量", () => {
expect(projectCode).toContain('const directorOnboardingScope = useUserStore((state) => state.user?.id?.trim() || "")');
expect(projectCode).toContain("onboardingScope={directorOnboardingScope}");
expect(projectCode).not.toContain("trimmedUserId");
expect(projectCode).not.toContain("onboardingScope={canvasStorageScope}");
});
test("工作台渲染非模态引导,并提供可发现的重新开始入口", () => {
expect(workbenchCode).toContain('import { CanvasDirectorOnboarding } from "@/components/canvas/director/canvas-director-onboarding"');
expect(workbenchCode).toContain("onboardingScope: string");
expect(workbenchCode).toContain('label="重新开始引导"');
expect(workbenchCode).toContain("setOnboardingRestartSignal((value) => value + 1)");
expect(workbenchCode).toContain("<CanvasDirectorOnboarding");
expect(workbenchCode).toContain("scope={onboardingScope}");
expect(workbenchCode).toContain("restartSignal={onboardingRestartSignal}");
expect(workbenchCode).toContain("overflow-x-auto");
expect(workbenchCode).toContain("overflow-y-hidden");
});
test("开发复现页启用独立 scope,可实际检查首次引导与重启", () => {
const reproCode = stripComments(readFileSync(resolve(import.meta.dir, "../src/pages/dev/director-repro-lab.tsx"), "utf8"));
expect(reproCode).toContain('onboardingScope="director-repro-lab"');
expect(reproCode).not.toContain('onboardingScope=""');
});
});

View File

@@ -1,7 +1,7 @@
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 { isDirectorOutputSnapshotCurrent, mergeDirectorOutputPreview, 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";
@@ -76,6 +76,44 @@ describe("upsertDirectorSceneById:连续保存不丢 scene(A6 回归)", ()
});
});
describe("异步输出只合并预览引用,不覆盖最新场景", () => {
test("保留上传期间发生的场景与镜头编辑", () => {
const latest = scene("s1", "上传期间改过的标题");
const shotId = latest.shots[0].id;
latest.shots[0] = { ...latest.shots[0], name: "上传期间改过的镜头" };
const merged = mergeDirectorOutputPreview(latest, { sceneId: latest.id, shotId, previewNodeId: "preview-1" });
expect(merged?.title).toBe("上传期间改过的标题");
expect(merged?.shots[0].name).toBe("上传期间改过的镜头");
expect(merged?.shots[0].previewNodeId).toBe("preview-1");
expect(merged?.shots[0].depthNodeId).toBeUndefined();
expect(merged?.shots[0].normalNodeId).toBeUndefined();
});
test("场景或镜头已经切换/删除时拒绝合并", () => {
const latest = scene("s1");
expect(mergeDirectorOutputPreview(latest, { sceneId: "other", shotId: latest.shots[0].id, previewNodeId: "preview" })).toBeNull();
expect(mergeDirectorOutputPreview(latest, { sceneId: latest.id, shotId: "missing", previewNodeId: "preview" })).toBeNull();
});
});
describe("截图与录制绑定开始时的场景快照", () => {
test("同一场景快照引用与同一活动镜头才允许继续", () => {
const current = scene("s1");
const expected = { scene: current, shotId: current.shots[0].id };
expect(isDirectorOutputSnapshotCurrent(current, expected)).toBe(true);
expect(isDirectorOutputSnapshotCurrent({ ...current }, expected)).toBe(false);
expect(isDirectorOutputSnapshotCurrent(null, expected)).toBe(false);
});
test("活动镜头切换时拒绝继续,即使原镜头仍存在", () => {
const current = scene("s1");
const originalShot = current.shots[0];
const otherShot = { ...originalShot, id: "shot-2", name: "镜头 2" };
const withOtherActive = { ...current, shots: [originalShot, otherShot], activeShotId: otherShot.id };
expect(isDirectorOutputSnapshotCurrent(withOtherActive, { scene: withOtherActive, shotId: originalShot.id })).toBe(false);
});
});
describe("预览与持久变更的发布边界(A2/A3/A4 回归)", () => {
/** 最小 session controller:与 workbench 的接线同构,但不依赖 React 挂载。 */
function harness(initial: DirectorScene) {

View File

@@ -0,0 +1,214 @@
import { describe, expect, test } from "bun:test";
import { DIRECTOR_INTERACTIVE_SELECTOR, blocksDirectorShortcut, isDirectorInteractiveNode, releaseDirectorFocusAfterPointer, resolveDirectorShortcut, type DirectorKeyEvent } from "../src/lib/canvas/director/director-shortcuts";
const press = (overrides: Partial<DirectorKeyEvent> & { key: string }): DirectorKeyEvent => ({ ...overrides });
describe("变换模式快捷键", () => {
test("W/E/R 映射到平移/旋转/缩放", () => {
expect(resolveDirectorShortcut(press({ key: "w" }))).toEqual({ kind: "transform-mode", mode: "translate" });
expect(resolveDirectorShortcut(press({ key: "e" }))).toEqual({ kind: "transform-mode", mode: "rotate" });
expect(resolveDirectorShortcut(press({ key: "r" }))).toEqual({ kind: "transform-mode", mode: "scale" });
});
test("大写与 Shift 组合仍然生效", () => {
expect(resolveDirectorShortcut(press({ key: "W" }))).toEqual({ kind: "transform-mode", mode: "translate" });
expect(resolveDirectorShortcut(press({ key: "E", shiftKey: true }))).toEqual({ kind: "transform-mode", mode: "rotate" });
});
test("带 Ctrl/Cmd 时不再当作变换模式", () => {
// Ctrl+R 是刷新,绝不能被解析成 scale。
expect(resolveDirectorShortcut(press({ key: "r", ctrlKey: true }))).toBeNull();
expect(resolveDirectorShortcut(press({ key: "w", metaKey: true }))).toBeNull();
});
});
describe("对象操作快捷键", () => {
test("Delete 与 Backspace 都删除选中对象", () => {
expect(resolveDirectorShortcut(press({ key: "Delete" }))).toEqual({ kind: "delete-selected" });
expect(resolveDirectorShortcut(press({ key: "Backspace" }))).toEqual({ kind: "delete-selected" });
});
test("H 切换显隐、Escape 取消选择", () => {
expect(resolveDirectorShortcut(press({ key: "h" }))).toEqual({ kind: "toggle-visibility" });
expect(resolveDirectorShortcut(press({ key: "Escape" }))).toEqual({ kind: "deselect" });
});
test("没有执行路径的键不解析:F 聚焦、F2 重命名、Ctrl+D 复制", () => {
expect(resolveDirectorShortcut(press({ key: "f" }))).toBeNull();
expect(resolveDirectorShortcut(press({ key: "F2" }))).toBeNull();
expect(resolveDirectorShortcut(press({ key: "d", ctrlKey: true }))).toBeNull();
expect(resolveDirectorShortcut(press({ key: "d", metaKey: true }))).toBeNull();
});
test("空格切换播放", () => {
expect(resolveDirectorShortcut(press({ key: " " }))).toEqual({ kind: "toggle-play" });
expect(resolveDirectorShortcut(press({ key: "Spacebar" }))).toEqual({ kind: "toggle-play" });
});
});
describe("撤销/重做", () => {
test("Ctrl/Cmd+Z 撤销", () => {
expect(resolveDirectorShortcut(press({ key: "z", ctrlKey: true }))).toEqual({ kind: "undo" });
expect(resolveDirectorShortcut(press({ key: "z", metaKey: true }))).toEqual({ kind: "undo" });
});
test("Ctrl/Cmd+Shift+Z 与 Ctrl/Cmd+Y 都是重做", () => {
expect(resolveDirectorShortcut(press({ key: "z", ctrlKey: true, shiftKey: true }))).toEqual({ kind: "redo" });
expect(resolveDirectorShortcut(press({ key: "Z", metaKey: true, shiftKey: true }))).toEqual({ kind: "redo" });
expect(resolveDirectorShortcut(press({ key: "y", ctrlKey: true }))).toEqual({ kind: "redo" });
});
test("裸 Z/Y 不触发撤销重做", () => {
for (const key of ["z", "y"]) expect(resolveDirectorShortcut(press({ key }))).toBeNull();
});
});
describe("修饰键与未知键边界", () => {
test("Alt 组合一律不解析,留给系统", () => {
expect(resolveDirectorShortcut(press({ key: "w", altKey: true }))).toBeNull();
expect(resolveDirectorShortcut(press({ key: "z", ctrlKey: true, altKey: true }))).toBeNull();
});
test("未映射的键返回 null", () => {
for (const key of ["q", "1", "ArrowUp", "Tab", "", "F5"]) {
expect(resolveDirectorShortcut(press({ key }))).toBeNull();
}
});
});
describe("交互控件语境不劫持按键", () => {
test("isInteractiveTarget 为真时所有快捷键都不解析", () => {
for (const key of ["w", "Delete", "Backspace", "h", "Escape", " "]) {
expect(resolveDirectorShortcut(press({ key, isInteractiveTarget: true }))).toBeNull();
}
expect(resolveDirectorShortcut(press({ key: "z", ctrlKey: true, isInteractiveTarget: true }))).toBeNull();
});
test("文本输入控件判为交互语境", () => {
expect(isDirectorInteractiveNode({ tagName: "INPUT" })).toBe(true);
expect(isDirectorInteractiveNode({ tagName: "textarea" })).toBe(true);
expect(isDirectorInteractiveNode({ tagName: "SELECT" })).toBe(true);
expect(isDirectorInteractiveNode({ isContentEditable: true })).toBe(true);
});
test("BUTTON 与 A 判为交互语境:Space/Delete 属于控件自己", () => {
expect(isDirectorInteractiveNode({ tagName: "BUTTON" })).toBe(true);
expect(isDirectorInteractiveNode({ tagName: "button" })).toBe(true);
expect(isDirectorInteractiveNode({ tagName: "A" })).toBe(true);
expect(isDirectorInteractiveNode({ tagName: "OPTION" })).toBe(true);
expect(isDirectorInteractiveNode({ tagName: "LABEL" })).toBe(true);
expect(isDirectorInteractiveNode({ tagName: "SUMMARY" })).toBe(true);
});
test("常见 role 型控件判为交互语境", () => {
const roles = ["textbox", "combobox", "spinbutton", "searchbox", "button", "switch", "tab", "menuitem", "menuitemcheckbox", "menuitemradio", "option", "slider", "checkbox", "radio", "link", "listbox", "menu", "radiogroup", "treeitem"];
for (const role of roles) {
expect(isDirectorInteractiveNode({ tagName: "DIV", getAttribute: (name: string) => (name === "role" ? role : null) })).toBe(true);
}
});
test("contenteditable 属性形式也判为交互语境", () => {
for (const value of ["", "true"]) {
expect(isDirectorInteractiveNode({ tagName: "DIV", getAttribute: (name: string) => (name === "contenteditable" ? value : null) })).toBe(true);
}
expect(isDirectorInteractiveNode({ tagName: "DIV", getAttribute: (name: string) => (name === "contenteditable" ? "false" : null) })).toBe(false);
});
test("普通元素、未知 role 与非法目标不判为交互语境", () => {
expect(isDirectorInteractiveNode({ tagName: "DIV", getAttribute: () => null })).toBe(false);
expect(isDirectorInteractiveNode({ tagName: "SPAN" })).toBe(false);
expect(isDirectorInteractiveNode({ tagName: "DIV", getAttribute: () => "presentation" })).toBe(false);
expect(isDirectorInteractiveNode(null)).toBe(false);
expect(isDirectorInteractiveNode(undefined)).toBe(false);
expect(isDirectorInteractiveNode("input")).toBe(false);
});
});
describe("blocksDirectorShortcut:交互祖先同样阻止全局快捷键", () => {
test("有 closest 时用选择器向上找,命中祖先即阻止", () => {
// 按钮里的 <svg>/<span>:event.target 是子元素,只判自身会漏掉整个控件。
const queried: string[] = [];
const nestedChild = {
tagName: "svg",
closest: (selector: string) => {
queried.push(selector);
return { tagName: "BUTTON" };
},
};
expect(blocksDirectorShortcut(nestedChild)).toBe(true);
expect(queried).toEqual([DIRECTOR_INTERACTIVE_SELECTOR]);
});
test("closest 没命中交互祖先时放行", () => {
const plainChild = { tagName: "SPAN", closest: () => null };
expect(blocksDirectorShortcut(plainChild)).toBe(false);
});
test("选择器覆盖标签、role 与 contenteditable 三类", () => {
for (const fragment of ["button", "a", "input", "textarea", "select", "option", "label", "summary"]) {
expect(DIRECTOR_INTERACTIVE_SELECTOR.split(",")).toContain(fragment);
}
for (const role of ["button", "switch", "tab", "menuitem", "option", "slider", "checkbox", "radio"]) {
expect(DIRECTOR_INTERACTIVE_SELECTOR).toContain(`[role="${role}"]`);
}
expect(DIRECTOR_INTERACTIVE_SELECTOR).toContain('[contenteditable="true"]');
});
test("没有 closest 的目标退化为单节点判定", () => {
expect(blocksDirectorShortcut({ tagName: "BUTTON" })).toBe(true);
expect(blocksDirectorShortcut({ tagName: "DIV", getAttribute: () => null })).toBe(false);
expect(blocksDirectorShortcut(null)).toBe(false);
expect(blocksDirectorShortcut("button")).toBe(false);
});
test("普通 DIV 容器仍允许全局快捷键:视口空白处按 W/Delete 必须生效", () => {
const viewportBackdrop = { tagName: "DIV", closest: () => null };
expect(blocksDirectorShortcut(viewportBackdrop)).toBe(false);
expect(resolveDirectorShortcut(press({ key: "w", isInteractiveTarget: blocksDirectorShortcut(viewportBackdrop) }))).toEqual({ kind: "transform-mode", mode: "translate" });
expect(resolveDirectorShortcut(press({ key: "Delete", isInteractiveTarget: blocksDirectorShortcut(viewportBackdrop) }))).toEqual({ kind: "delete-selected" });
});
});
describe("releaseDirectorFocusAfterPointer:点完工具按钮后快捷键必须立刻可用", () => {
const fakeButton = () => {
let blurred = 0;
return {
blur: () => {
blurred += 1;
},
blurCount: () => blurred,
};
};
test("鼠标激活(detail > 0)释放焦点", () => {
const target = fakeButton();
releaseDirectorFocusAfterPointer({ detail: 1, currentTarget: target });
expect(target.blurCount()).toBe(1);
});
test("键盘激活(detail === 0)保留焦点,Tab 序列不断", () => {
const target = fakeButton();
releaseDirectorFocusAfterPointer({ detail: 0, currentTarget: target });
expect(target.blurCount()).toBe(0);
});
test("双击等多次点击仍然释放焦点", () => {
const target = fakeButton();
releaseDirectorFocusAfterPointer({ detail: 2, currentTarget: target });
expect(target.blurCount()).toBe(1);
});
test("目标没有 blur 方法时不抛异常", () => {
expect(() => releaseDirectorFocusAfterPointer({ detail: 1, currentTarget: {} })).not.toThrow();
});
test("释放焦点后守卫不再拦截:这正是修复的行为", () => {
// 焦点在按钮上 -> 拦截;释放到视口容器 -> 放行。
const button = { tagName: "BUTTON", closest: () => ({ tagName: "BUTTON" }) };
expect(resolveDirectorShortcut(press({ key: "Delete", isInteractiveTarget: blocksDirectorShortcut(button) }))).toBeNull();
const bodyAfterBlur = { tagName: "BODY", closest: () => null };
expect(resolveDirectorShortcut(press({ key: "Delete", isInteractiveTarget: blocksDirectorShortcut(bodyAfterBlur) }))).toEqual({ kind: "delete-selected" });
});
});

View File

@@ -0,0 +1,236 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
/**
* 生产接线回归:模板与模式的领域函数正确,不代表真实入口用上了。
* 这里锁住「新建必须选模板」「已有场景不弹模板」「时间轴只在动画模式」三条链路。
*/
const workbench = readFileSync(resolve(import.meta.dir, "../src/components/canvas/director/canvas-director-workbench.tsx"), "utf8");
const dock = readFileSync(resolve(import.meta.dir, "../src/components/canvas/director/director-viewport-dock.tsx"), "utf8");
const viewport = readFileSync(resolve(import.meta.dir, "../src/components/canvas/director/director-viewport.tsx"), "utf8");
const hook = readFileSync(resolve(import.meta.dir, "../src/pages/canvas/use-canvas-director.ts"), "utf8");
const project = readFileSync(resolve(import.meta.dir, "../src/pages/canvas/project.tsx"), "utf8");
const modal = readFileSync(resolve(import.meta.dir, "../src/components/canvas/director/canvas-director-template-modal.tsx"), "utf8");
const store = readFileSync(resolve(import.meta.dir, "../src/stores/canvas/use-director-workbench-store.ts"), "utf8");
const styles = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8");
function slice(source: string, from: string, to: string) {
const start = source.indexOf(from);
expect(start).toBeGreaterThanOrEqual(0);
const end = source.indexOf(to, start + from.length);
expect(end).toBeGreaterThan(start);
return source.slice(start, end);
}
describe("新建场景必须显式选模板", () => {
test("createDirectorShot 第一个参数是 templateId,没有默认值", () => {
expect(hook).toContain("const createDirectorShot = useCallback((templateId: DirectorTemplateId, position?: Position) => {");
// 有默认模板等于又回到「无条件塞演员」。
expect(hook).not.toContain("templateId: DirectorTemplateId = ");
});
test("新建走模板工厂,不再走带默认演员的 createDirectorScene", () => {
expect(hook).toContain("createDirectorSceneFromTemplate(templateId, `镜头 ${shotIndex}`)");
expect(hook).not.toContain("createDirectorScene(");
});
test("两个新建入口都先开模板选择,而不是直接建场景", () => {
expect(project).toContain("onOpenDirector={() => setDirectorTemplateRequest({})}");
expect(project).toContain("onOpenDirector={(position) => setDirectorTemplateRequest({ position })}");
expect(project).not.toContain("onOpenDirector={() => createDirectorShot()}");
expect(project).not.toContain("onOpenDirector={createDirectorShot}");
});
test("选中模板后带着 position 建场景", () => {
const element = slice(project, "<CanvasDirectorTemplateModal", "/>");
expect(element).toContain("open={Boolean(directorTemplateRequest)}");
expect(element).toContain("onSelect={(templateId) => createDirectorShot(templateId, directorTemplateRequest?.position)}");
});
test("模板弹窗把 5 个模板全列出来,没有「默认」快捷项", () => {
expect(modal).toContain("DIRECTOR_TEMPLATES.map");
expect(modal).toContain("onSelect(template.id)");
expect(modal).not.toContain("默认模板");
});
});
describe("已有场景不触发模板选择", () => {
test("openDirectorWorkbench 命中已存在场景时不建新场景、不弹模板", () => {
const opener = slice(hook, "const openDirectorWorkbench = useCallback", "/** 每次保存都基于 store");
// 只有找不到场景(孤儿节点修复)才补建。
expect(opener).toContain("if (!scene) {");
expect(opener).toContain('createDirectorSceneFromTemplate("empty"');
expect(opener).not.toContain("setDirectorTemplateRequest");
});
test("孤儿修复用空场景兜底:用户没选过就不许塞演员", () => {
const opener = slice(hook, "const openDirectorWorkbench = useCallback", "/** 每次保存都基于 store");
expect(opener).not.toContain('createDirectorSceneFromTemplate("monologue"');
expect(opener).not.toContain("createDirectorActor");
});
test("workbench 自身不含模板选择逻辑:打开已保存场景不改写内容", () => {
expect(workbench).not.toContain("DIRECTOR_TEMPLATES");
expect(workbench).not.toContain("createDirectorSceneFromTemplate");
});
});
describe("模式接线", () => {
test("workbench 从 store 读 mode,并按 capabilities 派生显示", () => {
expect(workbench).toContain("const mode = useDirectorWorkbenchStore((state) => state.mode);");
expect(workbench).toContain("const capabilities = directorModeCapabilities(mode);");
});
test("时间轴只在 capabilities.timeline 为真时渲染", () => {
expect(workbench).toContain("{capabilities.timeline ? <DirectorSequencer");
});
test("动画模式把 Transform 轨迹接入视口,隐藏演员和零长度轨迹不显示", () => {
expect(workbench).toContain("showMotionPaths={capabilities.timeline}");
expect(viewport).toContain('object.visible && (object.kind === "actor" || object.primitive === "character")');
expect(viewport).toContain("directorTransformPathLength(object.keyframes) > 0.001");
expect(viewport).toContain("<Line points={points}");
expect(viewport).toContain("interpolateDirectorTransform(sorted[0].transform, sorted, playhead).position");
});
test("骨骼/姿势入口只对演员开放,且由 bones 把关", () => {
expect(workbench).toContain('{capabilities.bones && (object.kind === "actor" || object.primitive === "character") ? <>');
// motionClips 不得再作为放行条件:带动画的普通模型不是演员。
expect(workbench).not.toContain('object.primitive === "character" || motionClips.length) ? <>');
});
test("姿态模式提供全身与当前骨骼重置,不删除动画轨道", () => {
const inspector = slice(workbench, "function ObjectInspector(", "function LightInspector(");
expect(inspector).toContain('onClick={() => applyPose("stand")}>重置姿态</Button>');
expect(inspector).toContain("delete boneOverrides[selectedBoneId]");
expect(inspector).toContain(">重置当前骨骼</Button>");
expect(inspector).not.toContain("boneTracks: []");
});
test("动作片段与骨骼入口解耦:任何带 Clip 的对象都能调播放速度/循环", () => {
expect(workbench).toContain('{motionClips.length ? <><Field label="动作片段">');
});
test("关键帧入口由 keyframes 把关", () => {
expect(workbench).toContain("{capabilities.keyframes ? <>");
});
test("渲染视图下拉按当前模式过滤,而不是写死五项", () => {
expect(workbench).toContain("DIRECTOR_RENDER_MODE_LABELS.filter((option) => capabilities.renderModes.includes(option.value))");
expect(workbench).toContain("options={renderModeOptions}");
});
test("dock 不是绕过模式门控的第二条路径:渲染视图按钮同样按 renderModes 过滤", () => {
expect(dock).toContain("renderModes: DirectorRenderMode[];");
expect(dock).toContain("RENDER_VIEW_BUTTONS.filter((item) => renderModes.includes(item.mode))");
// 写死的按钮会绕过门控。
expect(dock).not.toContain('onClick={() => onRenderModeChange("pose")}');
expect(workbench).toContain("renderModes={capabilities.renderModes}");
});
test("store 层夹住 renderMode:任何路径都无法设置当前模式不允许的视图", () => {
expect(store).toContain("setRenderMode: (renderMode) => set((state) => (directorModeCapabilities(state.mode).renderModes.includes(renderMode) ? { renderMode } : {})),");
});
test("摄影机模式固定显示 shot/camera 检查器", () => {
expect(workbench).toContain("selectedObject && !capabilities.cameraTools ?");
expect(workbench).toContain("selectedLight && !capabilities.cameraTools ?");
});
test("运镜生成只更新首尾帧并提示到动画模式继续编辑,不清空手工关键帧", () => {
expect(workbench).toContain("resolveDirectorCameraMoveKeyframes(item.keyframes");
expect(workbench).toContain("已更新运镜首尾关键帧,可在动画模式继续编辑");
expect(workbench).not.toContain("keyframes: [{ id: nanoid(), time: 0, transform: start }");
});
test("小屏把属性检查器放到下方而不是隐藏,姿态与骨骼入口仍可达", () => {
expect(workbench).toContain("max-lg:col-span-2 max-lg:max-h-[40vh] max-lg:border-l-0 max-lg:border-t");
expect(workbench).not.toContain("border-l max-lg:hidden");
});
test("store 的 setMode 走 resolveDirectorModeTransition,清理不靠组件自觉", () => {
expect(store).toContain("setMode: (mode) => set((state) => resolveDirectorModeTransition({ mode, playing: state.playing, autoKey: state.autoKey, renderMode: state.renderMode })),");
});
test("mode 不写进 DirectorScene:类型文件里没有 mode 字段", () => {
const types = readFileSync(resolve(import.meta.dir, "../src/types/director.ts"), "utf8");
const sceneType = slice(types, "export type DirectorScene = {", "};");
expect(sceneType).not.toContain("mode");
});
test("切模式不重建会话:初始化 effect 的依赖里没有 mode", () => {
const initEffect = slice(workbench, "// 会话初始化只认 scene id", "// 打开会话时检查合法本地恢复候选");
// 依赖里出现 mode 就意味着换视图会 resetWorkbench + 清空 history。
expect(initEffect).toContain("}, [open, resetWorkbench, scene, writeDraft]);");
expect(initEffect).not.toContain("mode");
});
test("draft/history/save 的生命周期 effect 一律不依赖 mode", () => {
// 逐个锁住依赖数组:任一处混入 mode,切模式就会掉草稿或掉历史。
expect(workbench).toContain("}, [message, modal, open, scene, writeDraft]);");
expect(workbench).toContain("}, [mirrorDraft, stagedTransaction]);");
expect(workbench).toContain("}, [mirrorDraft]);");
// 快捷键监听只随 open 装卸,不随 mode 反复重挂。
expect(workbench).toContain("}, [open]);");
});
});
describe("异步导演台输出使用最新权威状态", () => {
test("上传后重新核验节点与场景,并把预览引用合入最新 scene", () => {
expect(hook).toContain("const sourceNodeAtStart = nodesRef.current.find");
expect(hook).toContain("const outputProjectId = projectId;");
expect(hook).toContain("projectIdRef.current !== outputProjectId");
expect(hook).toContain("const outputProject = useCanvasStore.getState().projects.find((item) => item.id === outputProjectId);");
expect(hook).toContain("const sourceNode = nodesRef.current.find((item) => item.id === sourceNodeId);");
expect(hook).toContain("const latestScene = outputProject?.directorScenes.find");
expect(hook).toContain("mergeDirectorOutputPreview(latestScene");
expect(hook).toContain("saveDirectorScene(mergedScene);");
expect(hook).not.toContain("saveDirectorScene({ ...output.scene");
});
});
describe("模式控件可发现、可键盘、小屏可达", () => {
const nav = slice(workbench, '<nav className="director-mode-switch"', "</nav>");
test("是真实 button 且用 aria-pressed 表达当前模式", () => {
expect(nav).toContain('type="button"');
expect(nav).toContain("aria-pressed={mode === item.mode}");
});
test("不用 primary 表示普通选中,只加 is-active class", () => {
expect(nav).toContain('className={`director-mode-switch-button ${mode === item.mode ? "is-active" : ""}`}');
expect(nav).not.toContain('type="primary"');
});
test("小屏不隐藏:整个开关没有 max-lg:hidden", () => {
expect(nav).not.toContain("max-lg:hidden");
});
test("鼠标点选后释放焦点:否则交互控件守卫会吃掉 W/E/R/Delete", () => {
expect(nav).toContain("releaseDirectorFocusAfterPointer(event)");
});
test("焦点释放规则集中在共享 helper,不在按钮里各写一遍 blur", () => {
// 无条件 blur 会破坏键盘 Tab 序列,判据必须留在 helper 内部。
expect(nav).not.toContain("event.currentTarget.blur()");
});
test("有可见文字标签与 hint title,不是纯图标", () => {
expect(nav).toContain("{item.label}");
expect(nav).toContain("title={item.hint}");
expect(nav).toContain('aria-label="导演台模式"');
});
test("带 data-mode 测试锚点:E2E 不靠中文文案定位模式按钮", () => {
expect(nav).toContain("data-mode={item.mode}");
});
test("模式与模板样式只用主题感知语义 token,不新增硬编码颜色", () => {
const block = slice(styles, "/* 一级模式切换。", ".director-actor-colors {");
expect(block).toContain("var(--control-selected-bg)");
expect(block).toContain("var(--control-focus-ring)");
expect(block).not.toMatch(/rgba?\(/);
expect(block).not.toMatch(/#[0-9a-fA-F]{3,8}/);
});
});

View File

@@ -0,0 +1,166 @@
import { describe, expect, test } from "bun:test";
import { resolveDirectorPlacement } from "../src/lib/canvas/director/director-placement";
import { createDirectorScene } from "../src/lib/canvas/director/director-scene";
import { DIRECTOR_TEMPLATES, createDirectorSceneFromTemplate, type DirectorTemplateId } from "../src/lib/canvas/director/director-templates";
import type { DirectorScene } from "../src/types/director";
const ALL_IDS: DirectorTemplateId[] = ["empty", "monologue", "dialogue", "blocking", "product"];
/**
* 用生产的 resolveDirectorPlacement 当判据:
* 把某个对象「原地重新摆一次」,若返回值仍是原位置,说明它与其余对象不相交。
* 这样重叠判据与真实摆放逻辑天然一致,测试里不再复制一份 overlap 数学。
*/
function overlappingNames(scene: DirectorScene): string[] {
return scene.objects
.filter((object) => {
const others = scene.objects.filter((item) => item.id !== object.id);
const resolved = resolveDirectorPlacement({ object, existing: others });
return resolved[0] !== object.transform.position[0] || resolved[2] !== object.transform.position[2];
})
.map((object) => object.name);
}
describe("模板目录", () => {
test("恰好提供 5 个模板,顺序固定且空场景在最前", () => {
expect(DIRECTOR_TEMPLATES.map((item) => item.id)).toEqual(ALL_IDS);
});
test("每个模板都有名称、摘要与说明,供选择时判断", () => {
for (const template of DIRECTOR_TEMPLATES) {
expect(template.name.length).toBeGreaterThan(0);
expect(template.summary.length).toBeGreaterThan(0);
expect(template.description.length).toBeGreaterThan(0);
}
});
});
describe("空场景真的没有演员", () => {
test("empty 模板不含任何对象", () => {
const scene = createDirectorSceneFromTemplate("empty");
expect(scene.objects).toEqual([]);
});
test("未知模板 id 回落到空场景,绝不偷偷塞回默认演员", () => {
const scene = createDirectorSceneFromTemplate("nope" as DirectorTemplateId);
expect(scene.objects).toEqual([]);
});
test("对比:兼容用的 createDirectorScene 仍然带默认演员(P0 依赖它,不能改)", () => {
expect(createDirectorScene("兼容").objects).toHaveLength(1);
});
});
describe("每个模板都生成可用的 camera / light / shot 布局", () => {
test("都恰好一台摄影机、一个镜头,且 shot 指向该摄影机", () => {
for (const id of ALL_IDS) {
const scene = createDirectorSceneFromTemplate(id);
expect(scene.cameras).toHaveLength(1);
expect(scene.shots).toHaveLength(1);
expect(scene.shots[0].cameraId).toBe(scene.cameras[0].id);
expect(scene.activeShotId).toBe(scene.shots[0].id);
}
});
test("都带三点布光,且含一盏环境光", () => {
for (const id of ALL_IDS) {
const scene = createDirectorSceneFromTemplate(id);
expect(scene.lights).toHaveLength(3);
expect(scene.lights.some((light) => light.type === "ambient")).toBe(true);
}
});
test("摄影机 focalLength 与 fov 自洽,不会出现焦段与视角矛盾", () => {
for (const id of ALL_IDS) {
const camera = createDirectorSceneFromTemplate(id).cameras[0];
const expected = (2 * Math.atan(36 / (2 * camera.focalLength)) * 180) / Math.PI;
expect(camera.fov).toBeCloseTo(expected, 6);
}
});
test("演员数量符合模板语义:单人 1、双人 2、走位 3、产品 0", () => {
const actorCount = (id: DirectorTemplateId) => createDirectorSceneFromTemplate(id).objects.filter((object) => object.kind === "actor").length;
expect(actorCount("monologue")).toBe(1);
expect(actorCount("dialogue")).toBe(2);
expect(actorCount("blocking")).toBe(3);
expect(actorCount("product")).toBe(0);
});
test("产品模板含产品主体与背景板,且没有演员", () => {
const scene = createDirectorSceneFromTemplate("product");
expect(scene.objects.map((object) => object.name)).toEqual(["产品主体", "背景板", "侧面道具"]);
expect(scene.objects.every((object) => object.kind !== "actor")).toBe(true);
});
});
describe("对象不重叠", () => {
test("双人、走位、产品模板内部两两不相交", () => {
for (const id of ["dialogue", "blocking", "product"] as DirectorTemplateId[]) {
expect(overlappingNames(createDirectorSceneFromTemplate(id))).toEqual([]);
}
});
test("全部模板都满足不相交(含单人与空场景的退化情形)", () => {
for (const id of ALL_IDS) {
expect(overlappingNames(createDirectorSceneFromTemplate(id))).toEqual([]);
}
});
test("双人模板两名演员面向彼此", () => {
const [left, right] = createDirectorSceneFromTemplate("dialogue").objects;
// 左侧演员在 -X,朝 +X;右侧在 +X,朝 -X。
expect(left.transform.position[0]).toBeLessThan(0);
expect(right.transform.position[0]).toBeGreaterThan(0);
expect(left.transform.rotation[1]).toBeCloseTo(Math.PI / 2, 6);
expect(right.transform.rotation[1]).toBeCloseTo(-Math.PI / 2, 6);
});
});
describe("确定性与 id 独立", () => {
test("同模板两次生成的结构完全一致(忽略 id 与时间戳)", () => {
const strip = (scene: DirectorScene) => JSON.stringify(scene, (key, value) => (key === "id" || key === "cameraId" || key === "activeShotId" || key === "createdAt" || key === "updatedAt" ? "<volatile>" : value));
for (const id of ALL_IDS) {
expect(strip(createDirectorSceneFromTemplate(id))).toBe(strip(createDirectorSceneFromTemplate(id)));
}
});
test("每个实例的 id 都独立:两次生成不共享任何 id", () => {
for (const id of ALL_IDS) {
const first = createDirectorSceneFromTemplate(id);
const second = createDirectorSceneFromTemplate(id);
const ids = (scene: DirectorScene) => [scene.id, ...scene.objects.map((o) => o.id), ...scene.cameras.map((c) => c.id), ...scene.lights.map((l) => l.id), ...scene.shots.map((s) => s.id)];
expect(ids(first).some((value) => ids(second).includes(value))).toBe(false);
}
});
test("单个场景内部 id 不自撞", () => {
for (const id of ALL_IDS) {
const scene = createDirectorSceneFromTemplate(id);
const all = [scene.id, ...scene.objects.map((o) => o.id), ...scene.cameras.map((c) => c.id), ...scene.lights.map((l) => l.id), ...scene.shots.map((s) => s.id)];
expect(new Set(all).size).toBe(all.length);
}
});
test("title 透传,未给时用默认名", () => {
expect(createDirectorSceneFromTemplate("empty", "镜头 7").title).toBe("镜头 7");
expect(createDirectorSceneFromTemplate("empty").title).toBe("未命名场景");
});
});
describe("不升级 schema", () => {
test("version 恒为 1,字段集合与兼容 factory 完全一致", () => {
const legacy = Object.keys(createDirectorScene("legacy")).toSorted();
for (const id of ALL_IDS) {
const scene = createDirectorSceneFromTemplate(id);
expect(scene.version).toBe(1);
expect(Object.keys(scene).toSorted()).toEqual(legacy);
}
});
test("模板不写入 mode 之类的工作台状态", () => {
for (const id of ALL_IDS) {
expect(Object.keys(createDirectorSceneFromTemplate(id))).not.toContain("mode");
}
});
});

View File

@@ -1,8 +1,17 @@
import { describe, expect, test } from "bun:test";
import { Euler, Vector3 } from "three";
import { applyDirectorTransformDelta, directorTransformDelta, resolveDirectorKeyframeRecord, resolveDirectorObjectTransformEdit, snapDirectorTime } from "../src/lib/canvas/director/director-animation-semantics";
import { interpolateDirectorTransform } from "../src/lib/canvas/director/director-scene";
import {
advanceDirectorPlayhead,
applyDirectorTransformDelta,
directorTransformDelta,
resolveDirectorCameraAlignment,
resolveDirectorCameraMoveKeyframes,
resolveDirectorKeyframeRecord,
resolveDirectorObjectTransformEdit,
snapDirectorTime,
} from "../src/lib/canvas/director/director-animation-semantics";
import { createDirectorCamera, directorTransformPathLength, finiteDirectorTransformKeyframes, interpolateDirectorTransform } from "../src/lib/canvas/director/director-scene";
import type { DirectorKeyframe, DirectorTransform } from "../src/types/director";
function transform(position: [number, number, number], rotation: [number, number, number] = [0, 0, 0], scale: [number, number, number] = [1, 1, 1]): DirectorTransform {
@@ -21,6 +30,79 @@ describe("snapDirectorTime", () => {
});
});
describe("摄影机对齐当前视图", () => {
test("无关键帧时更新基础 transform", () => {
const camera = createDirectorCamera();
const aligned = transform([9, 3, 2]);
const next = resolveDirectorCameraAlignment(camera, aligned, 1);
expect(next.transform).toEqual(aligned);
expect(next.keyframes).toEqual([]);
});
test("已有轨迹时写当前时间关键帧,CAM 立即显示对齐结果且不丢其他帧", () => {
const camera = {
...createDirectorCamera(),
keyframes: [keyframe("start", 0, transform([0, 0, 0])), keyframe("end", 2, transform([10, 0, 0]))],
};
const aligned = transform([4, 5, 6]);
const next = resolveDirectorCameraAlignment(camera, aligned, 1);
expect(next.transform).toBe(camera.transform);
expect(next.keyframes).toHaveLength(3);
expect(interpolateDirectorTransform(next.transform, next.keyframes, 1)).toEqual(aligned);
expect(next.keyframes.find((item) => item.id === "start")).toEqual(camera.keyframes[0]);
expect(next.keyframes.find((item) => item.id === "end")).toEqual(camera.keyframes[1]);
});
});
describe("生成摄影机运镜首尾帧", () => {
test("保留手工中间帧、已有 id 与 easing,只更新首尾 transform", () => {
const existing: DirectorKeyframe[] = [{ ...keyframe("start", 0, transform([9, 0, 0])), easing: "step" }, { ...keyframe("manual", 1, transform([4, 2, 0])), easing: "smooth" }, keyframe("end", 2, transform([8, 0, 0]))];
const next = resolveDirectorCameraMoveKeyframes(existing, transform([0, 0, 0]), transform([2, 0, 0]), 2);
expect(next.map((item) => item.id)).toEqual(["start", "manual", "end"]);
expect(next[0].transform.position).toEqual([0, 0, 0]);
expect(next[1]).toEqual(existing[1]);
expect(next[2].transform.position).toEqual([2, 0, 0]);
expect(next[0].easing).toBe("step");
});
test("空轨道生成两枚有序首尾帧", () => {
const next = resolveDirectorCameraMoveKeyframes([], transform([0, 0, 0]), transform([0, 0, -2]), 3);
expect(next.map((item) => item.time)).toEqual([0, 3]);
});
});
describe("播放头循环推进", () => {
test("跨越镜头末尾时保留余量", () => {
expect(advanceDirectorPlayhead(1.9, 0.3, 2)).toBeCloseTo(0.2, 8);
});
test("非法播放头、增量和时长安全回落", () => {
expect(advanceDirectorPlayhead(Number.NaN, 0.5, 2)).toBe(0.5);
expect(advanceDirectorPlayhead(1, Number.NaN, 2)).toBe(1);
expect(advanceDirectorPlayhead(1, 1, 0)).toBe(0);
});
});
describe("Transform 轨迹统计", () => {
test("按时间顺序累计空间路径,不把输入数组顺序当成路径顺序", () => {
const keys = [keyframe("end", 2, transform([3, 4, 0])), keyframe("start", 0, transform([0, 0, 0])), keyframe("middle", 1, transform([0, 4, 0]))];
expect(directorTransformPathLength(keys)).toBe(7);
expect(keys.map((key) => key.id)).toEqual(["end", "start", "middle"]);
});
test("忽略含非法坐标的段,避免 NaN 污染轨迹显示判断", () => {
const keys = [keyframe("valid", 0, transform([0, 0, 0])), keyframe("invalid", 1, transform([Number.NaN, 2, 0])), keyframe("valid-again", 2, transform([3, 4, 0]))];
expect(directorTransformPathLength(keys)).toBe(0);
});
test("轨迹渲染过滤非法时间与坐标,不把 NaN/Infinity 传给 Three", () => {
const keys = [keyframe("start", 0, transform([0, 0, 0])), keyframe("bad-time", Number.NaN, transform([1, 0, 0])), keyframe("bad-position", 1, transform([Number.POSITIVE_INFINITY, 0, 0])), keyframe("end", 2, transform([2, 0, 0]))];
const renderable = finiteDirectorTransformKeyframes(keys);
expect(renderable.map((item) => item.id)).toEqual(["start", "end"]);
expect(renderable.flatMap((item) => [item.time, ...item.transform.position]).every(Number.isFinite)).toBe(true);
});
});
describe("resolveDirectorObjectTransformEdit:Auto Key 打开", () => {
test("只在吸附播放头写单个关键帧,base 与其他关键帧不变", () => {
const base = transform([0, 0, 0]);

View File

@@ -0,0 +1,723 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { Euler, Vector3 } from "three";
import {
DIRECTOR_DEFAULT_VIEW_MODE,
DIRECTOR_VIEW_MODES,
directorUsablePerspectiveProjection,
directorViewFramingKey,
directorViewModeCapabilities,
resolveDirectorActiveCamera,
resolveDirectorActiveShot,
resolveDirectorEffectiveViewport,
resolveDirectorOrthographicFraming,
resolveDirectorOrthographicFrustum,
resolveDirectorViewFraming,
resolveDirectorViewUp,
type DirectorOrthographicFrustum,
type DirectorViewMode,
} from "../src/lib/canvas/director/director-view-modes";
import { createDirectorCamera, createDirectorLight, createDirectorObject, createDirectorScene } from "../src/lib/canvas/director/director-scene";
import type { DirectorObject, DirectorScene, DirectorVec3 } from "../src/types/director";
// 只读取工具栏源码做契约断言(见文末 describe);视口接线不在这次任务范围内 —— 那是集成方要做的事,
// 不该让这份测试的通过与否依赖另一条 pane 正在改动的文件。
const toolbar = readFileSync(resolve(import.meta.dir, "../src/components/canvas/director/director-view-toolbar.tsx"), "utf8");
const AXIS_MODES = ["top", "front", "back", "left", "right"] as const;
function objectAt(position: DirectorVec3): DirectorObject {
const object = createDirectorObject("box", "点云对象", position);
return { ...object, transform: { ...object.transform, scale: [0, 0, 0] } };
}
/** 只用可控的对象点位构图,摄影机/灯光清空,避免默认场景里的固定坐标干扰包围盒断言。 */
function sceneWithObjects(objects: DirectorObject[]): DirectorScene {
return { ...createDirectorScene(), cameras: [], lights: [], objects };
}
describe("取景模式骨架", () => {
test("恰好七个取景模式,顺序为 3D/CAM/TOP/FRONT/BACK/LEFT/RIGHT,模式与标签一一对应", () => {
expect(DIRECTOR_VIEW_MODES.map((item) => item.mode)).toEqual(["free", "camera", "top", "front", "back", "left", "right"]);
expect(DIRECTOR_VIEW_MODES.map((item) => item.label)).toEqual(["3D", "CAM", "TOP", "FRONT", "BACK", "LEFT", "RIGHT"]);
});
test("默认是自由视角:接线前后行为一致", () => {
expect(DIRECTOR_DEFAULT_VIEW_MODE).toBe("free");
});
test("每个模式都有可读提示,保证可发现", () => {
DIRECTOR_VIEW_MODES.forEach((item) => {
expect(item.hint.length).toBeGreaterThan(0);
expect(item.label.length).toBeGreaterThan(0);
});
});
});
describe("能力矩阵", () => {
test("3D 允许环绕、透视投影,且不接管取景", () => {
expect(directorViewModeCapabilities("free")).toEqual({ orbit: true, framed: false, projection: "perspective" });
});
test("CAM 锁死环绕、透视投影,并接管取景", () => {
expect(directorViewModeCapabilities("camera")).toEqual({ orbit: false, framed: true, projection: "perspective" });
});
test("五个正交轴向:锁环绕、不接管取景、必须是正交投影", () => {
AXIS_MODES.forEach((mode) => {
expect(directorViewModeCapabilities(mode)).toEqual({ orbit: false, framed: false, projection: "orthographic" });
});
});
test("未知模式回落默认能力,不返回 undefined", () => {
expect(directorViewModeCapabilities("nope" as DirectorViewMode)).toEqual(directorViewModeCapabilities("free"));
});
});
describe("shot/camera 解析回落", () => {
test("activeShotId 命中时取该 shot 与它的摄影机", () => {
const scene = createDirectorScene();
expect(resolveDirectorActiveShot(scene)?.id).toBe(scene.activeShotId);
expect(resolveDirectorActiveCamera(scene)?.id).toBe(scene.cameras[0].id);
});
test("activeShotId 指向不存在的 shot 时回落第一个 shot", () => {
const scene: DirectorScene = { ...createDirectorScene(), activeShotId: "missing" };
expect(resolveDirectorActiveShot(scene)?.id).toBe(scene.shots[0].id);
});
test("shot 的 cameraId 失效时回落第一台摄影机,而不是返回 null", () => {
const base = createDirectorScene();
const scene: DirectorScene = { ...base, shots: [{ ...base.shots[0], cameraId: "missing" }] };
expect(resolveDirectorActiveCamera(scene)?.id).toBe(base.cameras[0].id);
});
test("空场景返回 null 而不是抛异常", () => {
const scene: DirectorScene = { ...createDirectorScene(), shots: [], cameras: [] };
expect(resolveDirectorActiveShot(scene)).toBeNull();
expect(resolveDirectorActiveCamera(scene)).toBeNull();
});
});
describe("CAM 取景解算", () => {
test("3D 模式不产生取景:自由视角完全不被写入", () => {
expect(resolveDirectorViewFraming({ scene: createDirectorScene(), mode: "free", playhead: 0 })).toBeNull();
});
test("五个正交轴向同样不触发 CAM 取景:framed 只属于 camera", () => {
const scene = createDirectorScene();
AXIS_MODES.forEach((mode) => {
expect(resolveDirectorViewFraming({ scene, mode, playhead: 0 })).toBeNull();
});
});
test("CAM 模式取当前 shot 摄影机的位置、焦点与光学参数", () => {
const scene = createDirectorScene();
const camera = scene.cameras[0];
const framing = resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 });
expect(framing?.cameraId).toBe(camera.id);
expect(framing?.position).toEqual(camera.transform.position);
expect(framing?.target).toEqual(camera.target);
expect(framing?.fov).toBe(camera.fov);
expect(framing?.near).toBe(camera.near);
expect(framing?.far).toBe(camera.far);
});
test("CAM 模式下位置按 playhead 插值:拖时间轴即预览运镜", () => {
const base = createDirectorScene();
const camera = {
...base.cameras[0],
keyframes: [
{ id: "k0", time: 0, transform: { position: [0, 0, 0] as DirectorVec3, rotation: [0, 0, 0] as DirectorVec3, scale: [1, 1, 1] as DirectorVec3 } },
{ id: "k1", time: 2, transform: { position: [10, 0, 0] as DirectorVec3, rotation: [0, 0, 0] as DirectorVec3, scale: [1, 1, 1] as DirectorVec3 } },
],
};
const scene: DirectorScene = { ...base, cameras: [camera] };
expect(resolveDirectorViewFraming({ scene, mode: "camera", playhead: 1 })?.position[0]).toBeCloseTo(5, 5);
expect(resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 })?.position[0]).toBeCloseTo(0, 5);
});
test("空场景在 CAM 模式下返回 null,视口保持自由视角而不是黑屏", () => {
const scene: DirectorScene = { ...createDirectorScene(), shots: [], cameras: [] };
expect(resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 })).toBeNull();
});
test("非法数值(NaN/Infinity)不产生取景:绝不把坏值写进相机", () => {
const base = createDirectorScene();
const broken = { ...createDirectorCamera(), transform: { position: [Number.NaN, 0, 0] as DirectorVec3, rotation: [0, 0, 0] as DirectorVec3, scale: [1, 1, 1] as DirectorVec3 } };
const brokenTarget = { ...createDirectorCamera(), target: [0, Number.POSITIVE_INFINITY, 0] as DirectorVec3 };
const brokenFov = { ...createDirectorCamera(), fov: Number.NaN };
[broken, brokenTarget, brokenFov].forEach((camera) => {
const scene: DirectorScene = { ...base, cameras: [camera], shots: [{ ...base.shots[0], cameraId: camera.id }] };
expect(resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 })).toBeNull();
});
});
test("playhead 非有限时按 0 解算,而不是产出 NaN 取景", () => {
const scene = createDirectorScene();
const framing = resolveDirectorViewFraming({ scene, mode: "camera", playhead: Number.NaN });
expect(framing).not.toBeNull();
expect(framing?.position.every(Number.isFinite)).toBe(true);
});
test("位置与焦点重合时沿摄影机 -Z 造焦点,视线不为零向量", () => {
const base = createDirectorScene();
const camera = { ...createDirectorCamera(), transform: { position: [1, 1, 1] as DirectorVec3, rotation: [0, 0, 0] as DirectorVec3, scale: [1, 1, 1] as DirectorVec3 }, target: [1, 1, 1] as DirectorVec3 };
const scene: DirectorScene = { ...base, cameras: [camera], shots: [{ ...base.shots[0], cameraId: camera.id }] };
const framing = resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 });
expect(framing).not.toBeNull();
expect(framing?.target).toEqual([1, 1, 0]);
});
});
function sceneWithOptics(optics: { fov?: number; near?: number; far?: number }): DirectorScene {
const base = createDirectorScene();
const camera = { ...base.cameras[0], ...optics };
return { ...base, cameras: [camera], shots: [{ ...base.shots[0], cameraId: camera.id }] };
}
describe("透视投影可用性:拒绝写进 Three 的非法 fov/near/far", () => {
test("默认摄影机光学参数可用", () => {
const scene = createDirectorScene();
const camera = scene.cameras[0];
expect(directorUsablePerspectiveProjection({ fov: camera.fov, near: camera.near, far: camera.far })).toBe(true);
expect(resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 })).not.toBeNull();
});
test("非法 fov(0 / 180 / 超出开区间 / 负 / 非有限)不产生取景", () => {
[0, 180, 181, -10, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY].forEach((fov) => {
expect(directorUsablePerspectiveProjection({ fov, near: 0.05, far: 500 })).toBe(false);
expect(resolveDirectorViewFraming({ scene: sceneWithOptics({ fov }), mode: "camera", playhead: 0 })).toBeNull();
});
});
test("非法 near(0 / 负 / 非有限)不产生取景", () => {
[0, -0.1, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY].forEach((near) => {
expect(directorUsablePerspectiveProjection({ fov: 50, near, far: 500 })).toBe(false);
expect(resolveDirectorViewFraming({ scene: sceneWithOptics({ near }), mode: "camera", playhead: 0 })).toBeNull();
});
});
test("far 不大于 near 时不产生取景", () => {
[
{ near: 1, far: 1 },
{ near: 2, far: 1 },
{ near: 0.05, far: 0 },
{ near: 0.05, far: Number.NaN },
].forEach((optics) => {
expect(directorUsablePerspectiveProjection({ fov: 50, ...optics })).toBe(false);
expect(resolveDirectorViewFraming({ scene: sceneWithOptics(optics), mode: "camera", playhead: 0 })).toBeNull();
});
});
test("边界合法:开区间内的小 fov、接近 180 的 fov、极小正 near 且 far 更大", () => {
[
{ fov: 0.1, near: 0.05, far: 500 },
{ fov: 179.9, near: 0.05, far: 500 },
{ fov: 50, near: 1e-6, far: 1e-5 },
{ fov: 50, near: 0.05, far: 0.0500001 },
].forEach((optics) => {
expect(directorUsablePerspectiveProjection(optics)).toBe(true);
const framing = resolveDirectorViewFraming({ scene: sceneWithOptics(optics), mode: "camera", playhead: 0 });
expect(framing).not.toBeNull();
expect(framing?.fov).toBe(optics.fov);
expect(framing?.near).toBe(optics.near);
expect(framing?.far).toBe(optics.far);
});
});
});
describe("有效视口回落:CAM 无取景时用 free,恢复后回到 CAM", () => {
test("空 CAM 场景:取景为 null,活动相机与环绕都回落 free,不改 scene", () => {
const scene: DirectorScene = { ...createDirectorScene(), shots: [], cameras: [] };
const snapshot = JSON.parse(JSON.stringify(scene));
const framing = resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 });
expect(framing).toBeNull();
expect(resolveDirectorEffectiveViewport({ mode: "camera", framing })).toEqual({ camera: "free", orbit: true });
expect(scene).toEqual(snapshot);
});
test("合法 → 非法 → 合法:同一决策函数确定性回到 CAM", () => {
const validScene = createDirectorScene();
const valid = resolveDirectorViewFraming({ scene: validScene, mode: "camera", playhead: 0 });
expect(valid).not.toBeNull();
expect(resolveDirectorEffectiveViewport({ mode: "camera", framing: valid })).toEqual({ camera: "camera", orbit: false });
const invalid = resolveDirectorViewFraming({ scene: sceneWithOptics({ fov: 0 }), mode: "camera", playhead: 0 });
expect(invalid).toBeNull();
expect(resolveDirectorEffectiveViewport({ mode: "camera", framing: invalid })).toEqual({ camera: "free", orbit: true });
const recovered = resolveDirectorViewFraming({ scene: validScene, mode: "camera", playhead: 0 });
expect(recovered).not.toBeNull();
expect(resolveDirectorEffectiveViewport({ mode: "camera", framing: recovered })).toEqual({ camera: "camera", orbit: false });
expect(directorViewFramingKey(recovered)).toBe(directorViewFramingKey(valid));
});
test("非法 fov/near/far 与空场景走同一条 free 回落", () => {
const empty = resolveDirectorViewFraming({ scene: { ...createDirectorScene(), shots: [], cameras: [] }, mode: "camera", playhead: 0 });
const badFov = resolveDirectorViewFraming({ scene: sceneWithOptics({ fov: 180 }), mode: "camera", playhead: 0 });
const badNear = resolveDirectorViewFraming({ scene: sceneWithOptics({ near: 0 }), mode: "camera", playhead: 0 });
const badFar = resolveDirectorViewFraming({ scene: sceneWithOptics({ near: 10, far: 1 }), mode: "camera", playhead: 0 });
[empty, badFov, badNear, badFar].forEach((framing) => {
expect(framing).toBeNull();
expect(resolveDirectorEffectiveViewport({ mode: "camera", framing })).toEqual({ camera: "free", orbit: true });
});
});
test("3D 模式始终是 free,即使误传入 CAM 取景也不切换相机", () => {
const framing = resolveDirectorViewFraming({ scene: createDirectorScene(), mode: "camera", playhead: 0 });
expect(resolveDirectorEffectiveViewport({ mode: "free", framing })).toEqual({ camera: "free", orbit: true });
expect(resolveDirectorEffectiveViewport({ mode: "free", framing: null })).toEqual({ camera: "free", orbit: true });
});
test("五个正交轴向不走 CAM 回落:始终正交且锁环绕", () => {
AXIS_MODES.forEach((mode) => {
expect(resolveDirectorEffectiveViewport({ mode, framing: null })).toEqual({ camera: "orthographic", orbit: false });
});
});
test("回落决策是纯函数,不写 scene / 不产生 history 输入", () => {
const scene = createDirectorScene();
const objectsRef = scene.objects;
const camerasRef = scene.cameras;
const snapshot = JSON.parse(JSON.stringify(scene));
const framing = resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 });
resolveDirectorEffectiveViewport({ mode: "camera", framing });
resolveDirectorEffectiveViewport({ mode: "camera", framing: null });
expect(scene).toEqual(snapshot);
expect(scene.objects).toBe(objectsRef);
expect(scene.cameras).toBe(camerasRef);
});
});
describe("up 向量:保住荷兰角,且绝不与视线共线", () => {
test("无 roll 时就是世界 up", () => {
expect(resolveDirectorViewUp([0, 0, 0], [0, 0, -1])).toEqual([0, 1, 0]);
});
test("有 roll 时 up 随之旋转:裸 lookAt 会丢掉的荷兰角在这里留住", () => {
const up = resolveDirectorViewUp([0, 0, Math.PI / 2], [0, 0, -1]);
expect(up[0]).toBeCloseTo(-1, 5);
expect(up[1]).toBeCloseTo(0, 5);
expect(Math.abs(up[1] - 1)).toBeGreaterThan(0.5);
});
test("正俯视退化时换备选 up,叉积非零(否则 lookAt 解出 NaN 矩阵)", () => {
const view: DirectorVec3 = [0, -1, 0];
const up = resolveDirectorViewUp([0, 0, 0], view);
expect(up).toEqual([0, 0, -1]);
expect(new Vector3(...up).cross(new Vector3(...view)).length()).toBeGreaterThan(1e-6);
});
test("正仰视同样不共线", () => {
const view: DirectorVec3 = [0, 1, 0];
const up = resolveDirectorViewUp([0, 0, 0], view);
expect(new Vector3(...up).cross(new Vector3(...view)).length()).toBeGreaterThan(1e-6);
});
test("视线为零向量时返回世界 up,不返回非法值", () => {
expect(resolveDirectorViewUp([0, 0, 0], [0, 0, 0])).toEqual([0, 1, 0]);
});
test("任意朝向下 up 与视线都不共线:抽样覆盖球面", () => {
const angles = [-Math.PI / 2, -Math.PI / 4, 0, Math.PI / 4, Math.PI / 2, Math.PI];
angles.forEach((pitch) =>
angles.forEach((roll) => {
const rotation: DirectorVec3 = [pitch, 0, roll];
const view = new Vector3(0, 0, -1).applyEuler(new Euler(...rotation)).toArray() as DirectorVec3;
const up = resolveDirectorViewUp(rotation, view);
expect(up.every(Number.isFinite)).toBe(true);
expect(new Vector3(...up).cross(new Vector3(...view)).length()).toBeGreaterThan(1e-6);
}),
);
});
});
describe("取景同步键", () => {
test("无取景时为空串:3D 模式不触发相机回写", () => {
expect(directorViewFramingKey(null)).toBe("");
});
test("同一取景值稳定:对象换身份不引起重同步", () => {
const scene = createDirectorScene();
const first = resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 });
const second = resolveDirectorViewFraming({ scene: { ...scene, cameras: [{ ...scene.cameras[0] }] }, mode: "camera", playhead: 0 });
expect(directorViewFramingKey(first)).toBe(directorViewFramingKey(second));
});
test("playhead 推进导致取景变化时键随之变化", () => {
const base = createDirectorScene();
const camera = {
...base.cameras[0],
keyframes: [
{ id: "k0", time: 0, transform: { position: [0, 0, 0] as DirectorVec3, rotation: [0, 0, 0] as DirectorVec3, scale: [1, 1, 1] as DirectorVec3 } },
{ id: "k1", time: 2, transform: { position: [10, 0, 0] as DirectorVec3, rotation: [0, 0, 0] as DirectorVec3, scale: [1, 1, 1] as DirectorVec3 } },
],
};
const scene: DirectorScene = { ...base, cameras: [camera] };
const at0 = directorViewFramingKey(resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 }));
const at1 = directorViewFramingKey(resolveDirectorViewFraming({ scene, mode: "camera", playhead: 1 }));
expect(at0).not.toBe(at1);
});
test("换摄影机时键变化:切 shot 必须重新取景", () => {
const base = createDirectorScene();
const other = createDirectorCamera("副摄影机");
const scene: DirectorScene = { ...base, cameras: [other], shots: [{ ...base.shots[0], cameraId: other.id }] };
const first = directorViewFramingKey(resolveDirectorViewFraming({ scene: base, mode: "camera", playhead: 0 }));
const second = directorViewFramingKey(resolveDirectorViewFraming({ scene, mode: "camera", playhead: 0 }));
expect(first).not.toBe(second);
});
});
describe("正交轴向取景:可用性与基本形状", () => {
test("free/camera 不是正交轴向,返回 null", () => {
const scene = createDirectorScene();
expect(resolveDirectorOrthographicFraming({ scene, mode: "free" })).toBeNull();
expect(resolveDirectorOrthographicFraming({ scene, mode: "camera" })).toBeNull();
});
test("五个正交轴向都返回非空取景,且数值字段全部有限", () => {
const scene = createDirectorScene();
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode });
expect(framing).not.toBeNull();
expect([...framing!.position, ...framing!.target, ...framing!.up, framing!.horizontalSpan, framing!.verticalSpan, framing!.near, framing!.far].every(Number.isFinite)).toBe(true);
});
});
test("near 恒小于 far,且都是有限正数", () => {
const scene = createDirectorScene();
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode })!;
expect(framing.near).toBeGreaterThan(0);
expect(framing.far).toBeGreaterThan(framing.near);
});
});
test("horizontalSpan 与 verticalSpan 恒为正数,绝不退化成 0(否则正交相机变成无限缩放)", () => {
const scene = createDirectorScene();
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode })!;
expect(framing.horizontalSpan).toBeGreaterThan(0);
expect(framing.verticalSpan).toBeGreaterThan(0);
});
});
test("同一输入调用两次结果完全一致:纯函数,值稳定可重复", () => {
const scene = createDirectorScene();
AXIS_MODES.forEach((mode) => {
expect(resolveDirectorOrthographicFraming({ scene, mode })).toEqual(resolveDirectorOrthographicFraming({ scene, mode }));
});
});
});
describe("正交轴向取景:五个方向与 up 严格自洽", () => {
const asymmetric = () => sceneWithObjects([objectAt([-1, -1, -1]), objectAt([3, 3, 3])]);
test("top:看向 -Y,位置在目标正上方,up 为 -Z", () => {
const framing = resolveDirectorOrthographicFraming({ scene: asymmetric(), mode: "top" })!;
expect(framing.up).toEqual([0, 0, -1]);
expect(framing.position[1]).toBeGreaterThan(framing.target[1]);
expect(framing.position[0]).toBeCloseTo(framing.target[0], 5);
expect(framing.position[2]).toBeCloseTo(framing.target[2], 5);
});
test("front:看向 -Z,位置在目标 +Z 一侧,up 为 +Y", () => {
const framing = resolveDirectorOrthographicFraming({ scene: asymmetric(), mode: "front" })!;
expect(framing.up).toEqual([0, 1, 0]);
expect(framing.position[2]).toBeGreaterThan(framing.target[2]);
expect(framing.position[0]).toBeCloseTo(framing.target[0], 5);
});
test("back:看向 +Z,位置在目标 -Z 一侧,up 为 +Y,与 front 正对面", () => {
const framing = resolveDirectorOrthographicFraming({ scene: asymmetric(), mode: "back" })!;
expect(framing.up).toEqual([0, 1, 0]);
expect(framing.position[2]).toBeLessThan(framing.target[2]);
});
test("left:看向 +X,位置在目标 -X 一侧,up 为 +Y", () => {
const framing = resolveDirectorOrthographicFraming({ scene: asymmetric(), mode: "left" })!;
expect(framing.up).toEqual([0, 1, 0]);
expect(framing.position[0]).toBeLessThan(framing.target[0]);
});
test("right:看向 -X,位置在目标 +X 一侧,up 为 +Y,与 left 正对面", () => {
const framing = resolveDirectorOrthographicFraming({ scene: asymmetric(), mode: "right" })!;
expect(framing.up).toEqual([0, 1, 0]);
expect(framing.position[0]).toBeGreaterThan(framing.target[0]);
});
test("up 与视线方向恒定正交,绝不共线", () => {
const scene = asymmetric();
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode })!;
const view = framing.target.map((value, index) => value - framing.position[index]);
const dot = view.reduce((sum, value, index) => sum + value * framing.up[index], 0);
expect(Math.abs(dot)).toBeLessThan(1e-9);
});
});
});
describe("正交轴向取景:包围盒驱动的 target 与 verticalSpan", () => {
test("target 是可见内容的包围盒中心", () => {
const scene = sceneWithObjects([objectAt([-3, 1, -2]), objectAt([5, 4, 6])]);
const framing = resolveDirectorOrthographicFraming({ scene, mode: "front" })!;
expect(framing.target).toEqual([1, 2.5, 2]);
});
test("投影跨度取决于屏幕轴:top=X/Z,front/back=X/Y,left/right=Z/Y", () => {
// x 范围 10,y 范围 4,z 范围 6 —— 三轴都不同,能分辨每一种模式读了哪两根轴。
const scene = sceneWithObjects([objectAt([0, 0, 0]), objectAt([10, 4, 6])]);
const top = resolveDirectorOrthographicFraming({ scene, mode: "top" })!;
const front = resolveDirectorOrthographicFraming({ scene, mode: "front" })!;
const back = resolveDirectorOrthographicFraming({ scene, mode: "back" })!;
const left = resolveDirectorOrthographicFraming({ scene, mode: "left" })!;
const right = resolveDirectorOrthographicFraming({ scene, mode: "right" })!;
expect(top.horizontalSpan / front.horizontalSpan).toBeCloseTo(1, 5);
expect(top.verticalSpan / front.verticalSpan).toBeCloseTo(6 / 4, 5);
expect(front.horizontalSpan).toBeCloseTo(back.horizontalSpan, 9);
expect(front.verticalSpan).toBeCloseTo(back.verticalSpan, 9);
expect(left.horizontalSpan).toBeCloseTo(right.horizontalSpan, 9);
expect(left.verticalSpan).toBeCloseTo(right.verticalSpan, 9);
expect(front.verticalSpan).toBeCloseTo(left.verticalSpan, 9);
expect(left.horizontalSpan / front.horizontalSpan).toBeCloseTo(6 / 10, 5);
expect(top.horizontalSpan / left.horizontalSpan).toBeCloseTo(10 / 6, 5);
});
test("verticalSpan 随包围盒线性缩放:内容放大 2 倍,取景高度也放大 2 倍", () => {
const small = sceneWithObjects([objectAt([-1, -1, -1]), objectAt([1, 1, 1])]);
const large = sceneWithObjects([objectAt([-2, -2, -2]), objectAt([2, 2, 2])]);
const smallSpan = resolveDirectorOrthographicFraming({ scene: small, mode: "front" })!.verticalSpan;
const largeSpan = resolveDirectorOrthographicFraming({ scene: large, mode: "front" })!.verticalSpan;
expect(largeSpan / smallSpan).toBeCloseTo(2, 5);
});
test("对称立方体包围盒下,五个轴向的 verticalSpan 完全相等", () => {
const scene = sceneWithObjects([objectAt([-2, -2, -2]), objectAt([2, 2, 2])]);
const spans = AXIS_MODES.map((mode) => resolveDirectorOrthographicFraming({ scene, mode })!.verticalSpan);
spans.forEach((span) => expect(span).toBeCloseTo(spans[0], 9));
});
test("大比例和旋转后的对象按角点构图,不再只看中心导致裁切", () => {
const wide = createDirectorObject("plane", "背景板", [0, 1, 0]);
wide.transform = { position: [0, 1, 0], rotation: [0, Math.PI / 4, 0], scale: [10, 4, 2] };
const scene = sceneWithObjects([wide]);
const front = resolveDirectorOrthographicFraming({ scene, mode: "front" })!;
const top = resolveDirectorOrthographicFraming({ scene, mode: "top" })!;
expect(front.horizontalSpan).toBeGreaterThan(8);
expect(front.verticalSpan).toBeCloseTo(4 * 1.2, 6);
expect(top.horizontalSpan).toBeGreaterThan(8);
expect(top.verticalSpan).toBeGreaterThan(8);
});
});
describe("正交轴向取景:空场景与非法数据回落", () => {
test("空场景(无对象/摄影机/灯光)回落安全默认包围盒,target 在原点", () => {
const scene = sceneWithObjects([]);
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode })!;
expect(framing).not.toBeNull();
expect(framing.target).toEqual([0, 0, 0]);
expect(framing.horizontalSpan).toBeGreaterThan(0);
expect(framing.verticalSpan).toBeGreaterThan(0);
});
});
test("全部点位非法(NaN/Infinity)时回落安全默认包围盒,而不是产出非 finite 值", () => {
const base = createDirectorScene();
const scene: DirectorScene = {
...base,
objects: [objectAt([Number.NaN, 0, 0]), objectAt([0, Number.POSITIVE_INFINITY, 0])],
cameras: [{ ...createDirectorCamera(), transform: { position: [Number.NaN, Number.NaN, Number.NaN], rotation: [0, 0, 0], scale: [1, 1, 1] } }],
lights: [{ ...createDirectorLight("point", "坏灯光", [0, 0, 0]), transform: { position: [Number.NEGATIVE_INFINITY, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] } }],
};
const framing = resolveDirectorOrthographicFraming({ scene, mode: "front" })!;
expect(framing.target).toEqual([0, 0, 0]);
expect([...framing.position, framing.horizontalSpan, framing.verticalSpan, framing.near, framing.far].every(Number.isFinite)).toBe(true);
});
test("部分点位非法时只用合法点计算包围盒,不被坏值污染", () => {
const scene = sceneWithObjects([objectAt([0, 0, 0]), objectAt([Number.NaN, 5, 0]), objectAt([4, 4, 0])]);
const framing = resolveDirectorOrthographicFraming({ scene, mode: "front" })!;
// 合法点只有 (0,0,0) 与 (4,4,0):中心 (2,2,0)。
expect(framing.target).toEqual([2, 2, 0]);
});
test("隐藏对象不计入包围盒,只有可见内容参与构图", () => {
const visible = objectAt([0, 0, 0]);
const hidden: DirectorObject = { ...objectAt([100, 100, 100]), visible: false };
const scene = sceneWithObjects([visible, hidden]);
const framing = resolveDirectorOrthographicFraming({ scene, mode: "front" })!;
expect(framing.target).toEqual([0, 0, 0]);
});
});
function expectFrustumFits(frustum: DirectorOrthographicFrustum, horizontalSpan: number, verticalSpan: number, aspect: number) {
const width = frustum.right - frustum.left;
const height = frustum.top - frustum.bottom;
expect(frustum.halfWidth).toBeGreaterThan(0);
expect(frustum.halfHeight).toBeGreaterThan(0);
expect([frustum.halfWidth, frustum.halfHeight, frustum.left, frustum.right, frustum.top, frustum.bottom].every(Number.isFinite)).toBe(true);
expect(width).toBeGreaterThanOrEqual(horizontalSpan - 1e-9);
expect(height).toBeGreaterThanOrEqual(verticalSpan - 1e-9);
expect(width / height).toBeCloseTo(aspect, 9);
expect(frustum.left).toBeCloseTo(-frustum.halfWidth, 9);
expect(frustum.right).toBeCloseTo(frustum.halfWidth, 9);
expect(frustum.top).toBeCloseTo(frustum.halfHeight, 9);
expect(frustum.bottom).toBeCloseTo(-frustum.halfHeight, 9);
}
describe("正交视锥:同时装下水平与竖直跨度", () => {
test("极宽内容:正方形视口下半宽度等于水平跨度的一半,而不是高度×aspect", () => {
const scene = sceneWithObjects([objectAt([-40, 0, 0]), objectAt([40, 2, 0])]);
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode })!;
expectFrustumFits(resolveDirectorOrthographicFrustum({ horizontalSpan: framing.horizontalSpan, verticalSpan: framing.verticalSpan, aspect: 1 }), framing.horizontalSpan, framing.verticalSpan, 1);
});
const front = resolveDirectorOrthographicFraming({ scene, mode: "front" })!;
expect(front.horizontalSpan).toBeCloseTo(80 * 1.2, 9);
const frustum = resolveDirectorOrthographicFrustum({ horizontalSpan: front.horizontalSpan, verticalSpan: front.verticalSpan, aspect: 1 });
expect(frustum.halfWidth).toBeCloseTo(front.horizontalSpan / 2, 9);
expect(frustum.halfWidth).toBeGreaterThan(front.verticalSpan / 2);
});
test("极高内容:正方形视口下半高度等于竖直跨度的一半,水平方向按 aspect 补齐", () => {
const scene = sceneWithObjects([objectAt([0, -40, 0]), objectAt([2, 40, 0])]);
const front = resolveDirectorOrthographicFraming({ scene, mode: "front" })!;
expect(front.verticalSpan).toBeCloseTo(80 * 1.2, 9);
const frustum = resolveDirectorOrthographicFrustum({ horizontalSpan: front.horizontalSpan, verticalSpan: front.verticalSpan, aspect: 1 });
expectFrustumFits(frustum, front.horizontalSpan, front.verticalSpan, 1);
expect(frustum.halfHeight).toBeCloseTo(front.verticalSpan / 2, 9);
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode })!;
expectFrustumFits(resolveDirectorOrthographicFrustum({ horizontalSpan: framing.horizontalSpan, verticalSpan: framing.verticalSpan, aspect: 1 }), framing.horizontalSpan, framing.verticalSpan, 1);
});
});
test("窄视口(aspect=0.5)仍同时装下两条跨度,且宽高比保持 0.5", () => {
const scene = sceneWithObjects([objectAt([-5, 0, 0]), objectAt([5, 4, 0])]);
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode })!;
expectFrustumFits(resolveDirectorOrthographicFrustum({ horizontalSpan: framing.horizontalSpan, verticalSpan: framing.verticalSpan, aspect: 0.5 }), framing.horizontalSpan, framing.verticalSpan, 0.5);
});
});
test("宽视口(aspect=2)仍同时装下两条跨度,且宽高比保持 2", () => {
const scene = sceneWithObjects([objectAt([0, 0, 0]), objectAt([4, 4, 4])]);
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode })!;
expectFrustumFits(resolveDirectorOrthographicFrustum({ horizontalSpan: framing.horizontalSpan, verticalSpan: framing.verticalSpan, aspect: 2 }), framing.horizontalSpan, framing.verticalSpan, 2);
});
});
test("空场景回落默认包围盒后,视锥仍有限、为正,并能装下两条跨度", () => {
const scene = sceneWithObjects([]);
AXIS_MODES.forEach((mode) => {
const framing = resolveDirectorOrthographicFraming({ scene, mode })!;
const frustum = resolveDirectorOrthographicFrustum({ horizontalSpan: framing.horizontalSpan, verticalSpan: framing.verticalSpan, aspect: 16 / 9 });
expectFrustumFits(frustum, framing.horizontalSpan, framing.verticalSpan, 16 / 9);
});
});
test("aspect 为 0 / 负 / NaN / ±Infinity 时回落正方形,半范围仍有限且为正", () => {
[0, -1, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY].forEach((aspect) => {
const frustum = resolveDirectorOrthographicFrustum({ horizontalSpan: 10, verticalSpan: 10, aspect });
expectFrustumFits(frustum, 10, 10, 1);
expect(frustum.halfWidth).toBeCloseTo(frustum.halfHeight, 9);
});
});
test("跨度为 0 / 负 / NaN / ±Infinity 时仍给出有限正半范围", () => {
const invalid = [0, -4, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
invalid.forEach((horizontalSpan) =>
invalid.forEach((verticalSpan) => {
const frustum = resolveDirectorOrthographicFrustum({ horizontalSpan, verticalSpan, aspect: 1 });
expect(frustum.halfWidth).toBeGreaterThan(0);
expect(frustum.halfHeight).toBeGreaterThan(0);
expect([frustum.halfWidth, frustum.halfHeight, frustum.left, frustum.right, frustum.top, frustum.bottom].every(Number.isFinite)).toBe(true);
expect(frustum.halfWidth).toBeCloseTo(frustum.halfHeight, 9);
}),
);
});
test("left/right 的极宽内容沿 Z 装入水平跨度,top 的极宽内容沿 X 装入水平跨度", () => {
const alongZ = sceneWithObjects([objectAt([0, 0, -40]), objectAt([0, 2, 40])]);
const left = resolveDirectorOrthographicFraming({ scene: alongZ, mode: "left" })!;
const right = resolveDirectorOrthographicFraming({ scene: alongZ, mode: "right" })!;
expect(left.horizontalSpan).toBeCloseTo(80 * 1.2, 9);
expect(right.horizontalSpan).toBeCloseTo(left.horizontalSpan, 9);
expectFrustumFits(resolveDirectorOrthographicFrustum({ horizontalSpan: left.horizontalSpan, verticalSpan: left.verticalSpan, aspect: 1 }), left.horizontalSpan, left.verticalSpan, 1);
const alongX = sceneWithObjects([objectAt([-40, 0, 0]), objectAt([40, 0, 2])]);
const top = resolveDirectorOrthographicFraming({ scene: alongX, mode: "top" })!;
expect(top.horizontalSpan).toBeCloseTo(80 * 1.2, 9);
expectFrustumFits(resolveDirectorOrthographicFrustum({ horizontalSpan: top.horizontalSpan, verticalSpan: top.verticalSpan, aspect: 1 }), top.horizontalSpan, top.verticalSpan, 1);
});
});
describe("正交轴向取景:绝不写场景", () => {
test("调用后传入的 scene 与其 objects/cameras/lights 数组身份都不变", () => {
const scene = createDirectorScene();
const snapshot = JSON.parse(JSON.stringify(scene));
const objectsRef = scene.objects;
const camerasRef = scene.cameras;
const lightsRef = scene.lights;
(["free", "camera", ...AXIS_MODES] as const).forEach((mode) => {
resolveDirectorOrthographicFraming({ scene, mode });
});
expect(scene).toEqual(snapshot);
expect(scene.objects).toBe(objectsRef);
expect(scene.cameras).toBe(camerasRef);
expect(scene.lights).toBe(lightsRef);
});
});
describe("切换器可发现、可键盘、无新增全局样式", () => {
test("用原生 button 并以 aria-pressed 表达持久取景状态", () => {
expect(toolbar).toContain('type="button"');
expect(toolbar).toContain("aria-pressed={active}");
expect(toolbar).toContain('role="group"');
expect(toolbar).toContain('aria-label="导演台取景模式"');
});
test("每个按钮都有 aria-label 与 title,图标化文字也能被读出", () => {
expect(toolbar).toContain("aria-label={`${item.label} ${item.hint}`}");
expect(toolbar).toContain("title={item.hint}");
});
test("保留键盘焦点环,并尊重 reduced-motion", () => {
expect(toolbar).toContain("focus-visible:outline");
expect(toolbar).toContain("motion-reduce:transition-none");
});
test("点完释放焦点,否则 W/E/R 变换快捷键会被守卫吃掉", () => {
expect(toolbar).toContain("releaseDirectorFocusAfterPointer(event)");
});
test("只用既有 token,不写颜色字面值", () => {
expect(toolbar).toContain("var(--director-dock-surface)");
expect(toolbar).toContain("var(--director-dock-active-surface)");
expect(toolbar).not.toMatch(/#[0-9a-fA-F]{3,8}\b/);
expect(toolbar).not.toMatch(/\brgba?\(/);
});
test("不复用写死正方形尺寸的 dock 按钮类:CAM 是文字标签", () => {
const classNames = [...toolbar.matchAll(/className="([^"]*)"/g)].map((match) => match[1]).join(" ");
expect(classNames.length).toBeGreaterThan(0);
expect(classNames).not.toContain("director-viewport-dock");
});
test("对 DIRECTOR_VIEW_MODES 做整体 map,不写死具体模式或数量:新增/删减模式无需改这个文件", () => {
expect(toolbar).toContain("DIRECTOR_VIEW_MODES.map((item) => {");
expect(toolbar).not.toMatch(/item\.mode\s*===\s*"/);
expect(toolbar).not.toMatch(/DIRECTOR_VIEW_MODES\[\d/);
});
test("七个模式全部渲染:DIRECTOR_VIEW_MODES 有几项,toolbar 就自动出几个按钮", () => {
expect(DIRECTOR_VIEW_MODES).toHaveLength(7);
});
});