Repository navigation
The jump arrow follows the scroll, clamped at both ends; and the shipped sources stop carrying private numbers - #17
Conversation
…not have Forty-one of these comments opened with a label from a private numbering -- D40: as a section header, R1 inside a sentence, L2 and L3 naming layers that are defined in a document which is no longer in the package. The explanation was usually on the next line, so a reader was never going to go looking for it. What they did say, to someone reading the installed plugin, is that the project keeps a numbered defect list they cannot see. They are gone, and what replaces each one is the thing the number was standing in for: the rule as it reads. `/* ---- the inspector's own viewport ---- */` instead of `/* ---- D40: the inspector's own viewport ---- */`; the narrow-screen rule described as the narrow-screen rule; the prohibition this function enforces instead of the prohibition D13 enforces. Twenty of these were pointers, and they went in the previous commit. These are the other kind -- the label with no file name -- so the previous commit's assertion could not see them, which is why it took a second assertion rather than a wider pattern. The four layers are gone with them, and they were the same leak from the same missing document: `L0`-`L6` is a layer model with no key. node tests/run.mjs 57/57.
Two new assertions, one new pattern, and the boundary between them written down because the next person will trip over it. The new check covers the runtime sources and the two shipped test files, and deliberately does not cover the guard and mutation files -- which carry five times as much of this numbering as the runtime did. They are not the same shape. In index.html the number was a label on a section header with the explanation elsewhere. In mutate-inspector-dock.py it is "D40 gave the wide panel a ceiling and an inner scroller, so the sentence that scoped the header ... stopped being true": the number names the defect and the same sentence says what it did. That is the provenance that tells a reader why a control is in the suite at all, and METHODOLOGY.md is written the same way. Taking it out would cost the reason a mutation exists to remove a word nobody follows. So the pattern has to be careful in the other direction too: `#D8D8` is a colour and `R1` could be an identifier, so the word boundaries are load bearing. Anything looser is a guard that fails for the wrong reason. One mutation anchor broke and was re-anchored rather than dropped. mutate-cross-layer.py spells out the whole panel body so its replacement can put the window bar outside the closing `</section>`, and that body carried one of the rewritten comments. The harness refuses to start on an unresolved anchor, which is the right behaviour and still a failure this change caused. verify-review16.mjs 33 -> 34 assertions. mutate-review16.py 30 -> 32 mutations, all caught. mutate-cross-layer.py unchanged in count, re-anchored.
报告者指出:滚轮往上滚箭头就向上,到最顶端则向下,到最低端反之;滚出轨迹
区域时按位置夹取,不按滚动方向。
这一版替换掉上一版为此加的 ledgerJumpUsed 标志。新的判据没有「首屏」这个
特殊状态 —— 箭头任何时候都有答案,所以标志连同「先问目的地再武装」的顺序
规则一起消失。
ledgerJumpGoesUp() 换成四个具名分支,顺序是有意义的:
两端都不可达(整页放得下)→ 默认朝下,这是唯一没有否决权可施加的一格
底端已到或已过 → 朝上
顶端在屏 → 朝下
都在屏外 → 跟随方向
顺序就是规则,所以守卫按次序断言,不只断言四个分支都在。
方向信号不发到 <body>。当年那个用 body.is-reading-down 驱动控件可见性、后来
被撤回的信号,其撤回理由是「带内控件的可见性由带外的信号决定」—— 那个理由
在这里不成立:这是导航控件的朝向,而朝向本来就是滚动方向的直接后果,且没有
第二个读者。判据是用途,不是信号本身。
canDown 改用 ledgerEndBelow(),不再取 !ledgerEndInView():后者问的是「哨兵在
不在视口里」,那一个布尔值同时对应「还没到」和「已经滚过去」两个相反的状态,
而只有前者意味着还能往下跳。atLedgerEnd 与观察器对它的写入随之消失 —— 谓词
每帧重读,那个标志只剩一个写手没有读者。
这一格在当前布局下结构上不可达,所以不能写成实测结论:检视栏的高度上限按视口
算(窄屏按 INSPECTOR_LEDGER_SHARE = 0.25 给轨迹留四分之一视口),而这一格要求
检视栏比轨迹栏再高出整个视口。布局恰好让旧谓词成立,那是运气不是正确性,所以
谓词按它真正问的问题改写,并由守卫把「问的是哪个问题」本身钉住。
滚动监听器只读 window.scrollY(不触发布局),重活过 rAF 批一次,与本页
scheduleStickyOffsets 同一惯用法:该文档实测每帧 617ms 布局,滚动事件比帧密。
监听器是具名函数,不是内联的。verify-generation-pin 要求每个 scroll 处理器都指向一个
真实存在的函数,因为那条守卫要防的变异会重新加回注册而函数已随撤回删掉 —— 那正是部分
撤回留在磁盘上的样子。内联的 function () {} 有同样的隐患却毫无可审计性:没有名字给读者、
给 grep、给那条守卫去核对,而「每个 scroll 处理器指向的东西都存在」这条规则按构造就看不见它。
42 → 57 条断言,39 → 52 条变异,全捕,文件逐字节还原,还原后再跑一遍绿。
3 条守 ledgerJumpUsed 的变异随规则一起删。那个字段是被报告者的决定删掉的,不是
为了让变异通过而删 —— 这两件事必须分开写,否则下一个读提交的人会以为又一次犯了
「为了让变异通过而改断言」。相应断言的替代物是「方向有第三个值 null 而不是默认值」
和「规则不能塌成两个由顺序暗定的项」,各配一条变异。
两条变异重锚到新代码:哨兵判据从 atLedgerEnd = … 变成 return r.top >= viewH …,
跳转目标从 ledgerEndInView() 变成 ledgerEndBelow()。
新增 15 条断言覆盖新规则,其中三处是写这一版时才暴露的:
- 「先问后写」那条断言第一版比的是 buildLedgerJump() 全函数里首次出现的位置,
而上面那个 scroll 监听器也写 ledgerScrollDir,于是断言被一个与它无关的写入
满足,配套的变异没被抓住、报出来的是另一条。改成限定在 click 处理器内部。
- 探针的现场条件一律报「量不到」而不是失败。2026-10-14 那一轮拿 1 条消息的会话
跑时,输出里 9 处 FAIL 没有一处是功能不对:整条轨迹只有 1014 高、文档总共 1146,
中段、底端、真滚轮、点按那几站的前提全都构不成。把「量不到」写成「坏了」的报告
比没有报告更坏,它会把人引去改本来正确的代码。
- 探针切会话原来是个无声的 no-op:找不到就 return undefined,页面停在默认会话上,
探针照样把后面所有数字打出来。换成短会话那一次,输出与大会话逐字节相同(轨迹
底都是 8192),而那个会话只有 1 条消息,轨迹底不可能还是 8192。现在切会话
当断言,并且要等下拉真的有选项 —— 它是异步填的,readyState 变 complete 时
还是 0 项。
新变异里最该留下的一条是把 var canDown = ledgerEndBelow() 改回取负:它在任何可达
滚动位置上测不出差别,靠的是守卫钉住「问的是哪个问题」。布局恰好让错的答案成立
是运气,不是正确性;只钉可达行为的守卫永远发现不了这种错。
这一轮还有两条是别的守卫逼出来的,都不是本守卫的问题:
- verify-generation-pin 要求每个 scroll 处理器都指向一个真实存在的函数。第一版把
监听器写成内联的 function () {},那条规则按构造看不见它,整套因此 55/57。改成
具名函数 ledgerJumpOnScroll。变异的锚点跟着改成注册本身而不是函数体:把函数
一起删掉会被 generation-pin 以错误的理由抓住,等于没证明方向这件事。
- 写具名函数时在注释里原样引了那个被撤回的注册,verify-generation-pin 随即报一个
只存在于散文里的悬空处理器。它是裸文本扫描,分不清一次调用和一条提到该调用的注释。
注释改成描述那个注册而不是抄写它,这个坑也写进了源码注释。
MyPrototypeWhat
left a comment
There was a problem hiding this comment.
谢谢,这一版两件事拆得很清楚。我在 9a22be2 上核对过:去掉注释后,index.html 只剩跳转箭头这部分逻辑改动,server.mjs 只改了一处注释;miniapp/ 下已经没有私有编号、作者名或本机路径。npm run check、tests/run.mjs(57/57)和 CI 都通过,D14 和 #15 那几项也没有回归。
箭头在 1300×900、863×803、420×760 三种视口下实测:中段往下滚朝下、往上滚朝上,到底朝上,滚到轨迹顶端之上朝下,一屏放得下的短会话一直朝下,控制台没有报错。
需要修改
- 点「朝上」跳到轨迹顶端后,箭头在部分视口下仍然朝上,再点也没有反应。原因是
ledgerTopOffset()来自getBoundingClientRect(),是小数,而scrollTo落点会取整:1300×900 下 offset 是 503.77,滚动停在 504,504 > 503.77让canUp仍为真,于是走进「中段」分支,沿用点击时写入的'up'。第二次点击是scrollTo(0, 503.77),位置不变,箭头也不变。420×760 下(1108.91 → 1109)也是一样;863×803 下 offset 恰好是整数,所以看起来是正常的。比较时留 1px 容差(或两边取整)应该就能解决,最好再在verify-ledger-jump里补一条小数 offset 的用例。
建议(可选)
- 这是用户能看到的行为变化,按 CONTRIBUTING 的说法,可以考虑把版本号升到 1.1.1。
PR 描述已经写得很完整,可以直接作为 squash 的提交信息,谢谢。
| function ledgerJumpGoesUp() { | ||
| return ledgerJumpUsed && ledgerEndInView(); | ||
| var y = window.scrollY || window.pageYOffset || 0; | ||
| var canUp = y > ledgerTopOffset(); |
There was a problem hiding this comment.
ledgerTopOffset() 是小数(1300×900 下是 503.77),scrollTo 落点会取整成 504,这里的严格大于让跳到顶端之后 canUp 仍为真,箭头停在朝上,再点也不会动。建议留 1px 容差,比如 y > ledgerTopOffset() + 1。
There was a problem hiding this comment.
确认成立,而且范围比你说的宽:它不是某一个视口坏了,是间歇的。
机制
落点与判据读同一个数,而浏览器不保留它。ledgerTopOffset() 由 getBoundingClientRect() 给出,精度是 1/64px;scrollTo() 落在整数像素上。落到 503.7656 时读者的位置是 504,于是「严格大于 503.7656」把落点本身读成「上面还有」,箭头停在朝上,再点一次落回 504,页面不动。
浏览器实测(headless Edge + CDP,视口 760–1600 × 900,deviceScaleFactor 1,同一场会话):
| 视口宽 | ledgerTopOffset() |
落点 scrollY |
零容差判据 scrollY > offset |
|---|---|---|---|
| 760 | 996.5156 | 997 | true |
| 1100 | 521.7656 | 522 | true |
| 1300 | 503.7656 | 504 | true |
| 1440 | 503.7656 | 504 | true |
| 1600 | 503.7656 | 504 | true |
| 897 | 732.1406 | 732 | false |
最后一列是零容差那版判据在落点处的读数,由同一组落点与 offset 直接算出 —— 它在五个视口上为真,也就是说到了顶端它仍然回答「上面还有」。897 是唯一的例外,那一格本来就正确。
897 那一格把量化钉成就近取整而不是恒向上取整 —— 我最初是按后者推的,被它否掉了。于是这个缺陷取决于 offset 的小数部分落在哪一格:六个里有五个的旧判据在落点处为真,897 不是。读者无法预知自己会不会撞上,这比稳定复现更难被当成缺陷。
落点与判据的差实测最大 0.4844px,不到半像素 —— 正是「round 一个整数像素」的理论上界。
修法:用 Math.ceil,不是 +1
你建议的 y > ledgerTopOffset() + 1 成立。改成 y > Math.ceil(ledgerTopOffset()),理由是它对三种舍入都成立:
Math.floor:浏览器一旦向上取整就又卡住;Math.round:只在当前这条舍入规则下成立,而一次绿色的运行不会说明这一点;Math.ceil:落点必然 ≤ 它 —— 这就是「已到达」本身,不需要猜量化粒度。
底端没有同一个问题,所以没有一起改。 它的落点是「底边到视口顶」,判据是「底边还在折叠线以下」,两者相距整个视口高度,量化误差淹不掉;实测它的 offset 在每个视口上都是整数。给两端加同一个容差会把一条本来对的边界调歪。
一处你说对了、而我原来没看到的东西
tests/verify-ledger-jump.mjs 里有一条断言原本就把零容差写法当成规则钉着:
/var canUp = y > ledgerTopOffset\(\);/
所以这不是「守卫漏了它」,是守卫正握着它 —— 全绿,而其中一条钉的是错的东西。这一轮不只是加断言,是把那条改回来。
新增 4 条断言:零容差写法回不来;边界必须向上取整(floor 和 nearest 都不放行);底端不被加上同一个容差;落点保持不取整的 offset —— 最后一条是为了堵住同一个修复的第二条路:把落点也取整同样能让判据对上,但那是改了「跳到哪里」而不是「怎么算到达」。
新增 5 条变异,全被抓住。其中一条特意留着,因为它在当前浏览器上功能是对的:把 Math.ceil 换成 Math.round。它只在这一条舍入规则下成立,而跑绿时没有任何东西会说明这一点 —— 这就是钉「向上」而不是钉「一个能用的舍入」的原因。
同一组视口修后复验:到达顶端后 ledgerJumpGoesUp() 返回 false,箭头翻成朝下,再点真的滚到另一端(移动 6784–6908px)。落点数字一个没变 —— 改的只是怎么读那个落点。
外评审指出:`run.mjs` 里那个「跑完检查 shipped 文件是否还原」的守卫,比的是 **本次起点和本次终点**。被 kill -9 掉的那一次什么也写不下,所以它留下的变异态要等到 下一次运行才会被发现 —— 而那时它只表现为一堆无关的红。实测是 kill -9 掉一个变异控制后, 下一次 `node tests/run.mjs verify-anomaly` 报了一堆不相干的失败,**没有出现 shipped files 的任何提示**。 查下来是三处毛病,任何一处单独存在都足以让这个比对完全失效: 1. 快照是「已存在时才写」,全新检出永远没有快照可比,那行等于从来没执行过; 2. 写的是本次运行的**起点**,不是它结束时的字节; 3. **没有任何地方读它** —— 那个常量在 run.mjs 里出现 4 次,全是写。 第 3 条是根因。前两条只让它没内容可比,第 3 条让它连被读的资格都没有。 **改之前先确认它还在**:跑一次 `node tests/run.mjs verify-ledger-jump`,之后那个快照 文件不存在;手工造一份「上次干净结束」的快照,再把 index.html 改成一个真实变异(拆掉 四分支里「顶端在屏 → 朝下」那一条),同一条命令输出 `0/1 guards passed / failed: verify-ledger-jump.mjs`,一句都没提残留;改过的文件逐字节还原。 现在启动时读快照,逐文件与记录下来的摘要比对,不同就在结果**之前**打一行 WARN,点名 文件并说明两种可能。写快照的条件一并修好:无条件写、记**终点**、且只在本次没有变脏时 更新基线 —— 记一个变脏的终点会让下一次比对和变异意见一致,正是这个文件要防的事。 **这是报告而不是失败**,取舍和理由写在那行输出里:这套件会跑在有未提交改动的机器上, 「和上次结束时不一样」既可能是残留的变异态,也可能是有人在跑完之后正常改了代码,两者 字节上无法区分;一律判失败会把正在写代码的 contributor 报成坏掉的检出。而评审给的另一条 `git status --porcelain` 在装包副本里拿不到 —— 套件设计上就要能在没有 git 的地方跑。 改完之后同一份复现:快照会被创建,输出顶部出现 `WARN shipped files differ from the last run miniapp/client/index.html, …`。 node tests/run.mjs 57/57。
一个比对可以被三种方式悄悄去掉,而这三种读起来都完全正常:把读的那一行删掉(快照
从此只是写)、把写的条件改回「已存在时才写」(全新检出永远没有基线)、把记的内容
换回起点(等于什么都没记)。
verify-review16.mjs 加 7 条断言,mutate-review16.py 加 5 条变异,全捕。
其中两条值得单说,因为它们是「只有比较两个名字才发现」的那一类:
- 把两条消息**改成同名**。`shipped files restored` 与 `shipped files differ from the
last run` 合成一个名字,读者就无法分辨是哪一次运行弄脏的源码 —— 而这两种情况的
处置完全相反:一个是本次运行的守卫失败,另一个是启动时就带着上一轮的残留。
- 把快照改成**记起点**。起点与终点只在没有变脏时相等,所以这一条在每一次干净的运行
上都与正确版本没有区别。
另外三条分别是:不读快照、把逐文件比对换成常量、把「只在干净时写」改成总是写。最后
一条是唯一会**把问题藏两次**的 —— 记一个变脏的终点,下一次比对就与变异意见一致了。
断言里有一条值得单说:「缺失或读不出来的基线既不算差异也不算失败」。没有基线的是全新
检出,拿它当「全都不一样」会让每一次首跑都报警,拿它当失败会让每一个新克隆都红。
verify-review16.mjs 34 -> 41 assertions.
mutate-review16.py 32 -> 37 mutations, all caught.
node tests/run.mjs 57/57.
Reported in review, and it holds: at the top of the ledger the arrow pointed up and a second press moved the page by zero pixels. The landing and the test for it read one number, and the browser does not keep it. The offset is answered in 1/64px by getBoundingClientRect(); scrollTo() lands on a whole pixel, rounded to nearest. Landing at 503.7656 therefore puts the reader at 504, and a strict 「greater than 503.7656」 reads that as 「there is more above」. Measured across viewport widths the gap never reached half a pixel — and it decided the outcome. The same code froze the control at 1300, 1440, 1100 and 760, and behaved at 897, because 897's offset lands on a fraction below the nearest integer rather than above it. A defect that only some widths produce is harder to recognise as one than a defect that always happens. Rounding the offset up is what 「arrived」 means, rather than a tolerance chosen to cover the measurement. Rounding down fails the moment the browser rounds up; rounding to nearest is right only for the rule in force today, and nothing in a green run would say otherwise. Up holds for all three. The bottom end is deliberately left alone. Its landing is the end at the top of the viewport while its test asks whether the end is still below the fold — a whole viewport apart, so no rounding error reaches across it — and its offset happened to be a whole number at every width tried. That is why the two ends are treated differently rather than symmetrically.
…e bug The first version of this file asserted the zero-tolerance form as if it were the rule: /var canUp = y > ledgerTopOffset\(\);/ so 57 assertions were green and one of them was holding the defect in place. That is the other half of 「a guard that cannot fail is not a guard」 — a guard can pass while naming the wrong thing as right, and nothing in a green run distinguishes the two. So this fix is not only to add assertions. It is to take back the one that was asserting the bug. verify-ledger-jump.mjs 57 -> 61 assertions. Four new ones: the zero-tolerance form cannot come back; the boundary is rounded UP, with floor and nearest both rejected — nearest rejected even though it is the rounding this browser actually does; the bottom test is left alone; and the landing keeps the un-rounded offset, so the same fix cannot be reached a second way by rounding where a jump ends instead of how 「arrived」 is read. mutate-ledger-jump.py 53 -> 58 mutations, all caught; file restored byte for byte and green again afterwards. Two of the five are worth naming. Dropping the boundary is the defect itself, and the only one here a reader could walk back into by accident: the shorter form reads as the obvious way to ask 「is there more above」, and nothing in the source says a landing does not land where it was aimed. Changing it to Math.round is kept precisely because it is CORRECT on this browser — it holds only for the one rounding rule in force, and a green run says nothing about that. This is also why what is pinned is 「up」 rather than 「a rounding that works」. The probe had to change too, for the same shape of reason. Its reader carried its own copy of the predicate under test, so the first run after the fix reported the landing as 「still more above」 while the product had already answered no — it was measuring its own copy, which would never have moved no matter what shipped. Verdicts now read the shipped function's own answer, and the old form is kept in the output only as a contrast. node tests/run.mjs 57/57. Fixture check OK.
MyPrototypeWhat
left a comment
There was a problem hiding this comment.
感谢更新!我在本地 headless Chrome 里复测了 ea1f366 的修复:宽度 760、897、1100、1300、1440、1600(高 900),以及 863x803、420x760,另外在 1.25、1.5、2 倍缩放下也测了。顶部偏移量是小数时(例如 1300x900 下为 503.77),点击“回到顶部”后箭头会正确切换为向下,下一次点击能正常跳到底部,再点击能回到顶部;短账本和上下滚动的切换也都正常。把这一行还原为 y > ledgerTopOffset() 后,verify-ledger-jump.mjs 会失败,说明新增的断言能覆盖这个问题。npm run check(55/55)和 tests/run.mjs(57/57)都通过了。
需要修改
无。
建议(可选)
tests/run.mjs的checkShipped()(约 102-108 行):drift 是和本次开头的 snapshotBefore 比较的,所以即使开头已经检测到 leftover,只要本次运行正常结束,就会把漂移后的内容写成新基线。结果是警告只会出现一次,而且用git checkout恢复文件后下一次运行还会误报一次。可以考虑在 leftover 非空时不刷新快照,或者单独提示。- 本 PR 改动了客户端行为,
.minimax-plugin/plugin.json仍是 1.1.0,可以考虑升到 1.1.1。
两件互相独立的事,可以分开看。
plugin.json没有声明files,整个目录会装到每个读者的机器上,所以注释里写着一个只有作者解得开的编号,是欠读者的债。40 处,跨 2 个源文件。一、箭头的规则
ledgerJumpGoesUp()从「读一个标志」改成「读读者的方向,两端各有否决权」,四个具名分支,顺序是有意义的:顺序就是规则,所以
verify-ledger-jump.mjs按次序断言,而不只断言四个分支都在。第一条之所以排第一:它是唯一没有否决权可施加的一格,上一版靠ledgerJumpUsed在这里解围,而上一版在这个格子里本来就是退化的。规则不能写成两项用
&&/||连起来:两项的先后会替「两端都不在屏」这一格做决定,而且不留下任何痕迹。守卫因此断言形状,而不是断言某个结果。几个具体决定
<body>。 本页曾有过一次滚动驱动的控件让位,撤回理由是「带内控件的可见性由带外的信号决定」。那个理由在这里不成立:这是导航控件的朝向,朝向本来就是滚动方向的直接后果,而且没有第二个读者。同一个信号,两种用途,判据是用途而不是信号本身。ledgerJumpUsed,而不是新增一个字段。 新规则没有「首屏」这个特殊态 —— 箭头任何时候都有答案,所以标志连同「先问目的地再武装」的顺序规则一起消失。scrollTo()不同步触发 scroll 事件,而「朝你已经在的那一端按下去」一次都不触发 —— 等事件会让字形停在一个页面已经离开的位置。window.scrollY(不触发布局),重活过 rAF 批一次,与本页既有的粘性偏移同一惯用法:这个文档实测每帧 617ms 布局,滚动事件比帧密。verify-generation-pin.mjs要求每个 scroll 处理器都指向一个真实存在的函数 —— 它要防的正是「注册被重新加回、函数已随撤回删掉」。内联的function () {}按构造就看不见。一处改动必须说清楚它不是什么
canDown原本是!ledgerEndInView(),问的是「哨兵在不在视口里」。那一个布尔值同时对应「还没到」和「已经滚过去」两个相反的状态,而只有前者意味着还能往下跳。已改成ledgerEndBelow(),问「底端还在折叠线以下吗」;atLedgerEnd这个只剩写手没有读者的变量随之消失。这一格在当前布局下结构上不可达,所以下面不把它写成实测结论。 轨迹是两栏里更高的一根,而检视栏的高度上限是按视口算的(窄屏下
--inspector-full-h由inspectorUnderPins() - max(140, 0.25 × 视口高)得出,INSPECTOR_LEDGER_SHARE = 0.25),于是「滚到轨迹底端之下」需要检视栏比轨迹栏再高出整个视口。布局恰好让旧谓词成立 —— 那是运气,不是正确性。所以这一条由守卫钉住「问的是哪个问题」,而不是钉住某个结果;
mutate-ledger-jump.py里那条把ledgerEndBelow()改回取负的变异因此保留:它在任何可达滚动位置上测不出差别,靠的是形状被钉住。布局哪天变了,第一个报出来的就是它。二、私有编号
miniapp/client/index.html与miniapp/node/server.mjs里 40 处裸编号改成能独立读懂的话。守卫verify-review16.mjs现在断言 shipped 源文件里不再有这类编号,且同一条断言覆盖两个文件(原来只看其中一部分)。tests/下 242 处同样的编号刻意保留:那里的编号后面同一句话就地解释了它指哪个缺陷,是那些变异控制存在理由的溯源。守卫的边界和理由写在断言上方。改完之后,两个随包源文件里的裸编号数为 0 —— 这一条由
verify-review16.mjs断言守住,配一条把编号写回注释的变异。三、守卫
tests/verify-ledger-jump.mjstests/mutate-ledger-jump.pytests/verify-review16.mjstests/mutate-review16.py撤回了 3 条变异,连同它们守的 3 条断言。 它们守的是一个「按过一次就特殊」的记忆位,而这个规则是被这一版的规则取代的 —— 不是为了让某条变异通过而删。断言的替代物是「方向有第三个值而不是默认值」和「规则不能塌成两个由顺序暗定的项」,各配一条变异。
另有 2 条变异重锚到改动后的代码。
新增断言里有三处是写这一版时才暴露的,值得单说,因为它们都是「断言看起来在管一件事、实际被别的东西满足了」:
buildLedgerJump()里首次出现的位置,而同函数内的 scroll 监听器也写同一个字段,断言被一个与它无关的写入满足,配套变异没被抓住、报出来的是另一条。改成限定在点击处理器内部。四、验证
node tests/run.mjsnode tests/make-fixtures.mjs --checknpm run validate(仓库根)浏览器实测(2026-10-14,视口 897×900,一条 276 KB 会话,轨迹顶 83 / 底 8178):26 项,0 失败,1 处量不到。量到的包括 —— 中段往上滚 → 朝上(本轮核心)、到顶端 → 朝下、到底端 → 朝上、滚出轨迹顶端之上 → 朝下(方向是上,按位置夹取)、连发 6 个向上滚轮 → 朝上、连发 10 个向下滚轮 → 朝下、点击落点与箭头自动翻转、按钮在
scrollY0→900 全程可见(顶端夹取读者看得见)。量不到的那一处即上面第一节说的那一格。五、这一轮没做的
六、
tests/run.mjs的跨启动快照外评审指出:套件里那个「跑完检查 shipped 文件是否还原」的守卫,比的是本次起点和本次终点。被
kill -9掉的那一次什么也写不下,所以它留下的变异态要到下一次运行才会被发现 —— 而那时它只表现为一堆无关的红。实测就是kill -9掉一个变异控制后,下一次node tests/run.mjs verify-anomaly报了一堆不相干的失败,没有出现 shipped files 的任何提示。查下来是三处毛病,任何一处单独存在都足以让这个比对完全失效:
run.mjs里出现 4 次,全是写。第 3 条是根因。前两条只让它没内容可比,第 3 条让它连被读的资格都没有。
改之前先确认它还在:
node tests/run.mjs verify-ledger-jump跑完,那个快照文件不存在;手工造一份「上次干净结束」的快照,再把index.html改成一个真实变异(拆掉四分支里「顶端在屏 → 朝下」那一条),同一条命令输出0/1 guards passed / failed: verify-ledger-jump.mjs,一句都没提残留;改过的文件逐字节还原。现在:启动时读快照,逐文件与记录下来的摘要比对,不同就在结果之前打一行
WARN,点名文件并说明两种可能。写快照的条件一并修好:无条件写、记终点、且只在本次没有变脏时更新基线 —— 记一个变脏的终点会让下一次比对和变异意见一致,正是这个文件要防的事。这是报告而不是失败,取舍和理由写在该行输出里:这套件会跑在有未提交改动的机器上,「和上次结束时不一样」既可能是残留的变异态,也可能是有人在跑完之后正常改了代码,两者字节上无法区分。一律判失败会把正在写代码的 contributor 报成坏掉的检出。而
git status那条路在装包副本里拿不到 —— 套件设计上就要能在没有 git 的地方跑。改完之后同一份复现:快照会被创建,输出顶部出现
WARN shipped files differ from the last run miniapp/client/index.html, …。verify-review16.mjs+7 条断言、mutate-review16.py+5 条变异,全捕。其中两条值得单说,因为它们都是「读起来完全正常、只有比较两个名字才发现」:一条把两条消息改成同名(合成一个名字后,读者无法分辨是哪一次运行弄脏的),一条把快照改成记起点。