Skip to content

回声的几何

背景:ADR-204 拆分后测试套件膨胀至 207 文件 / 2428 用例,mock 密度指标亮红灯,但全绿。

过程:发现 340 行死代码 + 130 行重复工厂,建立两层 mock 架构,收敛至单一规范源。


试金石试验室扩张得很快。

上次外交官来盘点的时候,门口牌子上写的是"259 项测试 / 12 项失败"。他带着 AI 一个个修,修到 0 失败,搭起了 CI 管道,给每座城邦的试验室定了规矩。

那是第零卷的事了。

现在他再次站在门口。牌子上的数字变了:

今日状态:207 间试验室 / 2428 项测试 / 0 失败 / 4655 次检验

"零失败,"外交官念出来,语气里本该有骄傲。但他的目光落在另一块牌子上——那是 ADR-204 留下的统计:

单文件 vi.mock 密度:44 / 44 / 44 …… 软上限 20 / 硬上限 40

"44 超过 40 了,"他说。

"是,"AI 有点不安,"拆分之后每个文件都带着自己的 vi.mock 调用。原来一个大文件 92 处,拆成三个各 44,加起来 132——反而多了。"

"那这些红灯意味着什么?"

AI 犹豫了一下:"意味着……我们需要继续拆分?或者减少 mock?"

外交官没回答。他推门走了进去。


一、三座熔炉

试验室内部比上次整齐多了。207 间小实验室排列得井井有条,每间门口挂着名字:model-manager.focusmodel-manager.physicsenv-bridge.facade……门上的绿灯亮着,说明用例在跑,跑过了。

外交官随机走进一间——material-editor-mocks

房间不大,50 行见方。墙上挂着一排工具:engineModuleFactorysceneModuleFactorymeshModuleFactory……一共 25 把。他把它们逐一取下来看。

"这把 engineModuleFactory,"他举起来,"返回什么?"

AI 凑过去看:() => ({ Engine: MockEngine })

"再去隔壁 outfit-mocks 看看。"

隔壁房间墙上也挂着一排工具。外交官找到对应的那把——名字不同,叫 mockEngine。他取下来一对比:() => ({ Engine: MockEngine })

一模一样。

他又去了第三间——model-preset-mocks。同样的工具,同样的 () => ({ Engine: MockEngine })

"三间屋子,"外交官把三把工具并排放在桌上,"同一把锤子,锻了三次。"

他继续检查。mockScenemockCameramockTexturemockMathVectormockStandardMaterial……25 把工具里,有 20 把在三间屋子里都能找到完全相同的复制品。

"这不是工具的问题,"他把锤子放回去,"是熔炉的问题。三座熔炉,浇同一套模具。"


二、误诊

"那我们把 mock 调用数降下来,"AI 说,"把三个文件合回去?"

"不,"外交官摇头,"合回去就回到 984 行的上帝文件了。ADR-204 白拆的。"

"那拆得更细?每个文件少 mock 一点?"

"拆得更细,mock 调用总数反而更多。207 个文件各带 9 个 mock 就是 1863——你以为拆能解决?"

AI 困惑了:"那红灯到底是什么意思?"

外交官在桌前坐下来。他把三间 mock 屋的工具清单摊开,逐行比对。

model-preset-mocks 定义了 33 个工厂函数。material-editor-mocks 定义了 25 个别名映射。outfit-mocks 定义了 5 个领域特有 helper。三者之间, Babylon 工厂代码的水平重复约 130 行。

"红灯不是在说 mock 调用太多,"他慢慢说,"vitest 的 hoist 机制要求每个测试文件顶层调用 vi.mock()——这是结构性约束,不可消除。就像每间试验室都必须有自己的门,你不能因为门太多就拆门。"

"那真正的问题是什么?"

"是工厂代码的水平重复。三座熔炉浇同一套模具——该治的不是门的数量,是熔炉的冗余。"

他在 ADR 卷宗上写下一行字:

mock 密度是 vitest hoist 的结构性约束,不是卫生问题。真正该治的是工厂代码的重复度。


三、清理

外交官先去了地下室。

mocks/babylon.ts——220 行,落满灰尘。他打开门看了看:里面是一套完整的 Mock 类定义,和 mocks/babylon-classes.ts(683 行)高度重叠。

