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, '