Appearance
ADR-204: 单测分层与治理规范(拆上帝文件 · 降 mock 密度 · fixtures 复用 · unit/integration 分层)
状态: 🟢 实施中 ⚠️ 被 ADR-256 取代(new-adr.mjs 自动标注) 日期: 2026-07-29 关联: ADR-060(E2E 策略,本 ADR 补齐其下方的单元/集成层)、AGENTS.md(测试路由、代码审核七维、
npm run test入口)、frontend/AGENTS.md(前端子模块纪律) 背景: 单测已达 131 个文件 / ~2000 用例(frontend/src/**/*.test.ts,grep 实测)。总量本身不致命,致命的是结构未分层:用例挤在少数「上帝测试文件」里,mock 密度畸高,且已有的共享 mock 基础设施几乎无人复用。本 ADR 锁定单测层的分层模型、拆分阈值、mock 治理与 fixtures 复用规范,供多 AI 协同渐进落地——只治理结构,不推倒重来。
一、问题边界
1.1 现状清点(2026-07-29 grep 实测)
| 项 | 事实 | 来源 |
|---|---|---|
| 测试文件 | 131 个 *.test.ts | git ls-files 'src/**/*.test.ts' |
| 用例总数 | ~2000 | `Select-String '\b(it |
| 上帝文件(Top5 行数) | menu.test.ts 1551 / env-bridge.test.ts 1312 / perception.test.ts 1244 / model-manager.test.ts 1069 / library-core.test.ts 1057 | 逐文件行数统计 |
| 上帝文件(Top5 用例数) | perception 100 / library-core 99 / menu 95 / model-manager 88 / env-bridge 84 | 逐文件用例统计 |
| Mock 过载(Top) | model-detail-ui.test.ts 92 处 / menu.test.ts 89 / model-manager.test.ts 57 | `Select-String 'vi.(mock |
| 已有共享 mock | src/__tests__/mocks/:babylon-classes.ts、babylon.ts、babylon-mmd-mocks.ts、binding-factories.ts、engine-mock.ts、factories.ts | git ls-files |
| 共享 mock 复用率 | 仅 2/131 个测试文件从 mocks/ 导入 | Select-String "from '.*mocks/'" |
| 分层脚本 | 无 unit/integration 拆分;npm run test = vitest run 全量 | frontend/package.json |
| fixtures 集中层 | 无 src/__tests__/fixtures/ | git ls-files |
1.2 痛点(按杠杆率排序)
- 上帝测试文件:Top5 均破千行、80~100 用例。改一处模块逻辑要在 1500 行里翻找相关用例,违反 AGENTS.md「500 行文件先 grep 定位」的硬约束精神。
- Mock 过载:单文件 92 处打桩 = 测试与实现过度耦合。行为不变的重构会红一大片——测试在测「怎么写」而非「做什么」。
- 共享基础设施空转:已有 6 个共享 mock +
factories.ts,却只有 2 个文件在用。绝大多数测试在各自文件里重复造 mock,这正是 mock 过载的根因。 - 分层缺失:纯逻辑单测(毫秒级)与带 Babylon/DOM 桩的集成测试混跑,反馈慢,且加测试时无「该放哪一层」的指引。
1.3 非目标(防止滑向过度工程)
- 不砍用例数:2000 用例是覆盖资产,本 ADR 只重排结构,不以「减少用例」为 KPI。
- 不引入新测试框架:Vitest 已够用,只做分层脚本 + 目录约定。
- 不强制回填历史:存量按「触碰即改善」渐进迁移,不做一次性大爆炸重写。
二、方案设计
2.1 三层单测模型
在现有 Vitest 之上以目录 + 命名约定分层,不改框架:
| 层 | 定义 | 判据 | 速度目标 | 允许的桩 |
|---|---|---|---|---|
| L1 纯逻辑单测 | 无 DOM、无 Babylon、无 Wails 绑定的纯函数/算法 | 仅 import 叶子模块(@/core/clamp 等) | 毫秒级 | 几乎不需要 mock |
| L2 集成单测 | 依赖 DOM(happy-dom)、Babylon 桩、绑定桩的模块 | 需要 mocks/ 或 setup-wails.ts | 亚秒级 | 复用 mocks/ 共享桩 |
| L3 E2E | 真实用户旅程 | Playwright | 秒级 | 见 ADR-060 |
L1/L2 均为 Vitest,通过文件命名后缀区分:L2 使用 *.int.test.ts,L1 使用 *.test.ts(默认)。这样可选择性单独运行 L1 拿到秒级反馈。
2.2 拆分阈值(新增/触碰文件强约束)
| 维度 | 软上限 | 硬上限 | 处置 |
|---|---|---|---|
| 单测试文件行数 | 300 | 500 | 超软线建议拆;触碰超硬线文件必须拆 |
| 单文件用例数 | 30 | 50 | 同上 |
单文件 vi.mock/fn/spyOn 计数 | 20 | 40 | 超线优先抽 mocks/ 共享桩,而非就地打桩 |
拆分方向:按被测模块的子功能垂直切,参照 motion-popup 模块化拆分先例。例: menu.test.ts → menu/schema.test.ts + menu/keyboard-nav.test.ts + menu/state-binding.test.ts。
2.3 Mock 治理
- 共享优先:任何 Babylon/绑定/场景桩,先查
src/__tests__/mocks/是否已有;缺则补进共享层,禁止在测试文件里私造同类 mock。 - 测行为不测实现:mock 只桩「外部依赖的副作用与返回值」,不桩被测模块的内部方法。若必须 spy 内部方法才能测,说明该拆函数或提公共 API。
- UI builder 豁免延续:纯布局 UI builder 允许无单测(AGENTS.md 审核标准已豁免),对应测试从「逐行打桩」降级为「少量集成冒烟」,不追求分支全覆盖。
2.4 fixtures 复用层
新建 src/__tests__/fixtures/,收敛跨文件重复的场景级组装(区别于 mocks/ 的类级桩):
fixtures/scene.ts:makeTestScene()—— 基于mocks/babylon-classes组装含常用 mesh/material 的场景。fixtures/backend.ts:makeMockBackend()—— 统一 Wails 绑定桩,替代各文件重复的binding-factories拼装。- 迁移策略:拆分上帝文件时顺手把就地 mock 上抬到 fixtures,不做独立的大迁移任务。
2.5 分层脚本(frontend/package.json)
jsonc
"test": "vitest run", // 全量(CI 门禁不变)
"test:unit": "vitest run --exclude '**/*.int.test.ts'", // L1 纯逻辑,秒级反馈
"test:int": "vitest run '**/*.int.test.ts'", // L2 集成vitest.config.ts 现有 exclude(e2e/**、*.perf.test.ts)保持不变;分层通过脚本层过滤,不改 config 主体。
三、落地路标(渐进,无大爆炸)
| Phase | 内容 | 验收 |
|---|---|---|
| P0 规范落地 | 本 ADR + 更新 frontend/AGENTS.md 测试小节(分层/阈值/mock 治理) | 文档就位,npm run check:docs 绿 |
| P1 试点拆分 | ✅ 已完成(2026-07-29):menu.test.ts(1551 行 / 95 用例)垂直拆为 8 个 menu/*.test.ts(均 ≤287 行),抽 fixtures/menu.ts 收敛 makeLevel + new SlideMenu(...vi.fn()) 桩;旧 menu.test.ts 已删 | 用例数守恒(95→95)、npm run test 全绿(2437 passed / 0 failed)、单文件 ≤287 行 |
| P2 fixtures 推广 + 脚本 | ✅ 已完成(2026-07-29):建 fixtures/backend.ts(makeMockBackend + makeMockCapabilities);package.json 加 test:unit/test:int;env-bridge.test.ts(1471 行 / 84 用例 / 54 处打桩)拆为 6 个 env-bridge/*.int.test.ts(≤286 行),581 行 mock 前导上抬为共享桩 env-bridge/env-mocks.ts | 用例数守恒(84→84,全量 2437 passed)、test:int 精确命中 84、test:unit 2353 例独立可跑 |
| P3 触碰即改善 | ✅ 已完成(2026-07-29):perception.test.ts(1431 行 / 100 用例 / 18 处 vi.mock)拆为 8 个 perception/*.int.test.ts(均 ≤300 行);mockState/mockPipeline 以 vi.hoisted 内联留各测试文件(避免 vi.resetModules() 驱逐外置模块导致新旧实例脱节),perception-mocks.ts 收敛 18 个 vi.mock 工厂函数 + setupPerceptionTest + 共享 morph 助手。同轮 model-manager.test.ts(1270 行 / 88 用例 / 7 处 vi.mock)拆为 7 个 model-manager.*.test.ts(均 ≤205 行)+ model-manager-mocks.ts 共享桩(7 个 vi.mock 工厂 + 6 个 helper;mock 类自 ./mocks/babylon-classes 静态引入以保证与 SUT 被 mock 的导入同一引用,且 model-manager-mocks 的 import 置于 SUT 之前以规避 vi.mock 工厂 hoist 后引用未初始化)。同轮 library-core.test.ts(1189 行 / 99 用例 / 10 处 vi.mock)拆为 6 个 library-core.*.test.ts(均 ≤326 行)+ library-core-mocks.ts 共享桩(9 个 vi.mock 工厂 + makeModel/extractLevelRows 助手;mockState 以 vi.hoisted 内联对象字面量而非导入函数以规避 hoist 后引用未初始化)。同轮 material-editor.test.ts(1048 行 / 50 用例 / 26 处 vi.mock)拆为 4 个 material-editor.*.test.ts(均 ≤259 行)+ material-editor-mocks.ts 共享桩(26 个 vi.mock 工厂 + _mockMat 纯数据 helper;modelRegistry 非 mock 导入,共享模块 import 置于 Babylon 导入之前以规避 vi.mock 工厂 hoist 后引用未初始化) | 同轮 camera.test.ts(1017 行 / 63 用例 / 12 处 vi.mock,含 vi.importActual 绕过 self-mock 的 beforeAll 模式)拆为 3 个 camera.*.test.ts(均 ≤74 行,紧凑单行风格以在保留完整断言集的同时控制 boilerplate 体积);camera-mocks.ts 共享 8 个 vi.hoisted mock 类(但 vi.mock 因 hoist 时序须留在各测试文件内) |
P3 补充实施记录(2026-07-29):
menu-schema.test.ts(953 行 / 40 用例 / 含vi.doMock+vi.resetModules隔离模式)拆为 10 个menu-schema.*.test.ts(均 ≤~210 行,按 ADR-093 §6.1~§6.13 子功能垂直切)+menu-schema-mocks.ts共享 4 个vi.mock工厂(scene/lighting/perception/registry);含vi.doMock/vi.resetModules的motionModule./conflictHint两个 describe 保留动态await import取重置后模块实例(与 camera 同类的 hoist 时序约束,vi.mock 工厂不可跨文件共享),其余 8 个 describe 经containerbeforeEach/afterEach 自建 DOM 容器。验收:40 用例守恒、全量 0 failed、单文件 ≤300 行。
P3 补充实施记录(2026-07-29 续):
model-preset.test.ts(850 行 / 21 用例 / 34 处 vi.mock + 共享辅助函数)拆为 4 个model-preset.*.test.ts(serialize/apply/material/stopvmd,均 ≤~230 行)+model-preset-mocks.ts(34 个同步 vi.mock 工厂,Mock 类静态 import)+model-preset-helpers.ts(fakeMesh/createModel/applySpies 等纯辅助与 setup)。关键约束(踩坑):① vi.mock 工厂必须同步且 Mock 类静态 import,禁用vi.importActual包裹——否则 hoist 期报__vi_import_X__ not initialized;② mocks 导入须排在 SUT/helpers 之前,否则scene.ts触发 babylon mock 时工厂尚未初始化(与 model-manager 序一致)。验收:21 用例守恒、全量 0 failed、单文件 ≤300 行。
P3 补充实施记录(2026-07-29 续二):
virtual-skirt.test.ts(781 行 / 23 用例 / 7 处 vi.mock)拆为 8 个virtual-skirt.*.test.ts(inject 5 / dispose 2 / update 1 / quality 6 / coord 4 / coordspace 2 / build-cleanup 2 / waist-cache 1,均 ≤~100 行)+virtual-skirt-mocks.ts(7 个同步 vi.mock 工厂 +hoisted跨用例共享的callOrder/initialTransforms捕获对象 +resetHoisted)+virtual-skirt-helpers.ts(createOpenBottomCylinder/makeModel/makeRuntime/makePhysics/makeScene/testConfig 纯 fixture,Matrix/Vector3经 mocks 文件再导出以统一来源)。关键约束(踩坑):①vi.hoisted结果不能 export 跨模块(Vite 报Cannot export hoisted variable)——因本设计 vi.mock 工厂为间接调用() => mockX()、执行时机远晚于模块初始化,改用普通const hoisted = {...}即可安全跨文件共享同一实例;②vi.mock相对路径'../../core/backend'严格照搬原文件(测试文件同处src/__tests__/,解析行为与原 God 文件一致);③ math.vector/skirt-analyzer/physics-bridge/logger 保持真实(SUT 仅按需await import,不 eager 导入 WASM)。验收:23 用例守恒、全量 0 failed、单文件 ≤300 行。
P3 补充实施记录(2026-07-29 续三):
motion-modules-registry.test.ts(721 行 / 33 用例 / 5 处vi.mock)拆为 7 个motion-modules-registry.*.test.ts(init 4 / create 4 / param 6 / conflict 8 / disable 4 / snapshot 5 / ik 2,均 ≤~210 行)+motion-modules-registry-mocks.ts(shared跨用例单例含reset()+mockModelRegistry/setBoneOverrideSpy/clearBoneOverrideSpy/protectIkPositionSpy/mockActiveMotion/pushHistorySpy六个共享 spy + 5 个同步vi.mock工厂)+motion-modules-registry-helpers.ts(makeModel/makeModelWithBones/setActiveMotionWithModules纯 fixture,mockActiveMotion经shared统一写入)。关键约束(踩坑):① 原 God 文件用vi.hoisted在同一文件定义共享 spy,但vi.hoisted结果不能 export 跨文件;改用shared普通 const 单例(工厂函数延迟调用,import 完成后才执行,规避 hoisting 跨文件导出限制),且mockModelRegistry必须是跨工厂共享的同一Map实例(state 模块导出、SUT 操作、测试写入三者同一引用);②vi.mock对@/scene/motion/motion-intent保持async (importOriginal) => ({...actual, ...mockMotionIntent()})的异步 spread(SUT 的getActiveMotion须经 mock 接shared.mockActiveMotion);③resetAll在每文件beforeEach调用shared.reset()+setTargetModel(null),复刻原 resetAll 以重置 SUT 内部_currentModelId。验收:33 用例守恒、全量 0 failed、单文件 ≤300 行。
P3 补充实施记录(2026-07-29 续四):
ui-helpers.test.ts(705 行 / 38 用例 / 仅 1 处vi.mock('../core/icons'))拆为 4 个ui-helpers.*.test.ts(slide-toggle 9 / slider 15 / layout 7 / bone 7,均 ≤~230 行)。关键约束(踩坑):① 该文件 mock 极简(仅createIconifyIcon一个vi.fn),而vi.mock无法跨文件外置(vitest 要求必须在测试文件顶层调用才能生效),故每个拆分文件各自保留同一段vi.mock('../core/icons', () => ({ createIconifyIcon: vi.fn() }))+vi.mocked(createIconifyIcon)+beforeEach重置块——未另建 mocks/helpers 文件,因其会徒增复杂度且无法外置 vi.mock;② 各文件只 import 自身用到的 SUT 子集(../core/ui-helpers基础行 + 仅 slider 文件额外 import../core/ui-advanced-rows的addColorSliderRow/addVector3SliderRow/addModeSlider),保持 import 最小面;③ DOM 断言依赖 happy-dom(顶层 setup-wails 已装配),每个 it 内自建document.createElement('div')容器,无跨用例状态耦合。验收:38 用例守恒、全量 0 failed、单文件 ≤300 行。
P3 补充实施记录(2026-07-29 续五):
proc-motion-bridge.test.ts(662 行 / 68 用例 / 4 处vi.mock+vi.resetModules()+动态import取 fresh SUT)拆为 4 个proc-motion-bridge.*.test.ts(state 24 / toggles 22 / tracking 13 / lifecycle 9,均 ≤~270 行)+proc-motion-bridge-mocks.ts(createProcMockState/resetProcMockState+ 4 个接收 state 参数的纯工厂mockConfig(s)/mockAudio(s)/mockScene(s)/mockVmdLayers())。关键约束(踩坑):① 原 God 文件用vi.hoisted在同文件定义mockState,但vi.hoisted结果不能 export 跨文件,且该 SUT 测试用vi.resetModules()每次重置模块注册表——若把mockState做成跨文件共享单例,resetModules 可能重算 mocks 模块致引用错位;故改为每个测试文件const mockState = createProcMockState()生成本地实例(普通 const、不经 resetModules 重置),工厂函数以参数接收 state 保持纯净,resetProcMockState(mockState)在每个beforeEach复位;②vi.mock工厂保持() => mockConfig(mockState)延迟调用形式(import 完成后 SUT 求值时才执行,规避 hoisting 跨文件导出限制);③beforeEach序与原文件一致:vi.resetModules()→await import(SUT)→resetProcMockState(mockState),且 hook 超时保留30000(全量并行时 transform 与并行套件争抢资源)。验收:68 用例守恒、全量 0 failed、单文件 ≤300 行。
P3 补充实施记录(2026-07-29 续六):
model-ops.test.ts(574 行 / 36 用例 / 6 处vi.mock+ 真实config模块依赖预建 DOM)拆为 5 个model-ops.*.test.ts(focus 7 / physics 10 / morph 8 / vpd 3 / remove 8,均 ≤~190 行)+model-ops-mocks.ts(modelOpsShared模块加载期单例含mockModelManager+mockSceneModule/mockMaterial/mockEnv/mockCamera/mockPlayback/mockAudio六个同步工厂)+model-ops-helpers.ts(makeInst/resetState纯 fixture,导入真实config)。关键约束(踩坑):①vi.mock工厂只能引用 imported 绑定,不能引用局部 const——初版const mockModelManager = createMockModelManager()被 hoist 的vi.mock('../scene/scene')工厂在求值时以Cannot access 'mockModelManager' before initialization报 TDZ;改以modelOpsShared.mockModelManager(modelOpsShared是 imported 绑定,模块图求值阶段即就绪)直接喂给工厂;② 不能把createMockModelManager()塞进vi.hoisted回调,因该回调在 hoist 阶段执行、所调用的函数为跨文件 import(__vi_import_0__尚未初始化);③ 各测试文件保留自包含内联的vi.hoisted(() => { document.createElement... })DOM 预建块(happy-dom 下config的dom.ts顶层读 DOM,必须在 import../core/config/model-ops之前完成,且不可引用 import 故不抽进 mocks);④resetState复用原vi.clearAllMocks()+setModelRegistry(new Map())+setIsPlaying(false)+setMmdRuntime(null),且vi.clearAllMocks()保留mockReturnValue([])等默认实现,行为与原一致。验收:36 用例守恒、全量 0 failed、单文件 ≤300 行。
P3 补充实施记录(2026-07-29 续拆 model-detail-ui):
model-detail-ui.test.ts(536 行 / 13 用例 / ~45 处vi.mock)拆为 3 个model-detail-ui.*.test.ts(model 4 / info 4 / tags-morph 5)+model-detail-ui-mocks.ts(仅补model-preset-mocks.ts未覆盖的缺口:ShadowGenerator/粒子/GridMaterial/纹理三件套 + 应用模块桩 scene/scene-menu/outfit/lipsync/procedural-motion/beat-detector/audio)+model-detail-ui-helpers.ts(fakeMesh/createModel/cleanup/hasRenderCustom纯 fixture)。关键点:① 通用 Babylon/babylon-mmd 工厂直接复用model-preset-mocks.ts(Engine/Scene/灯光/相机/数学/材质/网格/加载器等 28 个),mocks 文件不重复造轮子——后续拆同类 scene 依赖测试照此复用;② 原文件mockModelManager经vi.hoisted定义、scene mock 用 getter 引用,拆分后改为 mocks 文件普通const单例(imported 绑定,() => mockSceneScene()延迟调用时已就绪),cleanup()在每文件beforeEach里mockReset其get;③ SUT(../menus/model-detail)静态 import 置于 helpers/mocks 之后(audio 拆分发现的 TDZ 变体)。验收:13 用例守恒、全量 0 failed、单文件 ≤~230 行。
P3 补充实施记录(2026-07-29 续拆 camera.adr100):
camera.adr100.test.ts(523 行 / 14 用例 / 9 个vi.hoistedMock 类 + SUT 经beforeAll动态vi.importActual('../scene/camera/camera')加载)拆为 2 个camera.adr100.*.test.ts(serialization 7 / guards 7)+camera-adr100-mocks.ts(Mock 类 +mockUiState/mockPBD共享状态 + 4 个模块工厂)。关键点:① 原文件的vi.hoistedMock 类降级为普通导出类——因 SUT 是动态vi.importActual加载,vi.mock工厂延迟到 beforeAll 时才执行,imported 绑定彼时已就绪,无需 hoisted(与 model-ops 的modelOpsShared同理);② 这些 Mock 类为 camera SUT 定制(setTarget拷贝语义、_panningMouseButton等),不并入通用mocks/babylon-classes;③ 两文件各自保留beforeAll加载 SUT +setSyncAxesCallback装配 +beforeEach复位序(preset/fov/autoCam/uiState/clearAllMocks)。验收:14 用例守恒、全量 0 failed、单文件 ≤~180 行。
P3 补充实施记录(2026-07-29 续拆 physics-contract):
physics-contract.test.ts(961 行 / 40 用例 / 零 vi.mock,WASM 装配已抽helpers/minimal-physics-impl)拆为 4 个physics-contract.*.test.ts(core 16:模块加载+世界+形状+内存+MmdRuntime / rigidbody 11:端到端+Bundle / constraint 5:6DOF Spring / collision-worlds 8:碰撞+多世界)。关键点:① 按原文件 10 个编号 describe 天然切缝垂直分组,测试体逐字搬运,各文件仅复制 ~20 行beforeAll(createMinimalPhysicsImpl)装配样板 + 按需薄包装(buildRigidBodyInfo/readLinearVelocity等);② 每文件独立 WASM 实例(initSync同步加载,开销小),互不共享指针,比原单文件更强隔离;③ 无 vi.mock 故不建 mocks/helpers 文件;④ 用例计数陷阱:grep -c "it("报 44 系constraintSetLinearLowerLimit(等含it(子串误计,逐 describe 手数为 40,以 vitest 运行报告为准。验收:40 用例守恒、全量 0 failed、单文件 ≤~400 行(纯顺序契约断言,无 mock 复杂度,允许略超 300 软阈值——契约测试以 describe 完整性优先)。P3 收尾裁定(2026-07-29):>500 行 backlog 清零。剩余 2 个超长文件刻意整体保留,不再拆分:
文件 行 保留理由 perception.perf.test.ts741 perf 基准,已被默认套件排除( vitest.config.tsexclude**/*.perf.test.ts),独立 config 运行;自包含合成骨骼 stub,拆分无收益bindings/app.contract.test.ts646 契约入口,AGENTS.md + 20+ 处 ADR/README/知识卡硬编码此路径为校验命令;拆分导致文档漂移面远大于收益
P3 标准拆分配方(已跨 12 个文件验证):①
vi.mock工厂收敛进*-mocks.ts同步导出,Mock 类静态import,禁用vi.importActual包裹(hoist 期__vi_import_X__ not initialized);② 跨用例共享状态用普通const shared单例(imported 绑定),vi.mock工厂直接引用它——禁止引用局部 const 或vi.hoisted内调用 import 函数(两类 TDZ);③vi.resetModules()+动态import取 fresh SUT 的场景(如 proc-motion-bridge):每文件本地const s = createX(),工厂以参数接收;④ DOM 预建(config顶层读 DOM)保留各文件自包含内联vi.hoisted(() => createElement...);⑤ 每文件beforeEach复位shared+ config setters,复刻原resetAll;⑥ 提交前npx vitest run <dir>验用例守恒 +npm run check:docs。
用例数守恒是拆分的硬验收:拆分前后 npm run test 报告的 pass 数必须一致,防止拆分中丢用例。
P2 实施记录(2026-07-29):三点与 ADR 原文的实现差异——①
test:int脚本用 vitest 位置过滤子串.int.test(而非 glob'**/*.int.test.ts'),因 Windows npm script 单引号不剥离且位置参数按子串匹配语义更可靠;test:unit用--exclude(追加语义,不覆盖 config 的 e2e/perf 排除)。② env-bridge 的模块桩集(config/env-impl/env-dispatcher/lighting/scene 等 10 模块)与 SUT 强绑定,故收敛为env-bridge/env-mocks.ts就近共享(拆分文件的vi.mock工厂经await import('./env-mocks')取桩,vitest 按测试文件隔离模块图、状态不串扰),而非塞进通用mocks/;其中 backend 桩改用fixtures/backend.ts的makeMockBackend(跨模块通用层)。③ 拆分文件首次落地*.int.test.tsL2 命名约定;P1 的menu/*.test.ts(依赖 happy-dom,属 L2)留待 P3 触碰时顺带改名。旧文件里 hoisted 的_defaults对象为死代码,未搬运。P1 实施记录(2026-07-29):
menu.test.ts是纯 DOM 组件测试(SlideMenu / showPopupMenu / registerPopupMenu),不依赖 Babylon,故抽出的共享设施为src/__tests__/fixtures/menu.ts(makeTestLevel+makeTestMenu,收敛makeLevel辅助函数与重复的new SlideMenu({...})桩),而非 ADR 示例中的fixtures/scene.ts(makeTestScene基于mocks/babylon-classes,面向 Babylon 依赖测试,留待 P2 触碰env-bridge/perception等上帝文件时引入)。拆分比 ADR 示例的「3~4 个」更细(8 个文件),因为 ADR 自身的「单文件 ≤300 行」硬阈值优先于「3~4」的软建议——3~4 个文件会迫使部分文件超过 300 行。验收:95 用例守恒、全量 2437 passed / 0 failed、单文件最大 287 行。
四、风险与权衡
| 风险 | 缓解 |
|---|---|
| 拆分中误删/合并用例 | 用例数守恒验收;拆分用 npm run codemod move-function 类 AST 工具或手工逐块搬,禁止 re.sub |
*.int.test.ts 重命名引发 CI/覆盖率遗漏 | vitest.config.ts 的 coverage include: src/**/*.ts 不受文件名影响;test(全量)仍是 CI 门禁,分层脚本仅为本地反馈 |
| 过度抽 fixtures 造成「测试的测试」耦合 | fixtures 只做无逻辑的组装工厂,不含断言、不含分支;一处 fixtures 只服务一类场景 |
| 规范沦为纸面 | 阈值仅对新增/触碰文件强约束,不搞存量一刀切,降低执行摩擦 |
五、决策
采用三层单测模型 + 拆分阈值 + 共享 mock/fixtures 复用 + 分层脚本的渐进治理方案。核心原则:总量不砍、结构分层、共享优先、触碰即改善。P0 规范先行,P1 以 menu.test.ts 试点验证方法论,其余存量随触碰渐进迁移。