Repository navigation
Commit 2a4d21c
authored
The jump arrow follows the scroll, clamped at both ends; and the shipped sources stop carrying private numbers (#17)
* Stop the shipped comments carrying a defect registry the reader does 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.
* Pin the bare numbering, and re-anchor the one mutation that broke on it
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.
* D47: 跳转箭头跟随滚动方向,两端夹取
报告者指出:滚轮往上滚箭头就向上,到最顶端则向下,到最低端反之;滚出轨迹
区域时按位置夹取,不按滚动方向。
这一版替换掉上一版为此加的 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 处理器指向的东西都存在」这条规则按构造就看不见它。
* D47: 重写 verify-ledger-jump.mjs 与 mutate-ledger-jump.py
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 随即报一个
只存在于散文里的悬空处理器。它是裸文本扫描,分不清一次调用和一条提到该调用的注释。
注释改成描述那个注册而不是抄写它,这个坑也写进了源码注释。
* 让套件看出上一次运行留下了什么
外评审指出:`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.
* The top's arrival test cannot be a zero-tolerance comparison
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.
* Pin the arrival boundary, including the assertion that was pinning the 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.1 parent 40f8627 commit 2a4d21c
8 files changed
Lines changed: 802 additions & 213 deletions
File tree
- plugins/avatasia/mmc-trajectory
- miniapp
- client
- node
- tests
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
41 | | - | |
42 | | - | |
| 41 | + | |
| 42 | + | |
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
| 36 | + | |
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
| |||
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
46 | | - | |
| 46 | + | |
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
| |||
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
157 | 157 | | |
158 | 158 | | |
159 | 159 | | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
160 | 175 | | |
161 | 176 | | |
162 | 177 | | |
| |||
217 | 232 | | |
218 | 233 | | |
219 | 234 | | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
220 | 273 | | |
221 | 274 | | |
222 | 275 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
33 | 39 | | |
34 | 40 | | |
35 | 41 | | |
| |||
55 | 61 | | |
56 | 62 | | |
57 | 63 | | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
58 | 101 | | |
59 | 102 | | |
60 | | - | |
61 | | - | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
62 | 110 | | |
63 | 111 | | |
64 | 112 | | |
| |||
131 | 179 | | |
132 | 180 | | |
133 | 181 | | |
134 | | - | |
| 182 | + | |
| 183 | + | |
135 | 184 | | |
136 | 185 | | |
137 | 186 | | |
| |||
0 commit comments