import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { moduleGroupSource } from "./helpers/module-group-source";
/**
* 生产接线回归:模板与模式的领域函数正确,不代表真实入口用上了。
* 这里锁住「新建必须选模板」「已有场景不弹模板」「时间轴只在动画模式」三条链路。
*/
const workbench = moduleGroupSource("components/canvas/previs/canvas-previs-workbench.tsx");
const dock = readFileSync(resolve(import.meta.dir, "../src/components/canvas/previs/previs-viewport-dock.tsx"), "utf8");
const viewport = moduleGroupSource("components/canvas/previs/previs-viewport.tsx");
const hook = readFileSync(resolve(import.meta.dir, "../src/pages/canvas/use-canvas-previs.ts"), "utf8");
const project = moduleGroupSource("pages/canvas/project.tsx");
const modal = readFileSync(resolve(import.meta.dir, "../src/components/canvas/previs/canvas-previs-template-modal.tsx"), "utf8");
const store = readFileSync(resolve(import.meta.dir, "../src/stores/canvas/use-previs-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("createPrevisShot 第一个参数是 templateId,没有默认值", () => {
expect(hook).toContain("const createPrevisShot = useCallback((templateId: PrevisTemplateId, position?: Position) => {");
// 有默认模板等于又回到「无条件塞演员」。
expect(hook).not.toContain("templateId: PrevisTemplateId = ");
});
test("新建走模板工厂,不再走带默认演员的 createPrevisScene", () => {
expect(hook).toContain("createPrevisSceneFromTemplate(templateId, `镜头 ${shotIndex}`)");
expect(hook).not.toContain("createPrevisScene(");
});
test("两个新建入口都先开模板选择,而不是直接建场景", () => {
expect(project).toContain("onOpenPrevis={() => setPrevisTemplateRequest({})}");
expect(project).toContain("onOpenPrevis={(position) => setPrevisTemplateRequest({ position })}");
expect(project).not.toContain("onOpenPrevis={() => createPrevisShot()}");
expect(project).not.toContain("onOpenPrevis={createPrevisShot}");
});
test("选中模板后带着 position 建场景", () => {
const element = slice(project, "");
expect(element).toContain("open={Boolean(previsTemplateRequest)}");
expect(element).toContain("onSelect={(templateId) => createPrevisShot(templateId, previsTemplateRequest?.position)}");
});
test("模板弹窗把 5 个模板全列出来,没有「默认」快捷项", () => {
expect(modal).toContain("PREVIS_TEMPLATES.map");
expect(modal).toContain("onSelect(template.id)");
expect(modal).not.toContain("默认模板");
});
});
describe("已有场景不触发模板选择", () => {
test("openPrevisWorkbench 命中已存在场景时不建新场景、不弹模板", () => {
const opener = slice(hook, "const openPrevisWorkbench = useCallback", "/** 每次保存都基于 store");
// 只有找不到场景(孤儿节点修复)才补建。
expect(opener).toContain("if (!scene) {");
expect(opener).toContain('createPrevisSceneFromTemplate("empty"');
expect(opener).not.toContain("setPrevisTemplateRequest");
});
test("孤儿修复用空场景兜底:用户没选过就不许塞演员", () => {
const opener = slice(hook, "const openPrevisWorkbench = useCallback", "/** 每次保存都基于 store");
expect(opener).not.toContain('createPrevisSceneFromTemplate("monologue"');
expect(opener).not.toContain("createPrevisActor");
});
test("workbench 自身不含模板选择逻辑:打开已保存场景不改写内容", () => {
expect(workbench).not.toContain("PREVIS_TEMPLATES");
expect(workbench).not.toContain("createPrevisSceneFromTemplate");
});
});
describe("模式接线", () => {
test("workbench 从 store 读 mode,并按 capabilities 派生显示", () => {
expect(workbench).toContain("const mode = usePrevisWorkbenchStore((state) => state.mode);");
expect(workbench).toContain("const capabilities = previsModeCapabilities(mode);");
});
test("时间轴只在 capabilities.timeline 为真时渲染", () => {
const timeline = slice(workbench, "{capabilities.timeline ?", ") : null}");
expect(timeline).toContain(" {
expect(workbench).toContain("showMotionPaths={capabilities.timeline}");
expect(viewport).toContain('object.visible && (object.kind === "actor" || object.primitive === "character")');
expect(viewport).toContain("previsTransformPathLength(object.keyframes) > 0.001");
expect(viewport).toContain(" {
const normalizedWorkbench = workbench.replace(/\s+/g, " ");
expect(normalizedWorkbench).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(");
const normalizedInspector = inspector.replace(/\s+/g, " ");
expect(normalizedInspector).toContain('onClick={() => applyPose("stand")}> 重置姿态 ');
expect(inspector).toContain("delete boneOverrides[selectedBoneId]");
expect(normalizedInspector).toContain("> 重置当前骨骼 ");
expect(inspector).not.toContain("boneTracks: []");
});
test("动作片段与骨骼入口解耦:任何带 Clip 的对象都能调播放速度/循环", () => {
expect(workbench.replace(/\s+/g, " ")).toContain('{motionClips.length ? ( <> ');
});
test("关键帧入口由 keyframes 把关", () => {
expect(workbench.replace(/\s+/g, " ")).toContain("{capabilities.keyframes ? ( <>");
});
test("渲染视图下拉按当前模式过滤,而不是写死五项", () => {
expect(workbench).toContain("PREVIS_RENDER_MODE_LABELS.filter((option) => capabilities.renderModes.includes(option.value))");
expect(workbench).toContain("options={renderModeOptions}");
});
test("dock 不是绕过模式门控的第二条路径:渲染视图按钮同样按 renderModes 过滤", () => {
expect(dock).toContain("renderModes: PrevisRenderMode[];");
expect(dock).toContain("RENDER_VIEW_BUTTONS.filter((item) => renderModes.includes(item.mode))");
// 写死的按钮会绕过门控。
expect(dock).not.toContain('onClick={() => onRenderModeChange("pose")}');
// 工作台当前挂的是 PrevisCanvasDock:它不提供渲染视图入口,唯一入口是已按模式过滤的顶栏下拉。
const canvasDock = readFileSync(resolve(import.meta.dir, "../src/components/canvas/previs/previs-canvas-dock.tsx"), "utf8");
expect(workbench).toContain(" {
expect(store).toContain("setRenderMode: (renderMode) => set((state) => (previsModeCapabilities(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("resolvePrevisCameraMoveKeyframes(item.keyframes");
expect(workbench).toContain("已更新运镜首尾关键帧,可在动画模式继续编辑");
expect(workbench).not.toContain("keyframes: [{ id: nanoid(), time: 0, transform: start }");
});
test("小屏通过 dock 开合属性检查器,姿态与骨骼入口仍可达", () => {
const workbenchCss = readFileSync(resolve(import.meta.dir, "../src/components/canvas/previs/canvas-previs-workbench.css"), "utf8");
expect(workbench).toContain('className={`pv-panel pv-panel--right thin-scrollbar ${inspectorOpen ? "is-open" : ""}`}');
expect(workbench).toMatch(/onToggleInspector=\{\(\) =>\s*\(?\s*compactLayout\s*\?\s*setInspectorOpen\(\(value\) => !value\)\s*:\s*setInspectorDocked\(\(value\) => !value\)\s*\)?\s*\}/);
expect(workbenchCss).toMatch(/\.pv-panel--right\.is-open\s*\{\s*transform:\s*translateX\(0\);\s*\}/);
expect(workbench).not.toContain("border-l max-lg:hidden");
});
test("store 的 setMode 走 resolvePrevisModeTransition,清理不靠组件自觉", () => {
expect(store).toContain("setMode: (mode) => set((state) => resolvePrevisModeTransition({ mode, playing: state.playing, autoKey: state.autoKey, renderMode: state.renderMode })),");
});
test("mode 不写进 PrevisScene:类型文件里没有 mode 字段", () => {
const types = readFileSync(resolve(import.meta.dir, "../src/types/previs.ts"), "utf8");
const sceneType = slice(types, "export type PrevisScene = {", "};");
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", () => {
const recovery = slice(workbench, "// 打开会话时检查合法本地恢复候选", "const activeShot =");
const pageHide = slice(workbench, "// 离开页面:", "// 卸载兜底:");
const unmount = slice(workbench, "// 卸载兜底:", "const undo =");
const shortcuts = slice(workbench, "// 预演台是全屏浮层", "/** 对象 transform 编辑的唯一入口");
// 锁住各自的依赖数组,同时允许格式器换行和尾逗号。
expect(recovery).toMatch(/\},\s*\[message,\s*modal,\s*open,\s*scene,\s*writeDraft\],?\s*\);/);
expect(pageHide).toMatch(/\},\s*\[mirrorDraft,\s*stagedTransaction\],?\s*\);/);
expect(unmount).toMatch(/\},\s*\[mirrorDraft\],?\s*\);/);
expect(shortcuts).toMatch(/\},\s*\[open\],?\s*\);/);
});
});
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?.previsScenes.find");
expect(hook).toContain("mergePrevisOutputPreview(latestScene");
expect(hook).toContain("savePrevisScene(mergedScene);");
expect(hook).not.toContain("savePrevisScene({ ...output.scene");
});
});
describe("模式控件可发现、可键盘、小屏可达", () => {
const nav = slice(workbench, '