# 2026-07-31 复盘 — 一条"红了两个月"的闸，复位后才发现它本来也看不见东西

> 任务：[[INFRA-F86]] ① `test:visual` 的 33 页 × 2 主题 baseline 复位。
> 产出：`66a4becd`（复位 + 三处机制修复 + 一个真缺陷）· `e92f0b51`（上闸 + 3 条接线断言）。
> 拆出：[[INFRA-F88]]（闸只覆盖每页 720px）· [[INFRA-F89]]（`:3001` 网页 UI 已坏）。

---

## 一 · 核心认知：「闸红」是最吵的失效模式，它掩盖了三个静默失效

进来时已知的问题只有一条：baseline 停在 v0.7.0，33 页全红。这条**很吵**（每次跑都喊），所以两个月里所有注意力都在它身上。复位过程中才发现，同一条闸上还叠着三个**不喊**的失效：

| # | 失效 | 它有多静默 |
|---|---|---|
| 1 | baseline stale（已知） | 每次跑都红 —— **吵** |
| 2 | pixelmatch 默认 `threshold: 0.2` 吞掉灰对灰改动 | 实测：一个控件边框从**不可见变可见**，diff = **0 个像素**。闸会说"通过" |
| 3 | 只截 viewport 720px，而页面真高 2648–179938px | 覆盖约 **7%** 的像素面。闸会说"通过" |
| 4 | 视觉审批门（`VISUAL_COMMIT_APPROVED`）不覆盖 `.png` | 66 张 baseline 可以无审批提交。**AI 能自签视觉真源**。没有任何报错 |

**判据（可复用）**：修一条"一直在红"的闸时，不要把"让它变绿"当终点 —— 红只是它**最容易被发现**的那个毛病。变绿之前先问三句：**它测的范围是什么 / 它的灵敏度是多少 / 它的产出物受什么保护**。这次三句各抓到一个真问题。

如果只做被要求的那件事（重拍 66 张、让它绿），交付出去的会是一条**看起来更可信、实际仍然近乎全盲**的闸 —— 比继续红更危险，因为红至少不会让人误以为有覆盖。

---

## 二 · 犯的错（如实记，含差点当成结论的中间态）

1. **凭印象推断容差语义，方向反了**。看到 `maxDiffPixels: 100` 与 `maxDiffPixelRatio: 0.001` 并存、而 474 字节差的改动竟然 pass，我的第一反应是"两者取更宽松者（922px）"。读盘上源码才发现 `playwright-core/lib/server/utils/comparators.js` 是 `Math.min(maxDiffPixels1, maxDiffPixels2)` —— **取更严者**，100 恒为约束。真凶是每像素感知阈值，不是像素预算。**教训与硬规则 #10 同型**：判断工具行为前读装在盘上的实现，不要读记忆里的文档。
2. **第一次量覆盖率量的是挂载前的高度，得出一张错表**。`networkidle` 后立刻读 `scrollHeight`，多数页给 1367/1389px（那是 `DocsPageLoading` 骨架的高度），于是算出"覆盖率 8%–53%"。是同一批数据里 `button` 一处 9920 vs 1389 的自相矛盾把我拽回去的。**教训**：两次测量互相矛盾时，先解释矛盾、再报任何数字；矛盾本身就是信息（这次它直接指出 `networkidle` 不是就绪信号，成了修复 ③ 的依据）。
3. **故障注入第一次是无效注入，而"7 passed"看起来像成功**。我用 `sed` 去破坏 pre-commit 的 pattern，转义没写对、字符串其实还在，测试自然仍绿。当时若不顺手 `grep -c` 核一下替换后的命中数，两种误读都可能发生：以为"断言是空过的"，或以为"注入成功且闸有问题"。**判据**：故障注入必须先证明**故障真的注进去了**（正是 `regression-pass-needs-fault-proof` 那条 memory 的下半句），再看测试红不红。

---

## 三 · 验证有效的做法（下次直接用）

1. **收紧任何容差/阈值之前，先量噪声底 —— 而且要在 settle 之后量**。第一版噪声测量（`.docs-loading` 消失即截图）给出 1303–3687px 的"噪声"，据此会得出"阈值根本不能收紧"的错误结论。补上"连拍到两帧逐字节相同"（即 `toHaveScreenshot` 自己的稳定性语义）后，七页两次独立加载在 `threshold` 降到 **0.05** 时噪声都是 **0 px**。有了这个数，"收紧到 0.1"从口味变成了带证据的决定。
2. **让"闸为什么放过它"变成可核算的数字**：直接 `require` 工具自己打包的比较器（`playwright-core/lib/third_party/pixelmatch` + 它的 `PNG`）离线复算同一对图片，得到 `threshold=0.2 → 0` / `0.1 → 152` / `0 → 152`。随后套件实跑的失败信息逐字就是 `152 pixels` —— 离线复算与实跑对上，才敢说机制搞清楚了。
3. **一个改动只该动一处时，用 token 选择去保证它**：修 light 下不可见的边框时选 `--input-line-border`（dark 值与原 `--line-border` 逐字相同 `#595959`，light 给 `#cccccc`），于是 `form-dark` 保持字节不变、只有 `form-light` 需要重签。验收跑的结果正是「1 failed（form-light 152px）/ 32 passed」，一眼可读。
4. **闸的接线要有断言 + 四组故障注入**（沿用 [[INFRA-F87]] 立下的约定）：`release.mjs` 真调 `test:visual` 且走 `fail()`、没有 `--skip-visual` 逃生舱、pre-commit 真覆盖 `__screenshots__` 的 png、spec 里挂载等待与 `threshold` 都还在。每组注入后 `cmp -s` 验复原逐字节一致。
5. **报障先分「HTML」与「子资源」**：owner 报 `:3001` 打不开，我第一次 `curl` 首页拿到 200 就说"服务正常"。真相是首页 200、子资源全 404（[[INFRA-F89]]）。**`curl <首页>` 返回 200 不能证明页面可用**；至少再取一个 HTML 里引用的 asset。这条跨项目通用。
6. **owner 的 66 张图是稀缺资源，一次性用掉**：所有"会改变截图的决定"（要不要 fullPage、要不要修 form 的 CSS、要不要收紧阈值）都在**送审之前**定完，避免让 owner 审两遍。这也是为什么值得先花 20 分钟做 fullPage 可行性实测（结论：不可行，4/8 页连拍不一致 + `icon` 页 17.9 万 px）。

---

## 四 · 留给下一个人的一句话

**"视觉闸绿了"必须永远跟一句"绿覆盖什么"。** 现在这条闸的准确含义是：*33 个 docs 页的顶部 1280×720、dark 与 light 两个主题，与 owner 2026-07-31 逐页签核过的截图相比，在 pixelmatch `threshold: 0.1` 下差异不超过 100 像素。* 它不覆盖页面主体（[[INFRA-F88]]）、不覆盖跨机字体栅格化差异、也不判断截图本身对不对（那永远是人的活）。`RELEASING.md` 里已按这个口径写明边界。
