Files
ttstd-infinite-canvas/web/test/director-template-mode-wiring.test.ts

252 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from "bun:test";
import { 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, "<CanvasPrevisTemplateModal", "/>");
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("<PrevisSequencer");
expect(workbench.match(/<PrevisSequencer\b/g)).toHaveLength(1);
});
test("动画模式把 Transform 轨迹接入视口,隐藏演员和零长度轨迹不显示", () => {
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("<Line points={points}");
expect(viewport).toContain("interpolatePrevisTransform(sorted[0].transform, sorted, playhead).position");
});
test("骨骼/姿势入口只对演员开放,且由 bones 把关", () => {
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")}> 重置姿态 </Button>');
expect(inspector).toContain("delete boneOverrides[selectedBoneId]");
expect(normalizedInspector).toContain("> 重置当前骨骼 </Button>");
expect(inspector).not.toContain("boneTracks: []");
});
test("动作片段与骨骼入口解耦:任何带 Clip 的对象都能调播放速度/循环", () => {
expect(workbench.replace(/\s+/g, " ")).toContain('{motionClips.length ? ( <> <Field label="动作片段">');
});
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("<PrevisCanvasDock");
expect(canvasDock).not.toContain("onRenderModeChange");
expect(workbench).not.toContain("<PrevisViewportDock");
});
test("store 层夹住 renderMode:任何路径都无法设置当前模式不允许的视图", () => {
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, '<nav className="previs-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={`previs-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("releasePrevisFocusAfterPointer(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, "/* 一级模式切换。", ".previs-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}/);
});
});