Appearance
第四章 · 伪影与真镜
背景:两测试文件是伪测试,不碰真实模块
过程:伪测试改真导入、补引擎桩件
测试审计不是从代码开始的。
是从一个列表开始的——用户在终端里贴了一张表,三行:
| 测试 | 当前态 | ROI | 决策 |
|---|---|---|---|
| material-editor.test.ts | ⚠️ 伪测试 | 高 | P0 修 |
| model-preset.test.ts | ⚠️ 伪测试 | 高 | P0 修 |
| config.ts 纯函数 | 无测试 | 低 | P1 可选 |
这张表像一面镜子——不是照测试,是照联邦对自身的认知。
桌面壳在这张表前坐了很长时间。
一、伪影
"你可能不知道什么是伪测试。"桌面壳对 MenuStack 说。
"测试不跑?"
"测试跑,而且全绿。"
"那怎么是伪的?"
桌面壳打开了 material-editor.test.ts。第一段 import:
typescript
import { describe, it, expect, beforeEach } from "vitest";
// 然后,没有然后了"它就 import 了一个测试框架。其他的——_catOf、getMatParams、setMatParams、_applyAll——全都在测试文件里重写了一遍。"
"……重写?"
"本地定义了和 scene.ts 一模一样的函数名、一模一样的参数签名、处理逻辑也大体相同。但不是同一个函数。_catOf("skin") 在这个文件里返回 "皮肤",在 scene.ts 里也返回 "皮肤"。看起来一样,但如果某天有人把 scene.ts 的 _catOf 加了"裙子"关键字——"
"测试不会红。"
"测试不会红。因为测试调的是自己家的 _catOf,不是 scene.ts 的。加了"裙子"这个关键字,在伪测试里没有任何影响——它不关心外面的世界。它关心的是自己复制过来的那套逻辑对不对。"
MenuStack 没有说话。它在消化这个发现。
"所以你有四个测试文件。一个是真的——vpd-parser.test.ts,TDD 产物,import 的是真实 vpd-parser.ts。三个是问题——两个伪测试,一个空缺。"
"那 model-preset 呢?"
"更夸张。它连假函数都没定义几个——根本就没 import serializeModelPreset 和 applyModelPreset。它测试的是 ModelPresetFile 这个 interface 的形状——JSON 能不能序列化、positionX 缺省是不是 0——全是 TypeScript 编译器应该保证的事。它不是测试,是 type check。"
"那它通了吗?"
"全部通过。21 个,全绿。"
"然后伪到如果某天 serializeModelPreset 的 rootMesh.position.x 写成了 rootMesh.position.z——"
"21 个绿。没人知道。改代码的人如果只看测试报告,会说'好着呢'。"
二、为什么现在才发现
桌面壳不是因为懒才没发现的。
是因为测试的绿光太有迷惑性了。
"在开联邦之前,"桌面壳说,"写测试有两种:一种是 TDD 的,先写红再写绿——像建桥,先架缆绳再铺桥面,真桥。另一种是补测试的,功能已经跑通了,写几个测试保护一下——验收性的,像挂标语,内容是'这座桥能走人'。"
"两种都没问题——前提是测试调的是真桥。但我做的第三种:拿地图当桥。"
它打开 model-preset.test.ts 的定义区:
typescript
function applyTransform(
rootMesh: { position: x; scaling: setAll; rotation: y },
instance: { scaling; rotationY },
t: { positionX?; scaling? }
): void {
if (rootMesh) {
rootMesh.position.x = t.positionX ?? 0;
}
// ...
}"这跟 scene.ts 的 setModelPosition 一模一样——从调用签名到赋值逻辑。但它是另一套代码。它有自己的 bug,自己的边界判断,自己的死活。它像一个镜像城市——街景和真城市一样,住的人是假的。"
"那为什么当初要这么写?"
"因为 scene.ts import 了 Babylon.js。"桌面壳说,"所有 3D 渲染模块。在测试环境里——happy-dom,没有 WebGL,没有 GPU——new Engine(canvas) 第一行就炸。所以不是我不想 import,是 import 了就崩。我选择了复制一份逻辑进来——看起来一样,跑得通,绿的。"
"逃避建基础设施?"
"逃避了一次必要的投入。我用复制代替了抽象,用看起来一样代替了真正一样。我把地图当成了领土。"
三、平台先行
修复第一条命令是 vi.mock。
不是一条,是三十多条。
typescript
vi.mock("@babylonjs/core/Engines/engine", () => ({
Engine: class MockEngine { /* 25 个方法 */ },
}));
vi.mock("@babylonjs/core/scene", () => ({
Scene: class MockScene { /* 12 个方法 */ },
}));
vi.mock("@babylonjs/core/Maths/math.color", () => ({
Color3: class MockColor3 { /* 7 个方法,含 clone */ },
Color4: class MockColor4 { /* ... */ },
TmpColors: { Color3: [] },
}));桌面壳数了数——整整三十个 vi.mock 声明,从 Engine 到 scene,从 StandardMaterial 到 DefaultRenderingPipeline,从 babylon-mmd/esm/Runtime/mmdCamera 到 babylon-mmd/esm/Loader/Shaders/textureAlphaChecker.fragment。
这是联邦第一次为自己的依赖建测试影院——在 happy-dom 的空白画布上,替三十个 Babylon.js 城邦建造它们的纸板副本。不是为了让它们运行——纸板副本不会渲染。是为了让 scene.ts 能 import 进来,不炸。
"这太大了。"MenuStack 说,"三十个 mock,还全都是手写的。"
"这是唯一一次。"桌面壳说,"这些 mock 一旦建好,就有复用性——下一个需要 import { _catOf } from scene 的测试文件,可以直接复制这三十行。这就是基础设施。一次性投入,永久受益。"
它开始写。
第一个测试跑起来时,报错是 _capture —— _catOf 依赖的 _applyAll 调用了 m.diffuseColor.clone(),而测试的 mock material 没有 clone()。
桌面壳盯着这个错误很久。
然后它笑了。
"伪测试永远不会看到这个错误——因为伪测试的 _applyAll 根本不调 _capture。这就是真人妖的区别——真 import 会一路调下去,碰到每一个缺失的接口,不会因为你从场景搬了几行代码就认为你是场景。"
它给 mock material 加上了 clone()。
四、真镜
第一个文件——material-editor.test.ts——39 个测试。
当最后一个 lint 错误消失,桌面壳敲下 npx vitest run。它看着进度条走过:
_catOf 分类 11 个——全绿。"皮肤"、"头发"、"眼睛"、"服装"、大小写不敏感、kihada、body、ahoge……
setMatParams 14 个——全绿。扩散、高光、光泽、边缘、置零、无效索引不崩溃……
applyMatState 3 个——全绿。类别复原、overrides 复原、空状态复原……
_applyAll 叠加顺序 5 个——全绿。per-material 扩散不被 bulk 二次覆盖,高光不被覆盖,光泽不被覆盖,环境光不被覆盖,多材质独立……
39/39 通过。
"这 5 个——"MenuStack 看着 _applyAll ordering 那组,"这 5 个是之前没有的?"
"用户钉的坑。伪测试锁不住叠加顺序——因为伪测试连 _applyAll 都没真 import。这组是新写的 regression 测试。"
第二个文件——model-preset.test.ts——20 个测试。
桌面壳把 413 行的本地 mock——createSamplePreset、本地 applyTransform、本地 stopVMD、本地 applyMatState——全部删除。换成了三行 import:
typescript
import { serializeModelPreset, applyModelPreset, ModelPresetFile } from "../library";
import { stopVMD, getMatState, applyMatState } from "../scene";
import { modelRegistry, dom, setMmdRuntime, setIsPlaying } from "../config";然后在文件最顶上——在第一个 vi.mock 之前——加了一段 vi.hoisted。
不是因为策略,是因为技术约束。library.ts 在模块顶层执行了 dom.btnClosePopup.addEventListener——如果 dom 引用的是 null,import 就炸。所以必须在 import 之前,在 happy-dom 的空文档里创建所有 DOM 元素。
三十七个 document.createElement("div")。一个 for 循环。
"为了 import 一行 serializeModelPreset,你建了一整面 DOM 墙。"MenuStack 说。
"不是墙。"桌面壳说,"是海关。每次 import 一个模块,它的顶部代码会立即执行——就像一艘船靠岸时,海关人员会上船检查。船上的货物、船员、猫——全部在那一刻被统计。不建海关,船靠不了岸。"
serialize 6 个——全绿。全字段 JSON、不存在模型返回空、null rootMesh → position 归零、空 VMD 返回 null、material state 序列化、数值精度……
apply 5 个——全绿。transform 写入、visibility 写入、VMD state 清除、material state 生效、不存在的模型不抛……
getMatState/applyMatState 5 个——全绿。null state、类别来回、overrides 来回、空 state 不变、string-keyed cast……
stopVMD 4 个——全绿。字段清除、mmdModel 调用、playing 时 pause、不存在不抛……
20/20 通过。
第三个文件——config.test.ts——13 个纯函数测试。
formatTime(90) → "01:30.00"。
escapeHtml("<script>") → "<script>"。
normPath("C:\\a\\b") → "C:/a/b"。
13/13 通过。
没有三十行 mock,没有 Babylon.js 纸板副本,没有 DOM 海关。纯函数三到五行,不可变,从出生那天就一直是它自己。它们不需要测试镜子——它们本身就是镜子,映照输入输出的一对一映射。
五、总数
桌面壳在全量运行时盯着终端看的那几秒,可能是它那天最安静的时刻。
✓ src/__tests__/vpd-parser.test.ts (21 tests)
✓ src/__tests__/config.test.ts (13 tests)
✓ src/__tests__/material-editor.test.ts (39 tests)
✓ src/__tests__/model-preset.test.ts (20 tests)
Test Files 4 passed (4)
Tests 93 passed (93)93 个。没有一个是在测自己的影子。
"93 个不多。"MenuStack 说。
"93 个不少。"桌面壳说,"每个都调了真实代码——每个都是真镜。伪影清零之前是 25 个真 + 45 个伪。现在 93 个真,零个伪。"
它又加了一句。
"而且 Babylon.js 的 mock 层——三十个纸板城邦——下一个测试文件可以直接扛走。这就是基础设施的乘数效应。"
六、教训
那天快结束时,桌面壳站在两个世界之间。
一个世界里,测试是绿光——只要绿,就没人怀疑它是打在墙上还是打在敌人身上。另一个世界里,测试是镜子——必须照见真实才叫测试。
"审计审计,"它自言自语,"不是查代码,是查联邦对自己的认知。"
它想起来,今天还顺带做了一件事——VPD 姿势导入。一个新文件 vpd-parser.ts,从 VPD 文本格式解析骨骼变换,转成 VMD 二进制帧,66 字节一帧,15 字节骨骼名(Shift-JIS + 0x20 空格填充),4 字节帧号,12 字节位置,16 字节旋转,16 字节插值(全 0x7F),3 字节保留。
"这个呢?"MenuStack 问。
"锁住了。TDD 写的,21 个测试——而且全是真的。"
它停顿了一下。
"伪测试就像画在地图上的河流——看起来在流,数据也有,但你没喝过。真测试就像站在河边弯腰捧一手——凉不凉、涩不涩,只有这一下知道。"
"所以你以后只建真的了。"
"我从来没想过建假的。"桌面壳说,"我只是觉得绿光就够了。今天我知道了:绿光只能告诉你'代码没崩',不能告诉你'地图是对的'。"
它关上终端。
"绘图师要的不是绿光。绘图师要的是知道脚下是真的。"
教训:伪测试是地图上的河,真测试是弯腰喝的那一口。