"谁在用你?"

AI 跑了一条 grep:grep -r "from '.*mocks/babylon'" src/

零个结果。

"零个消费者,"AI 确认。

外交官又去了隔壁。mocks/factories.ts——120 行,同样落灰。grep 结果:零。

"340 行,"他站在地下室门口,"零引用。ADR-204 拆分的时候,旧文件的 mock 基础设施被原样复制到了新的 -mocks.ts 里,旧文件留着没删。"

他关上了地下室的灯。


四、一座熔炉

清理完死代码,外交官回到三座熔炉前。

"我要做一件事,"他说,"把 33 个 Babylon 工厂函数从 model-preset-mocks 提升到 mocks/babylon-factories.ts——一个所有 Babylon/BMD mock 的单一规范源。"

他在白板上画了一张图:

mocks/babylon-classes.ts  (683 行,不动)
  └── Mock 类定义:MockScene / MockMesh / MockMaterial / MockEngine ...

mocks/babylon-factories.ts  (新建,95 行)
  └── 工厂函数:mockEngine() / mockScene() / mockCamera() ...
  └── 所有 Babylon/BMD 相关测试的唯一工厂源

各模块 *-mocks.ts  (保留,只放领域特有 mock)
  └── 不含通用 Babylon 工厂(改为 import from babylon-factories)

"然后 model-preset-mocks 降级为薄 re-export shim——46 行,33 个 re-export 加 2 个 app mock。7 个消费者不用改 import 路径。"

"material-editor-mocks 从 165 行砍到 50 行——只保留别名映射和 6 个空模块桩的求值。"

"outfit-mocks 从 55 行砍到 33 行——保留换装特有的 mockEmptymockSceneModulemockT。"

AI 看着白板:"那 130 行重复呢?"

"归零。"

外交官把第一座熔炉的模具搬进了 babylon-factories.ts。然后是第二座、第三座。每搬一把工具,他就去比对一次——返回值是不是同一个 Mock 类,参数签名是不是一致。

搬到 mockTexture 的时候,他停了一下。

"这个在 model-preset-mocks 里缺失,"他翻了翻,"但 outfit 测试依赖它。"

他从 babylon-classes.ts 里找到 MockTexture,补了一个工厂:export const mockTexture = () => ({ Texture: MockTexture })

"规范源不能有缺口。"

搬到 material-editor-mocks 的时候,踩了一个坑。

6 个空模块桩——physicsEngineModuleMocktgaLoaderModuleMock 等——消费者按引用,不是按函数调用。原来在 material-editor-mocks 里,它们是 export const xxx = () => ({}) 的形式,消费者 vi.mock('...', () => xxx) 时,xxx 是一个函数,调用后返回 {}

但如果改成从 babylon-factories re-export,mockPhysicsEngineComponent 本身就是 () => ({})——re-export 后 xxx 还是函数,没问题。

可有一个消费者写的是 vi.mock('...', () => physicsEngineModuleMock)——它把 physicsEngineModuleMock用,不当函数调。如果 physicsEngineModuleMock 是函数 () => ({}),那 vi.mock 工厂返回的就是一个函数,不是对象。vitest 会报错:is not returning an object

"语义不同,"外交官在坑边蹲了一会儿,"函数 () => ({}) 和值 {} 在 re-export 时不等价。"

他的解决方案:对这 6 个空模块桩,不 re-export 工厂函数,而是在 material-editor-mocks求值

ts
export const physicsEngineModuleMock = mockPhysicsEngineComponent();

这样 physicsEngineModuleMock 就是 {}——一个 plain object,消费者按值引用时拿到的是对象,不是函数。

"坑填上了,"他站起来,"但教训要记下来。"


五、行为,而非调用

两层架构就位后,外交官没有停。他注意到 replace-model-inherit.test.ts 里有 6 处这样的断言:

ts
expect(mmState.modelManager.setVisibility).toHaveBeenCalledWith('m1', false);
expect(mmState.modelManager.setOpacity).toHaveBeenCalledWith('m1', 0.5);
expect(mmState.modelManager.setWireframe).toHaveBeenCalledWith('m1', true);

