Skip to content

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.tsgit 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
已有共享 mocksrc/__tests__/mocks/babylon-classes.tsbabylon.tsbabylon-mmd-mocks.tsbinding-factories.tsengine-mock.tsfactories.tsgit 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 痛点(按杠杆率排序)

  1. 上帝测试文件:Top5 均破千行、80~100 用例。改一处模块逻辑要在 1500 行里翻找相关用例,违反 AGENTS.md「500 行文件先 grep 定位」的硬约束精神。
  2. Mock 过载:单文件 92 处打桩 = 测试与实现过度耦合。行为不变的重构会红一大片——测试在测「怎么写」而非「做什么」。
  3. 共享基础设施空转:已有 6 个共享 mock + factories.ts,却只有 2 个文件在用。绝大多数测试在各自文件里重复造 mock,这正是 mock 过载的根因。
  4. 分层缺失:纯逻辑单测(毫秒级)与带 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 拆分阈值(新增/触碰文件强约束)

维度软上限硬上限处置
单测试文件行数300500超软线建议拆;触碰超硬线文件必须拆
单文件用例数3050同上
单文件 vi.mock/fn/spyOn 计数2040超线优先抽 mocks/ 共享桩,而非就地打桩

拆分方向:按被测模块的子功能垂直切,参照 motion-popup 模块化拆分先例。例: menu.test.tsmenu/schema.test.ts + menu/keyboard-nav.test.ts + menu/state-binding.test.ts

2.3 Mock 治理

  1. 共享优先:任何 Babylon/绑定/场景桩,先查 src/__tests__/mocks/ 是否已有;缺则补进共享层,禁止在测试文件里私造同类 mock。
  2. 测行为不测实现:mock 只桩「外部依赖的副作用与返回值」,不桩被测模块的内部方法。若必须 spy 内部方法才能测,说明该拆函数或提公共 API。
  3. UI builder 豁免延续:纯布局 UI builder 允许无单测(AGENTS.md 审核标准已豁免),对应测试从「逐行打桩」降级为「少量集成冒烟」,不追求分支全覆盖。

2.4 fixtures 复用层

新建 src/__tests__/fixtures/,收敛跨文件重复的场景级组装(区别于 mocks/ 的类级桩):

  • fixtures/scene.tsmakeTestScene() —— 基于 mocks/babylon-classes 组装含常用 mesh/material 的场景。
  • fixtures/backend.tsmakeMockBackend() —— 统一 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 现有 excludee2e/***.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.tsmakeMockBackend + makeMockCapabilities);package.jsontest:unit/test:intenv-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/mockPipelinevi.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 助手;mockStatevi.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.resetModulesmotionModule./conflictHint 两个 describe 保留动态 await import 取重置后模块实例(与 camera 同类的 hoist 时序约束,vi.mock 工厂不可跨文件共享),其余 8 个 describe 经 container beforeEach/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.tsshared 跨用例单例含 reset() + mockModelRegistry/setBoneOverrideSpy/clearBoneOverrideSpy/protectIkPositionSpy/mockActiveMotion/pushHistorySpy 六个共享 spy + 5 个同步 vi.mock 工厂)+ motion-modules-registry-helpers.tsmakeModel/makeModelWithBones/setActiveMotionWithModules 纯 fixture,mockActiveMotionshared 统一写入)。关键约束(踩坑):① 原 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-rowsaddColorSliderRow/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.tscreateProcMockState/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.tsmodelOpsShared 模块加载期单例含 mockModelManager + mockSceneModule/mockMaterial/mockEnv/mockCamera/mockPlayback/mockAudio 六个同步工厂)+ model-ops-helpers.tsmakeInst/resetState 纯 fixture,导入真实 config)。关键约束(踩坑):① vi.mock 工厂只能引用 imported 绑定,不能引用局部 const——初版 const mockModelManager = createMockModelManager() 被 hoist 的 vi.mock('../scene/scene') 工厂在求值时以 Cannot access 'mockModelManager' before initialization 报 TDZ;改以 modelOpsShared.mockModelManagermodelOpsShared 是 imported 绑定,模块图求值阶段即就绪)直接喂给工厂;② 不能把 createMockModelManager() 塞进 vi.hoisted 回调,因该回调在 hoist 阶段执行、所调用的函数为跨文件 import(__vi_import_0__ 尚未初始化);③ 各测试文件保留自包含内联vi.hoisted(() => { document.createElement... }) DOM 预建块(happy-dom 下 configdom.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.tsfakeMesh/createModel/cleanup/hasRenderCustom 纯 fixture)。关键点:① 通用 Babylon/babylon-mmd 工厂直接复用 model-preset-mocks.ts(Engine/Scene/灯光/相机/数学/材质/网格/加载器等 28 个),mocks 文件不重复造轮子——后续拆同类 scene 依赖测试照此复用;② 原文件 mockModelManagervi.hoisted 定义、scene mock 用 getter 引用,拆分后改为 mocks 文件普通 const 单例(imported 绑定,() => mockSceneScene() 延迟调用时已就绪),cleanup() 在每文件 beforeEachmockResetget;③ 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.hoisted Mock 类 + 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.hoisted Mock 类降级为普通导出类——因 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.ts741perf 基准,已被默认套件排除(vitest.config.ts exclude **/*.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.tsmakeMockBackend(跨模块通用层)。③ 拆分文件首次落地 *.int.test.ts L2 命名约定;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.tsmakeTestLevel + makeTestMenu,收敛 makeLevel 辅助函数与重复的 new SlideMenu({...}) 桩),而非 ADR 示例中的 fixtures/scene.tsmakeTestScene 基于 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 试点验证方法论,其余存量随触碰渐进迁移。