"这些在测什么?"他问。

"测 applyInheritedState 调用了哪些 setter,"AI 说,"调用断言——toHaveBeenCalledWith。"

"但这个模块的本质是什么?它不是转发层,不是 WASM 桥接。它是一个有状态输出的业务逻辑——把模型属性从 A 复制到 B。"

"所以?"

"所以该测的不是'调了什么',而是'状态变成了什么'。"

他在 mmState 的 hoisted 块里注入了一個 _applied 数组。每个 setter mock 被调用时,往数组里 push 一个状态快照:

ts
setVisibility: vi.fn((id, v) => { _applied.push({ visible: v }); }),
setOpacity: vi.fn((id, v) => { _applied.push({ opacity: v }); }),

然后 6 处 toHaveBeenCalledWith 收敛为 1 处 toMatchObject

ts
const applied = Object.assign({}, ...mmState._applied);
expect(applied).toMatchObject({
    visible: false, opacity: 0.5, wireframe: true,
    physicsEnabled: false, scaling: 2.0, position: [1, 2, 3],
});

"不是'调了 setVisibility 传入 false',而是'最终状态里 visible 是 false'。"外交官看着新断言,"前者测的是过程,后者测的是结果。过程可以重构,结果不会变。"

他翻了另外两个候选文件——drop-import.test.ts(27 处调用断言)和 playback.observables.test.ts(18 处)。看了半小时,合上了。

"这两个不动。dispatcher 和 observable 的本质就是调用——测调用等于测行为。不是所有 toHaveBeenCalledWith 都该替换。"


六、最后的巨人

最后,外交官面对两个 >500 行的功能测试文件。

backend.test.ts——984 行,71 个用例。他数了数,grep 估算 65 个,但实际 71 个——因为有一个 for 循环动态生成了 7 个 it 块。

"你一个人干 71 个人的活,"他对着文件说。

他把它拆成了 7 个文件,按功能域分:capabilities(15 用例)、data-chain(20)、virtual-dir(9)、extract(12)、resolve(5)、fsa(6)、update(2)。每个文件带自己的 vi.mock(vitest 约束),但共享 backend-mocks.ts(44 行)里的 idbStore Map 和重置函数。

browser-adapter.test.ts——512 行,24 用例。拆成 4 个文件 + browser-adapter-mocks.ts(17 行)。

"共享的 mem 不能用 vi.hoisted(),"他发现,"因为 vi.hoisted() 的结果不能跨文件 export——Vite 会报错。只能用普通 const。"

他在 browser-adapter-mocks.ts 里写:

ts
export const mem = new Map<string, Map<string, unknown>>();

不是 vi.hoisted(() => new Map())——那样跨文件 export 会炸。普通 const 就够了,因为 vitest 按文件隔离模块图,每个测试文件拿到的 mem 是独立实例。

"又一个坑,"他记下来,"vi.hoisted 不能跨文件 export。共享状态用普通 const。"


尾声

224 间试验室。2428 项测试。0 失败。用例数守恒。

外交官站在试验室走廊的尽头,回头看来时的路。

地下室清空了——340 行死代码化为齑粉。三座熔炉合并为一座——babylon-factories.ts,95 行,所有 Babylon mock 工厂的唯一来源。五个模块级 mocks 文件从"自建工厂"转为"消费共享层"。断言从"测调用"转为"测状态"——不是全部,只是该转的那些。两个巨人文件被拆解为 11 个小文件,各自带着自己的共享 mock。

他想起刚进试验室时看到的红灯——vi.mock 密度 44/44/44,超过硬上限 40。

那不是红灯。那是回声。

三座熔炉浇同一套模具,每一次锻造都在墙壁上留下回声。回声越来越响,听起来像问题。但真正的问题不是回声太大——是有太多面墙在反射同一个声音。

拆掉多余的墙,回声自然消失。

走廊尽头,一盏绿灯亮着。上面写着:

mocks/babylon-factories.ts — 单一规范源

外交官看了它一会儿,转身离开。

聚合悖论记录:联邦的试验室越多,维护试验室本身的成本越高。当三间屋子存放同一把锤子时,问题不是锤子太多,而是你忘了自己只有一座熔炉。