From 58943297bf0d7d21766739a7519505d1f564ae81 Mon Sep 17 00:00:00 2001 From: avatasia Date: Sun, 11 Oct 2026 14:21:17 +0800 Subject: [PATCH 01/88] test(mmc-trajectory): ship the guards that were only on one machine The PR #15 review asked whether the rule-name guard could be reproduced, and noted that the script behind it is not in the repository. It was not: the mutation controls and verifiers lived in a scratch directory on the machine that wrote the client, with absolute paths in them. Fifteen of them now live in tests/, and they run on a clean checkout with nothing but Node and Python: node tests/run.mjs Every path resolves from the directory's own location, and client-check.mjs writes its extracted copy of the inline script to a temporary directory rather than into the checkout. Why half of them are mutate-*.py: a guard that cannot fail is not a guard, because it reads as coverage. Each one breaks exactly one rule in the shipped source, requires the guard that owns that rule to fail *and* to name the right check, then restores the file and requires green again. Two of the six report "all N mutations caught"; the rest print each mutation and the check that caught it. Four were left out rather than carried: mutate-filter-guards.mjs, mutate-qa-lens.py Guard the layer buttons and the turn lens. Commit 91464cd removed the layer presets in favour of the type chips; turns-btn, setLayer, LAYER_KINDS and applyLayerToKinds now occur zero times in index.html. The rule they guard is gone. mutate-steer.py, mutate-window-default.py The rules are still live -- verify-window-widen.mjs still covers the second one -- but the mutation anchors no longer match the source. These need re-anchoring, which is a separate change; shipping them red would only teach reviewers to ignore the suite. tests/README.md records the same reasoning, and states plainly that the one-shot patch scripts that write the package are not here and must not be run: they have no dry-run and no backup, they write straight back to index.html / server.mjs / the READMEs, and re-running one edits text that has since moved on. 15/15 pass. The four payload files are byte-identical to main -- this commit adds only tests/. --- .../avatasia/mmc-trajectory/tests/README.md | 72 ++ .../tests/check-light-theme.mjs | 140 ++++ .../mmc-trajectory/tests/client-check.mjs | 769 ++++++++++++++++++ .../tests/mutate-compaction-turn.py | 141 ++++ .../mmc-trajectory/tests/mutate-index-col.py | 138 ++++ .../tests/mutate-label-source.py | 66 ++ .../tests/mutate-name-collision.py | 58 ++ .../mmc-trajectory/tests/mutate-pr15-fixes.py | 146 ++++ .../tests/mutate-turn-breakdown.py | 151 ++++ plugins/avatasia/mmc-trajectory/tests/run.mjs | 51 ++ .../tests/verify-compaction-turn.mjs | 164 ++++ .../tests/verify-generations.mjs | 405 +++++++++ .../mmc-trajectory/tests/verify-overview.mjs | 592 ++++++++++++++ .../mmc-trajectory/tests/verify-parsers.mjs | 219 +++++ .../tests/verify-pr15-fixes.mjs | 139 ++++ .../tests/verify-prompt-fix.mjs | 89 ++ .../tests/verify-window-widen.mjs | 91 +++ 17 files changed, 3431 insertions(+) create mode 100644 plugins/avatasia/mmc-trajectory/tests/README.md create mode 100644 plugins/avatasia/mmc-trajectory/tests/check-light-theme.mjs create mode 100644 plugins/avatasia/mmc-trajectory/tests/client-check.mjs create mode 100644 plugins/avatasia/mmc-trajectory/tests/mutate-compaction-turn.py create mode 100644 plugins/avatasia/mmc-trajectory/tests/mutate-index-col.py create mode 100644 plugins/avatasia/mmc-trajectory/tests/mutate-label-source.py create mode 100644 plugins/avatasia/mmc-trajectory/tests/mutate-name-collision.py create mode 100644 plugins/avatasia/mmc-trajectory/tests/mutate-pr15-fixes.py create mode 100644 plugins/avatasia/mmc-trajectory/tests/mutate-turn-breakdown.py create mode 100644 plugins/avatasia/mmc-trajectory/tests/run.mjs create mode 100644 plugins/avatasia/mmc-trajectory/tests/verify-compaction-turn.mjs create mode 100644 plugins/avatasia/mmc-trajectory/tests/verify-generations.mjs create mode 100644 plugins/avatasia/mmc-trajectory/tests/verify-overview.mjs create mode 100644 plugins/avatasia/mmc-trajectory/tests/verify-parsers.mjs create mode 100644 plugins/avatasia/mmc-trajectory/tests/verify-pr15-fixes.mjs create mode 100644 plugins/avatasia/mmc-trajectory/tests/verify-prompt-fix.mjs create mode 100644 plugins/avatasia/mmc-trajectory/tests/verify-window-widen.mjs diff --git a/plugins/avatasia/mmc-trajectory/tests/README.md b/plugins/avatasia/mmc-trajectory/tests/README.md new file mode 100644 index 0000000..6a8b8b7 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/tests/README.md @@ -0,0 +1,72 @@ +# Regression guards + +These are the checks that decide whether this plugin is still correct. They are not part of the +runtime payload — `miniapp.json` declares only `artifacts.client` and `artifacts.node`, and this +directory is neither, so nothing here is shipped. + +``` +node tests/run.mjs run everything +node tests/run.mjs verify- only the verifiers +node tests/run.mjs mutate- only the mutation controls +``` + +Requires Node (the client guards run the inline script through `node --check`) and Python 3 (the +mutation controls are Python). No network, no running Mini App, no machine-specific paths: every +path resolves from this directory's own location. + +## Why half of these are `mutate-*` + +A guard that cannot fail is not a guard, because it reads as coverage. Each `mutate-*` file breaks +exactly one rule in the shipped source, runs the guard that owns that rule, requires it to fail +**and** to name the right check, then restores the file and requires green again. If a mutation is +reported as surviving, the guard it exercised was not testing anything. + +That is also why they exist as separate files rather than as extra assertions: the mutation has to +be able to make the real guard go red, which an assertion inside the guard cannot do to itself. + +## Files + +| file | what it pins | +| --- | --- | +| `client-check.mjs` | the inline script parses; the DOM-free helpers; the static policy rules | +| `check-light-theme.mjs` | every `--mcode-*` token the page consumes is defined in both themes | +| `verify-overview.mjs` | the overview strip, toolbar controls and window bar | +| `verify-generations.mjs` | the compaction lineage walk: chain, orphans, generation totals | +| `verify-parsers.mjs` | session labels and multi-session parse counts | +| `verify-compaction-turn.mjs` | a compaction checkpoint is attributed to the turn it interrupted | +| `verify-pr15-fixes.mjs` | the six fixes made for the PR #15 review comments | +| `verify-prompt-fix.mjs` | injected-block splitting via `canonicalTextRange` | +| `verify-window-widen.mjs` | `向上加载更早的 N 条` survives the next poll (self-contained: the shipped rule is transcribed) | +| `mutate-index-col.py` | the record-index column has room for the numbers it shows | +| `mutate-label-source.py` | kind button labels come from `KIND_LABELS`, not from markup | +| `mutate-name-collision.py` | no record kind is labelled with a name that means something else | +| `mutate-turn-breakdown.py` | the turn breakdown parts sum to the headline | +| `mutate-compaction-turn.py` | the compaction-turn attribution is not a free-text label | +| `mutate-pr15-fixes.py` | each of the six PR #15 fixes has a guard that can fail | + +## Deliberately not here + +Four mutation controls from development guard rules that no longer exist, or whose anchor has +moved. Shipping a suite that is red teaches reviewers to ignore it, so they were left out rather +than carried: + +- **`mutate-filter-guards.mjs`** — guards the layer buttons (`turns-btn`, `setLayer`, + `LAYER_KINDS`, `applyLayerToKinds`). Those were removed when the layer presets were dropped in + favour of the type chips; all five symbols now occur zero times in `index.html`. The rule it + guards is gone. +- **`mutate-qa-lens.py`** — guards the 问答 turn-lens. Same removal; no lens symbol remains. +- **`mutate-steer.py`** — the rule is still live but its anchor string (`const steered = + turn.records.some(...)`) no longer matches `server.mjs`. Needs re-anchoring. +- **`mutate-window-default.py`** — same: the rule (a poll resetting a widened window) is live and + `verify-window-widen.mjs` still covers it, but the mutation's anchor into `index.html` no longer + matches. Needs re-anchoring, and `verify-window-widen.mjs` has no mutation control yet. + +## One-shot patches are not here either + +Development also produced scripts that **write the package** to apply one specific edit. They are +deliberately excluded and marked `ONE-SHOT PATCH, ALREADY APPLIED` in the file header: they have +no dry-run and no backup, they write straight back to `index.html` / `server.mjs` / the READMEs, and +re-running one now edits text that has since moved on. + +**Do not run a `ONE-SHOT PATCH` script.** If you need to change the client or the service, edit the +file and let the guards below tell you whether you broke something. \ No newline at end of file diff --git a/plugins/avatasia/mmc-trajectory/tests/check-light-theme.mjs b/plugins/avatasia/mmc-trajectory/tests/check-light-theme.mjs new file mode 100644 index 0000000..7ef8cf9 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/tests/check-light-theme.mjs @@ -0,0 +1,140 @@ +/** + * Light-theme token coverage check. + * + * The README states the light theme is defined but was not visually verified. + * We cannot emulate prefers-color-scheme from here, but we can verify the + * stronger structural property: every --mcode-* token that the stylesheet + * CONSUMES must be defined in BOTH themes, otherwise light mode silently + * falls back to an undefined value. + * + * Actual stylesheet shape in this page: + * :root, :root[data-theme='light'] { ... } <- light + * :root[data-theme='dark'] { ... } <- dark + * @media (prefers-color-scheme: dark) { + * :root:not([data-theme]) { ... } <- pre-JS dark fallback + * } + */ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const html = await readFile(path.join(here, '..', 'miniapp', 'client', 'index.html'), 'utf8'); + +const css = html.match(/]*>([\s\S]*?)<\/style>/i)?.[1]; +if (!css) { + console.error('FAIL: no is the stylesheet. A constant there is the rejected D12 +// version wearing a different selector. Comments are stripped first: the rule is about a +// declaration, and naming the property in a comment that explains why there isn't one is not +// violating it — scanning the raw text would make this guard punish its own explanation. +const styleEnd = html.indexOf(''); +const cssRaw = styleEnd > 0 ? html.slice(0, styleEnd) : ''; +const css = cssRaw.replace(/\/\*[\s\S]*?\*\//g, ''); +assert('the stylesheet declares no contain-intrinsic-size', + !!css && css.indexOf('contain-intrinsic-size') < 0); + +const cssAt = css.indexOf('content-visibility'); +assert('content-visibility is gated on the ledger class, not unconditional', + cssAt >= 0 && /\.ledger-body\.is-skipping-offscreen \.turn-group\s*\{[^}]*content-visibility: auto/.test(css)); + +/* ---------- 2. calibration, and the class that depends on it ---------- */ + +const step = functionSource('buildStep'); +assert('buildStep is in the shipped script', !!step); + +// The row loop ends at its own `built += 1;`, not at the `if (build.at < groups.length)` that +// follows it — the calibration sits between the two, which is exactly the slot that has to be +// after "the first batch exists" and before "the class makes measuring impossible again". +const loopEnd = step && step.indexOf('built += 1;'); +const calibAt = step && step.indexOf('if (!build.heights) {'); +assert('the calibration runs after the first batch is built', + loopEnd > 0 && calibAt > loopEnd); + +// The class is what makes the heights unmeasurable, so writing it anywhere but inside the +// calibration guard means the next pass measures a prediction. +const classOn = step && step.indexOf("ledger.classList.add('is-skipping-offscreen')"); +assert('the class is switched on inside the calibration guard, not before it', + calibAt > 0 && classOn > calibAt && classOn < step.indexOf('if (build.at < groups.length) {', calibAt)); + +// Once is the point: recalibrating per batch would be measuring against a mix of real and skipped. +assert('the calibration happens once per build, not once per batch', + (step.match(/if \(!build\.heights\) \{/g) ?? []).length === 1 + && (step.match(/classList\.add\('is-skipping-offscreen'\)/g) ?? []).length === 1); + +/* ---------- 3. every group ends up sized ---------- */ + +const applyAt = step ? step.indexOf('applyIntrinsicSize(section') : -1; +const appendAt = step ? step.indexOf('ledger.appendChild(section)') : -1; +assert('a group is sized before it is appended, in the row loop', + applyAt > 0 && appendAt > applyAt && applyAt < loopEnd); + +// Anchored on the call's argument, not the bare name: the comment inside the loop explains that +// sizeBuiltGroup() covers the first batch, and a name-only anchor finds that sentence first. +const sizeFirst = step ? step.indexOf('sizeBuiltGroup(firstBatch[f])') : -1; +assert('the first batch, which existed before there was anything to predict from, is sized too', + sizeFirst > calibAt); + +/* ---------- the arithmetic, run ---------- */ + +/** + * Run applyIntrinsicSize against a stub section and read back what it wrote. The three heights + * below are the ones D12 measured on a real session (head 39, row 36, band 38 — the calibrated + * values were 39.40 / 36 / 38.40, rounded here so the expected number is a whole number of CSS + * pixels rather than a float this file would have to carry). + */ +function size(rows, bands, heights) { + const style = {}; + const sandbox = { section: { style }, Math, JSON }; + vm.createContext(sandbox); + const src = functionSource('applyIntrinsicSize'); + if (!src) return { error: 'applyIntrinsicSize is not in the shipped script' }; + try { + vm.runInContext(src, sandbox); + vm.runInContext('applyIntrinsicSize(section, ' + rows + ', ' + bands + ', ' + + (heights ? JSON.stringify(heights) : 'null') + ');', sandbox); + return { written: style.containIntrinsicSize, error: null }; + } catch (err) { + return { error: String(err && err.message) }; + } +} + +const H = { head: 39, row: 36, band: 38 }; + +// 39 + 100×36 + 0×38 = 3,639 +assert('a group of 100 rows is sized at head + 100 rows', + size(100, 0, H).written === 'auto 3639px'); + +// 39 + 98×36 + 2×38 = 39 + 3528 + 76 = 3,643 +assert('compaction bands are counted at their own measured height', + size(98, 2, H).written === 'auto 3643px'); + +// The `auto` keyword is what lets the browser prefer a size it has already rendered, so a reader +// who scrolls down and back does not accumulate drift. Without it this reads as a plain number. +assert('the size is remembered rather than frozen', + /^auto \d+px$/.test(size(100, 0, H).written ?? '')); + +// Before the calibration there is nothing to predict from, and writing a number anyway is how a +// group ends up sized from a constant. +// +// Both halves are asserted, and the first one matters: a version that drops the `if (!heights)` +// guard does not write a wrong number, it *throws* on `null.head`. Reading only the style would +// score that the same as correctly declining, which is the "CRASHED is not a catch" trap in +// METHODOLOGY.md — the guard would pass on a build that dies on the first batch. +const noHeights = size(100, 0, null); +assert('declining to size without heights is not a crash', + !noHeights.error); +assert('nothing is written before the heights are known', + noHeights.written === undefined); + +assert('the derivation is not the rejected single constant', + size(10, 0, H).written !== size(100, 0, H).written); + +if (failures.length) { + console.error(`[intrinsic-size] ${pass} passed, ${failures.length} failed`); + for (const f of failures) console.error(' FAIL ' + f); + process.exit(1); +} +// "0 failed" on success is what mutate-intrinsic-size.py reads to decide the baseline is green. +console.log(`[intrinsic-size] ${pass} passed, 0 failed`); \ No newline at end of file From c4992bca9f61370a2a92f1c55cad6e1cf8a6a05b Mon Sep 17 00:00:00 2001 From: avatasia Date: Mon, 12 Oct 2026 11:39:18 +0800 Subject: [PATCH 20/88] =?UTF-8?q?test(mmc-trajectory):=20=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=20R6=20=E7=9A=84=E4=B8=A4=E5=A4=84=E8=B7=A8=E5=B1=82?= =?UTF-8?q?=E9=85=8D=E5=AF=B9=EF=BC=8CStage=203=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=EF=BC=88=E6=9C=AA=E5=81=9A=E6=B8=85=E5=8D=95=20#5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R6 允许控件所在层与它写入的层不同,条件是这对关系被写下来。此前它只存在于 docs/LAYERS.md 的一句话里,没有任何断言,于是改位置或改归属时没有一处会先红。 verify-cross-layer.mjs(12 条)+ mutate-cross-layer.py(6 条变异全捕)守的是配对, 不是整洁:DOM 父节点说这个控件被呈现成哪一层,赋值说它写哪一层,改任何一半而不 声明,配对就不成立。 声明 1 #window-bar 在 L5 面板内、写 L3(只写模块级 ledgerWindow / ledgerProgress) 声明 2 #overview 持有写 L2 字段 state.overviewSelection 的控件 六条变异分别对应六条具名断言,守卫必须按名字报出:把 window-bar 挪出面板、给它加 一个 state. 赋值、给进度写者加一个、把 state.overviewSelection 塞进窗口条、让刷选 落点不再写该字段、让 L2 入口不再读它。 第 6 条变异重锚过一次:var selection = state.overviewSelection; 在 renderOverviewSelection() 里也有一处,锚在 visibleEntries() 的函数头上,否则命中 的是 L3 的那个读者,而 L2 入口会保持完好、变异存活。变异不删,重锚。 本条刻意不覆盖 R5,而 R5 没有闭合:state.overviewSelection 今天有 5 个写点 (clearOverviewSelection / setActualDuration / onOverviewPointerMove / onOverviewPointerUp / resetBaseFilters),没有一个是 R5 要求的那个单一 setter。 断言「已经收敛」是一条过不了的守卫,断言「当前是 5 个」是一条恒真的守卫,两条都 是错的写法,所以这个缺口留在文档里而不是被断言盖住。收它属于 D4。 同时把两项设计写进 docs/LAYERS.md,都标为「设计已完成,等指示」,未写实现代码: 按 AGENTS.md 的硬约束,四个问题的答案落文档之前不写实现。 Stage 2(异常轴建模,#4)读点已数清:anomalyRules 4 写 13 读、multiAnomaly 3 写 2 读、anomalyLast 7 写 1 读。R2 单一入口成立——flattenTurns() 产出 {turn, record},所以两个入口拿到的本来就是同一个 turn 对象。对外口径不变, 但 state 形状变,verify-anomaly.mjs(53 KB)与 mutate-anomaly.py(29 KB)里 守形状的断言要逐条改写、守行为的原样保留,一条都不删。建议分三次独立提交。 Stage 4(增量协议 ?since=,#2)调研结论改变了收益估计:payload 只有 session / stats / turns 三个键,41 MB 全在 turns,而 stats 是累加器必须全量重算——所以 ?since= 省的是传输与客户端 JSON.parse,不是服务端 CPU。游标选 record.index。 这是本轮唯一一处真口径变更(applyPayload 从整体替换变追加合并),读点已列全, 写侧只有 1 处。 产品代码零改动:index.html 仍为 310,348 bytes,套件 36 → 38 条全绿。 --- .../avatasia/mmc-trajectory/docs/LAYERS.md | 131 ++++++++++++++-- .../tests/mutate-cross-layer.py | 113 +++++++++++++ .../tests/verify-cross-layer.mjs | 148 ++++++++++++++++++ 3 files changed, 381 insertions(+), 11 deletions(-) create mode 100644 plugins/avatasia/mmc-trajectory/tests/mutate-cross-layer.py create mode 100644 plugins/avatasia/mmc-trajectory/tests/verify-cross-layer.mjs diff --git a/plugins/avatasia/mmc-trajectory/docs/LAYERS.md b/plugins/avatasia/mmc-trajectory/docs/LAYERS.md index a738830..1ee39de 100644 --- a/plugins/avatasia/mmc-trajectory/docs/LAYERS.md +++ b/plugins/avatasia/mmc-trajectory/docs/LAYERS.md @@ -411,6 +411,40 @@ D12 把等待的**形状**改了(进度可见、分批交还主线程、在途 **守卫** `tests/verify-intrinsic-size.mjs`(14 条)+ `tests/mutate-intrinsic-size.py`(9 条变异全捕),连同 `verify-anchor-carry.mjs` / `mutate-anchor-carry.py` 一起构成 D14 + D15 的不变量。 +### D16 — 在途打断被当成失败上报 **已归因,未修(与 Stage 4 的 B 绑定,等指示)** + +**现象** 浏览器里 `net::ERR_ABORTED` 与「更新失败,正在重试」横幅同时出现。今天做 D15 实测时反复撞上:页面每 2.5 s 轮询一次,会话一有新增就整包重下 41 MB,下一轮轮询把上一轮取消掉。 + +**代码路径** `index.html:3304-3316`: + +```js +return response.json().catch(function () { return null; }) // 3304-3307 + .then(function (body) { + if (!body || typeof body !== 'object') throw new Error('api'); // 3316 +``` + +fetch 层的打断是被正确区分的——`index.html:3340` 有 `if (error && error.name === 'AbortError') return;`。但**响应头已到、正文在传输途中被取消**时,`response.json()` 的拒绝落进上面那个 `.catch()`,被折成 `null`,再被 3316 行重新抛成一个**不带 `.code` 的普通 `Error('api')`**。`handleTrajectoryError()`(3372)分不清它和真失败:已有 payload 就 `state.stale = true`(3377)→ `staleBar`;没有 payload 就 `state.hardError`(3388)→ `errorBar`。 + +**已观察与未分离的部分**:横幅与 `ERR_ABORTED` 我是在浏览器里**看到的**(今天 D15 实测,10-13 时段反复出现)。但「究竟走的是 3340 的早退还是 3305→3316」我没有分离出来——3340 那条会**不**显示横幅,所以看到横幅本身就是「不是 3340」的证据。仍是推断的部分:41 MB 的传输时长是否真是取消发生的时间点。 + +**代价** D12 设计的在途打断是**特性**,不是故障。它今天每次都产出一条「更新失败」的读者可见信号,而下一轮成功就会把它抹掉——读者看到的是一条假警报,以及一个会被误当成服务不稳定的页面。 + +**四问** + +1. **归层** 不属于 L0–L6 任何一层——它是「一次轮询失败之后该显示什么」的判定,位置在 `applyPayload` 与 `renderBars` 之间。 +2. **关联** 与 D12(分批 + 在途可打断)**从属**。D12 制造打断,本条规定打断不是失败。不改 D12 的任何一个字。 +3. **布局** 不新增 DOM。只改 `#stale-bar` / `#error-bar` 的**触发条件**,横幅本身不动。 +4. **口径** 改变一个读者可见的信号(「更新失败,正在重试」的出现条件),所以先列读点: + +| 字段 | 写点 | 读点 | +| --- | --- | --- | +| `state.stale` | 3:`applyPayload` 3352(清)、`handleTrajectoryError` 3377(置)、3387(清) | **1**:`renderBars` 6614 `dom.staleBar.hidden = !state.stale` | +| `state.hardError` | 2:3351(清)、3388(置) | 3:5193(账本内空状态)、6606–6609(错误横幅) | + +读点少,所以这一问是**可穷举**的:把「正文读取失败」与「传输被取消」分成两条路,第二条不置 `stale`,`staleBar` 的其余触发条件一个不动。 + +**为什么没顺手做**:它与 Stage 4 的 A 是同一处代码的两端(都在 `fetchTrajectory` 的续体里),但风险等级差很远。本条 5 行、独立可回退;A 动协议与 `applyPayload` 的替换语义。捆在一起会让这 5 行也被 A 的回退拖累。Stage 4 推荐先 B 后 A 就是为这个。 + --- ## 3. 分层模型 @@ -480,22 +514,96 @@ D12 把等待的**形状**改了(进度可见、分批交还主线程、在途 三条共用同一个出口,是因为它们回答的是同一个问题:**这次重画,哪些状态要重新判定。** -### Stage 2 — 把异常轴变成 L2 的字段(模型) +### Stage 2 — 把异常轴变成 L2 的字段(模型)**设计已完成,等指示** + +四项任务不变:收字段、合成 R2 单一入口、`resetBaseFilters()` 变成一次赋值、删掉 D2 那句注释。读点已经数清,四问如下。 + +**归层** L2。三个字段今天就已经住在 `state` 里(L2 那一组),本条不改变它们属于哪一层,只改变**它们是不是一个东西**。不改 L1/L3/L4/L5/L6 任何一层,也不动任何 DOM 位置。 + +**关联** 与 L2 其余筛选(`kinds` / `query` / 时间区间 / `overviewSelection`)是**互斥且从属**:异常轴一旦非空,其余四个立刻停止生效(`passesBaseFilter` 整个被跳过)。这个互斥今天靠 `resetBaseFilters()` 一个协调函数维持,而 D5 说得很清楚——协调函数本身就是「没有共享模型」的产物。本条要的是互斥由**模型本身**表达,不由调用顺序表达。 + +**布局** 不动。异常轴那一行(`重置` / 五个规则按钮 / `全部异常` / `多选`)的位置、层级、控件语言一个字不改。本条是纯模型重构。 + +**口径** 对外承诺的数字全部不变:规则按钮上的计数、KPI、总览条跨度、窗口条读数,选中语义与粘性偏移也不变。**变的只有 `state` 的形状**——而 `state` 是内部的。所以这一问的答案是「口径不变」,代价是 53 KB 的 `verify-anomaly.mjs` 与 29 KB 的 `mutate-anomaly.py` 会因为**形状断言**(而不是行为断言)大面积变红,那些必须逐条判断是守行为还是守形状:守行为的原样保留,守形状的改写到新形状上。**一条都不删。** + +三项改动的读点与写点(`file:line` 为 `miniapp/client/index.html`): + +| 字段 | 写点 | 读点 | +| --- | --- | --- | +| `anomalyRules` | 4:`clearFilters()` 5830、`onAnomalyMultiChange()` 6831、`onAnomalyRuleClick()` 6812、`onAnomalyReset()` 6850 | 13 处,含 `anomalyCounts()` 4590、`visibleEntries()` 4650、`hasNonDefaultFilter()` 4904、`spanPassesFilter()` 4926、`applyFilterScope()` 5720、`syncAnomalyMode()` 6887 与异常提示文案 6906 | +| `multiAnomaly` | 3:5832、6828、6855 | 6746(重置按钮按下态)、6893(复选框回写) | +| `anomalyLast` | 7:5831、6791、6799、6802、6807、6835、6851 | 6830–6833(多选关掉时的幸存规则) | + +**R2 单一入口成立,证据在数据形状上**:`flattenTurns()`(2804–2811)产出的元素就是 `{ turn, record }`,所以 `visibleEntries()` 走 `anomalyContext(entry)`(4545,取 `entry.turn`)与 `spanPassesFilter()` 走 `turnById.get(record.turnId)`(4919)拿到的是**同一个 turn 对象**。两个入口现在重复的是同一段分支(先判 `anomalyRules.length > 0`,是则 `passesAnomaly`,否则 `passesBaseFilter`),可以合成 `recordVisible(record, turn)` 一个入口,两边各传自己的 turn 来源。 + +**D2 那句注释有两处,不是一处**:2273(「The type chips are the only filter. Which record kinds are visible is one array — state.kinds…」)与 3075(「The type chips are the only filter, so this array is the only definition of what is on screen…」)。两处都删,理由相同:异常轴存在时它们不成立。 + +**风险**:这是本轮唯一一处会同时改动 13 个读点、4 个写点和两个重守卫覆盖范围的改动。分三次独立提交(收字段 → R2 入口 → reset 赋值 + 删注释),每配自己的变异,任意一步可单独回退。不与 Stage 3 或 Stage 4 合并。 + +### ~~Stage 3 — 声明跨层关系(R6)~~ **已完成** + +为「window-bar 在 L5 面板内、写 L3」和「overview 持有写 L2 的控件」各加一条断言。守的是**配对**,不是整洁:DOM 父节点说这个控件被呈现成哪一层,赋值说它写哪一层,改任何一半而不声明,配对就不成立。 + +`tests/verify-cross-layer.mjs`(12 条)+ `tests/mutate-cross-layer.py`(6 条变异全捕): + +| 断言 | 守的是 | +| --- | --- | +| `#window-bar` 是 `section.panel[aria-labelledby="ledger-heading"]` 的后代 | 声明 1 的**位置**半:它被呈现成 L5 | +| `updateWindowBar()` 不写任何 `state.*` | 声明 1 的**写**半:它只写模块级 `ledgerWindow` | +| `setLedgerProgress()` 同样不写任何 `state.*` | 第二个写者(D8 允许两个写者,但必须同层) | +| 两个写者都不碰 `state.overviewSelection` | 反向:一个呈现在 L5 的控件不许回写 L2 | +| `#overview` 是自己的 section 而不是 panel | 布局本身已经说明它不属于账本 | +| `onOverviewPointerUp` 写 `state.overviewSelection` | 声明 2 的**写**半:总览条真的持有那个控件 | +| `visibleEntries()` 读 `state.overviewSelection` | 声明 2 的**读**半:那个字段确实是 L2 字段,不是装饰 | + +六条变异分别对应六条断言,守卫必须**按名字**报出来:把 window-bar 挪出面板、给它加一个 `state.` 赋值、给进度写者加一个、把 `state.overviewSelection` 塞进窗口条、让刷选落点不再写该字段、让 L2 入口不再读它。第 6 条的锚点重锚过一次——`var selection = state.overviewSelection;` 在 `renderOverviewSelection()` 里也有一处,锚在 `visibleEntries()` 的函数头上,否则命中的是 L3 的那个读者。 + +**这条守卫刻意不覆盖 R5,而 R5 没有闭合。** `state.overviewSelection` 今天有 **5 个写点**:`clearOverviewSelection()`(4126)、`setActualDuration()`(4139)、`onOverviewPointerMove()`(4337,拖拽中)、`onOverviewPointerUp()`(4442,落点)、`resetBaseFilters()`(6871)。R5 要求 L3→L2 的区间回写只经一个 setter,而它一个都没有。断言「已经收敛」是一条过不了的守卫,断言「当前是 5 个」是一条恒真的守卫——两条都是错的写法,所以这条缺口留在这里而不是被断言盖住。收它属于 D4,见 Stage 4。 + +### Stage 4 — 增量协议 `?since=`(L0 / 协议)**设计已完成,等指示** + +未做清单第 2 项。先把调研结论摆出来,因为它们改变了这项的收益估计。 + +**payload 里 41 MB 的构成**(`buildTrajectoryPayload`,`server.mjs:1359-1395`,只有三个顶层键): + +| 键 | 量级 | 能不能只发尾部 | +| --- | --- | --- | +| `session` | 固定,几百字节 | 不需要增量 | +| `stats` | 小,但**必须全量重算** | **不能**——每个计数器都是遍历全部行累加出来的(`server.mjs:921-1340`) | +| `turns` | **41 MB 全部在这里**,逐记录 | 能——`record.index` 稠密、1 起、只增不改 | + +**所以 `?since=` 省的是传输与客户端 `JSON.parse`,不是服务端 CPU。** 服务端仍然要全量扫(读+解析实测 306 ms)、全量构造 payload,缓存里仍然躺着那 41 MB 的 `body`(命中时直接从缓存发出,`server.mjs:1980-1990`)。任何「41 MB → 几 MB,所以轮询快了几倍」的说法都要按这个前提读。 + +**游标选 `record.index`**:1 起稠密、只增不改(追加只延长 `records`),而且它已经是 `/raw` 的公开键(`?index=`,`server.mjs:2166-2169`)。`record.relativeMs` 不行(按全局最小时间戳重算),`record.id` 不行(不透明且会重复),`generation` 不行(是轮转标签不是位置)。payload 里**没有** `seq` 字段;`index.html:3273` 的 `seq` 是客户端本地的 `refreshSeq`,从不上行。 + +**四问** + +1. **归层** L0(数据)+ L1(会话 / 上下文代绑定)。改的是协议与 `state.payload` 这个容器的填充方式,不碰 L2 以上的任何层。 +2. **关联** 与 D10(按代缓存 / ETag / 304)是**分工不是叠加**:无新增时仍走 304(这是最常见的稳态,且最省);一旦有新增,必然 200,此时 D10 的整包缓存不再省到网络,只省到磁盘读。D10 不被取代。 +3. **布局** 不新增任何 DOM、任何控件、任何层。 +4. **口径** **这是本轮唯一一处真口径变更**:客户端对 payload 的处理从**整体替换**变成**追加合并**(`applyPayload`,`index.html:3345-3370`:`state.payload = payload` + `state.flat = flattenTurns(payload.turns)` + `turnById` 全量重建)。按第 4 问,读点先列全: + +| 读的是什么 | 读点 | 增量下要不要改 | +| --- | --- | --- | +| `payload.turns` | **写侧只有 `applyPayload` 一处**(3353 `flattenTurns`、3358 `turnById`);读侧 4 处:`signatureOf` 2822–2833、`scopedStats` 3698、`liveTurn` 4523、`applyPayload` 3358 | 写侧改合并;`signatureOf` 与 `liveTurn` 读的是 `state.payload.turns`,合并后语义不变 | +| `payload.stats` | 6 处:`signatureOf` 2820/2828/2832、`scopedStats` 3628、`kpiStats` 3724、`renderKpi` 3807、`updateWindowBar` 5410、`statsAll` 6601 | **全部不变**——`stats` 每次都全量来 | +| `payload.session` | 8 处:`signatureOf` 2827、`sessionStartedAt` 2960、`renderOverview` 3753、`renderKpi` 3808、`rawCacheKey` 6215、`buildHandoffMarkdown` 6446、`renderGeneration` 6552、`renderFooter` 6618 | 不变 | + +好消息:**写侧只有一处**。坏消息:那一处是所有派生状态的源头,改成合并之后 `signatureOf` 必须仍能识别「有新增」(它比较 `turns.length` 与 records 计数,合并后这两个数照样在涨),`turnById` 要增量建而不是全量重建。 -- `anomalyRules + multiAnomaly + anomalyLast` 收成一个 `anomaly: {mode, rules, last}`。 -- `visibleEntries()` 与 `spanPassesFilter()` 合成 R2 的单一入口。 -- `resetBaseFilters()` 变成对 L2 模型的一次赋值,取代逐控件清理(D5)。 -- 删掉 `state.kinds` 上那句「the only filter」的注释(D2),因为它不再成立。 +**取舍(需要指示,我不替这条决定)** -### Stage 3 — 声明跨层关系(R6) +- **A 做完整 `?since=`**:server 加参数与尾部分支、client `applyPayload` 改合并、协议文档、专属守卫与变异。收益:传输 41 MB → 几百 KB。风险:触及替换语义,且 41 MB 的 `stats` 之外没有可省的 CPU。 +- **B 只修 D16**:约 5 行,让页面不再把在途打断误报成「更新失败」。41 MB 照旧。 +- **C 先 B 后 A**,各自独立提交。 -- 为「window-bar 在 L5 面板内、写 L3」和「overview 持有写 L2 的控件」各加一条断言:改位置或改归属时,断言先红。 +**推荐 C。** 理由:D16 是今天浏览器实测里反复撞到的那一个,代价只有 5 行,收益确定;A 动的是协议与 `applyPayload` 的替换语义,风险等级完全不同,捆在一起会让 B 也被 A 的回退拖累。A 的真实收益还要实测(41 MB → 几百 KB 到底救回多少秒),而 B 的收益不需要量。 --- ## 6. 本轮明确不做 -- **不再做 Stage 1 的结构搬动。** D1 已撤回为「设计如此」,`STAGE1-SPEC.md` 已标不采纳。剩下的 Stage 2(异常轴建模)与 Stage 3(声明跨层关系)等明确指示。 +- **不再做 Stage 1 的结构搬动。** D1 已撤回为「设计如此」,`STAGE1-SPEC.md` 已标不采纳。Stage 2(异常轴建模)与 Stage 4(增量协议)已出设计,等明确指示。 - 没有把 L4 改成跟随全部筛选。那是一次口径变更,按第 4 节的第 4 问处理:先列全部读点,再逐个加断言,最后才改。 - 没有为了「层要连续」去动任何控件的位置。D9 修的是**重算责任**,不是布局。 @@ -510,10 +618,11 @@ D12 把等待的**形状**改了(进度可见、分批交还主线程、在途 | 1 | ~~A 的墙钟时间没缩短。~~ **已做** | 代码 | — | D15 逐组推导 `contain-intrinsic-size` 已实现并实测:同一会话 A/B **3,437 ms → 817 ms(4.2×)**,文档高误差 **+0.07%**,`sized=159/159`,深度重建与顶部同档。D12 当初挡下它的那个疑问(跳过子树里的行有没有布局盒)已有实测答案:`anchorOffset()` 在 cv 之下成立,D14 的锚点没有被破坏。详见 D15。 | | 1b | ~~D14 — 点行时滚动位置永久丢失~~ **已修** | 代码 | — | 「显示全部」下点任意一行,若此刻有轮询建行在途,`buildStep()` 的 `if (build.token !== ledgerBuildToken) return;` 会连 `restoreRowAnchor()` 一起跳过,位置被 `clear(ledger)` 造成的塌缩永久丢弃。已用「滚动意图跨建行转交」修掉,并顺带把 token 自增移到 `clear(ledger)` 之上(否则四条提前返回什么都不退休)。浏览器实测通过;D15 之下复测仍成立。详见 D14。 | | 1c | **一次未解释的位置跳变**(待复现) | 验证 | 无 | 见 D15 实测:某一趟近底部的重建 `postY` 比 `preY` 高了 77,226 px,屏上的行没有变,随后被后续重建带下去;同一深度之后未能复现。**机制未验证,不写成缺陷,也不写成没问题。** | -| 2 | **增量协议 `?since=`**(只发新增尾部) | 代码 + 协议 | 需先在本文定口径 | 性能上严格支配 D10 的按代缓存:1,400 ms → ~10 ms 量级,且不需要 116 MB 常驻。**但它主要省网络与 JSON 解析,对 DOM + layout 成本帮助有限**,不要指望它顺带解决 A。**2026-10-12 实测补一条**:`GET /api/trajectory` 对本会话返回 **40,955,447 字节**(`/api/sessions` 4,477 字节);无变化时走 304,**会话每多一条记录就整包重下**。这正是本轮实测中页面反复掉进「更新失败,正在重试」的直接原因——41 MB 的传输已经不是一个可以先放着不管的量级。 | +| 2 | **增量协议 `?since=`**(只发新增尾部) | 代码 + 协议 | **设计已完成,等指示** | 调研结论改变了收益估计:payload 只有 `session` / `stats` / `turns` 三个键,41 MB 全在 `turns`;而 **`stats` 是累加器,必须全量重算**——所以 `?since=` 省的是**传输与客户端 `JSON.parse`,不是服务端 CPU**,缓存里仍然躺着 41 MB。游标选 `record.index`(稠密、只增不改、已是 `/raw` 的公开键)。这是本轮唯一一处真口径变更(`applyPayload` 从整体替换变追加合并),读点已列全——**写侧只有 1 处**,读侧 `stats` 6 / `turns` 5 / `session` 8。详见 Stage 4。 | | 3 | ~~给「显示全部」封顶~~ **已否决,不做** | 口径 | — | **chli 2026-10-12 定:不封顶。** 理由是「无限大无非是极端情况下假死,碰到的几率很小」。补充一条事实(已告知,不影响决定):18,924 行不是极端情况,就是本会话现在的规模,3,956 ms 就是在它上面测出来的。封顶还会把「读完整个会话」从点一次变成点约 69 次「向上加载更早的 200 条」,而那个控件今天只在 `hiddenAbove > 0` 时出现,等于新增一条路。此路不再讨论。 | -| 4 | **Stage 2 — 异常轴建模**(见 5.3) | 代码 | 等明确指示 | `anomalyRules/multiAnomaly/anomalyLast` 收成 `anomaly: {mode, rules, last}`;`visibleEntries()` 与 `spanPassesFilter()` 合成 R2 的单一入口;`resetBaseFilters()` 变成对 L2 模型的一次赋值(解 D5);删掉 `state.kinds` 上那句已经不成立的「the only filter」注释(解 D2)。 | -| 5 | **Stage 3 — 声明跨层关系(R6)** | 代码 | 无 | 小活:为「window-bar 在 L5 面板内、写 L3」和「overview 持有写 L2 的控件」各加一条断言,改位置或改归属时断言先红。 | +| 4 | **Stage 2 — 异常轴建模**(见第 5 节) | 代码 | **设计已完成,等指示** | 四问已答,读点已数清:`anomalyRules` 4 写 13 读、`multiAnomaly` 3 写 2 读、`anomalyLast` 7 写 1 读。R2 单一入口**成立**——`flattenTurns()` 产出 `{turn, record}`,所以两个入口拿到的本来就是同一个 turn 对象。对外口径全部不变,但 `state` 形状变,`verify-anomaly.mjs`(53 KB)与 `mutate-anomaly.py`(29 KB)里**守形状**的断言要逐条改写、守行为的原样保留,**一条都不删**。建议分三次独立提交。详见 Stage 2。 | +| 2b | **D16 — 在途打断被当成失败上报** | 代码 | 与 #2 的 B 绑定,等指示 | `index.html:3304-3316` 把「正文传输被取消」折成 `Error('api')`,与真失败无法区分,于是每次 D12 设计的在途打断都产出一条「更新失败,正在重试」。约 5 行,读点只有 4 处且可穷举。详见 D16。 | +| 5 | ~~Stage 3 — 声明跨层关系(R6)~~ **已做** | 代码 | — | `verify-cross-layer.mjs`(12 条)+ `mutate-cross-layer.py`(6 条变异全捕),为「window-bar 在 L5 内写 L3」与「overview 持有写 L2 的控件」各加一组断言。调研中撞见 **R5 未闭合**:`state.overviewSelection` 有 5 个写点,没有单一 setter——这条守卫刻意不覆盖它(断言「已收敛」过不了,断言「当前 5 个」恒真),收它属于 D4。详见 Stage 3。 | | 6 | **窄屏 sticky 只有代码证据,没有浏览器实测** | 验证 | 环境 | in-app 浏览器视口固定 735×900 且不可 resize。目前窄屏那条路只有源码和网络请求证据。 | | 7 | **跨家族验证只有 codex 一条腿** | 验证 | 环境 | claude OAuth 过期、codebuddy 要 `/login`,需 chli 交互式处理。 | | 8 | **PR #16 reviews=0** | 流程 | 等 maintainer | `.probe\perf-payload.json`(34 MB 临时产物)已于 2026-10-12 移入回收站,不再列。 | diff --git a/plugins/avatasia/mmc-trajectory/tests/mutate-cross-layer.py b/plugins/avatasia/mmc-trajectory/tests/mutate-cross-layer.py new file mode 100644 index 0000000..d6aef51 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/tests/mutate-cross-layer.py @@ -0,0 +1,113 @@ +"""Mutation control for the R6 cross-layer declarations. + +R6 is the rule that lets a control sit in one layer and write another, provided the pair is +written down. A rule that is only prose in docs/LAYERS.md has no failure mode, so the two +declared pairs are asserted here — and a rule nothing tests is exactly the rule that quietly +stops being true. + +Every mutation below breaks one half of one declaration in the shipped file and requires +verify-cross-layer.mjs to report that assertion *by name*. If a mutation survives, the guard is +what is wrong: re-anchor it, never delete it. +""" +import io +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +CLIENT = os.path.join(ROOT, "miniapp", "client", "index.html") +GUARD = os.path.join(HERE, "verify-cross-layer.mjs") + +GREEN = "ALL PASSED" + +MUTATIONS = [ + # Declaration 1, position half: the window bar leaves the L5 panel entirely. + ("the window bar is presented outside the ledger panel", + '
\n' + '
\n' + ' ', + '
\n' + ' \n' + '
', + "#window-bar is presented inside the ledger panel (L5)"), + + # Declaration 1, write half: the L3 bar starts writing an L3 state field. + ("the window bar starts writing a state field", + ' ledgerWindow = { shown: shown, hiddenAbove: hiddenAbove, total: total };', + ' ledgerWindow = { shown: shown, hiddenAbove: hiddenAbove, total: total };\n' + ' state.limit = 1;', + "updateWindowBar writes no state.* field (it is L3, not L2)"), + + # The second writer into the same bar leaves its layer. + ("the progress writer leaves its layer", + ' ledgerProgress = rows === null ? null : { rows: rows, total: total };', + ' ledgerProgress = rows === null ? null : { rows: rows, total: total };\n' + ' state.kinds = KIND_ORDER.slice();', + "setLedgerProgress writes no state.* field either"), + + # The converse of declaration 1: a control presented in L5 reaching back into L2. + ("the window bar reaches back into the L2 brushed range", + ' applyAxisButton(parts.axis);', + ' state.overviewSelection = null;\n applyAxisButton(parts.axis);', + "neither window-bar writer touches state.overviewSelection"), + + # Declaration 2, write half: the brush stops writing the L2 field entirely. + ("the overview brush stops writing the L2 range", + ' state.overviewSelection = { start: start, end: end };\n' + ' resetWindowMode();\n' + ' renderOverviewSelection();', + ' resetWindowMode();\n' + ' renderOverviewSelection();', + "the overview brush still writes state.overviewSelection on landing"), + + # Declaration 2, read half: the field survives but nothing filters on it, which is the + # state where the overview holds a control that does nothing and still looks correct. + # Anchored to visibleEntries()'s opening lines, not to the assignment alone — + # renderOverviewSelection() reads the same field one line earlier in the file, and + # hitting that one instead would leave the L2 entry point intact. + ("the L2 visibility entry point stops reading the brushed range", + ' var out = [];\n var selection = state.overviewSelection;', + ' var out = [];\n var selection = null;', + "the L2 visibility entry point reads state.overviewSelection"), +] + +original = io.open(CLIENT, encoding="utf-8", newline="").read() + + +def run(): + proc = subprocess.run(["node", GUARD], cwd=HERE, + capture_output=True, text=True, encoding="utf-8", errors="replace") + return proc.stdout + proc.stderr + + +ok = True +baseline = run() +base_ok = GREEN in baseline +print(("ok " if base_ok else "FAIL ") + "baseline green") +ok = ok and base_ok + +caught_all = [] +for title, old, new, expect in MUTATIONS: + hits = original.count(old) + if hits != 1: + raise SystemExit("FAIL anchor for %r matched %d times" % (title, hits)) + try: + io.open(CLIENT, "w", encoding="utf-8", newline="").write(original.replace(old, new, 1)) + during = run() + named = ("FAIL " + expect) in during + failed = GREEN not in during + print(("ok " if (named and failed) else "FAIL ") + "guard catches: " + title) + if not named: + print(" expected the guard to name: " + expect) + caught_all.append(named and failed) + finally: + io.open(CLIENT, "w", encoding="utf-8", newline="").write(original) + +restored = io.open(CLIENT, encoding="utf-8", newline="").read() +after = run() +after_ok = GREEN in after +print(("ok " if restored == original else "FAIL ") + "client restored byte for byte") +print(("ok " if after_ok else "FAIL ") + "green again after restore") + +sys.exit(0 if (ok and all(caught_all) and restored == original and after_ok) else 1) diff --git a/plugins/avatasia/mmc-trajectory/tests/verify-cross-layer.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-cross-layer.mjs new file mode 100644 index 0000000..07f6087 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/tests/verify-cross-layer.mjs @@ -0,0 +1,148 @@ +/** + * R6: a control may sit in one layer and write another, but the pair has to be declared. + * + * Two places on this page do that, and neither used to say so anywhere in the code: + * + * 1. `#window-bar` lives *inside* the ledger panel (L5) but writes the L3 render projection — + * how many rows are on screen. It changes nothing about which records are selected. + * 2. `#overview` is the L3 projection, and it *also* holds a control that writes an L2 field, + * `state.overviewSelection` — the brushed time range the base filter reads. + * + * Those are the whole exceptions. Everything else is where it looks like it is, which is why the + * rule is a declaration and not a prohibition. The cost of not writing them down is concrete: + * a reader — or the next change — reads position as ownership, and a control that "clearly + * belongs to the filter" gets moved, and the layer it writes stops being obvious. + * + * What this guard checks is the *pair*, not the tidiness: the DOM parent says which layer the + * control is presented in, and the assignments say which layer it writes. Change either half + * without declaring it and the pairing stops holding, which is exactly when the document's + * claim becomes false. + * + * What this guard deliberately does NOT check: R5, the claim that L3 writes L2 through exactly + * one setter. That is a real defect and it is not closed — `state.overviewSelection` has five + * writers today (`clearOverviewSelection`, `setActualDuration`, `onOverviewPointerMove`, + * `onOverviewPointerUp`, `resetBaseFilters`). Asserting a convergence that has not happened + * would be a guard that cannot pass; asserting the current count would be a guard that cannot + * fail. See docs/LAYERS.md R5 / D4. + * + * mutate-cross-layer.py breaks each rule below in the shipped file and requires this guard to + * report it by name. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const HTML = fileURLToPath(new URL('../miniapp/client/index.html', import.meta.url)); +const html = readFileSync(HTML, 'utf8'); + +let pass = 0; +const failures = []; +function assert(name, cond) { + if (cond) pass += 1; + else failures.push(name); +} + +/** Brace-matched body of a top-level `function name(` declaration. */ +function functionSource(name) { + const at = html.indexOf('function ' + name + '('); + if (at < 0) return null; + let depth = 0; + for (let j = html.indexOf('{', at); j < html.length; j += 1) { + if (html[j] === '{') depth += 1; + else if (html[j] === '}') { + depth -= 1; + if (depth === 0) return html.slice(at, j + 1); + } + } + return null; +} + +/** The inner HTML of the ledger panel — the L5 container the window bar is presented in. */ +function ledgerPanelHtml() { + const open = html.indexOf('
'); + if (open < 0) return null; + const close = html.indexOf('
', open); + if (close < 0) return null; + return html.slice(open, close); +} + +/** Any `state. =` assignment in a body. Reads like `state.x === y` are not writes. */ +function stateWrites(src) { + if (!src) return []; + const out = []; + const re = /state\.(\w+)\s*=(?!=)/g; + let m; + while ((m = re.exec(src)) !== null) out.push(m[1]); + return out; +} + +// ---------------------------------------------------------------- declaration 1: window-bar + +// It is presented inside the ledger panel. Moving it out is a move to another layer, which is +// allowed — but then the declaration above stops being true and this guard is where that shows. +{ + const panel = ledgerPanelHtml(); + assert('the ledger panel exists to contain anything', panel !== null); + assert('#window-bar is presented inside the ledger panel (L5)', + panel !== null && panel.indexOf('id="window-bar"') >= 0); +} + +// It writes the L3 render projection and nothing else: two module-level fields, no `state.*`. +{ + const src = functionSource('updateWindowBar'); + assert('updateWindowBar exists', src !== null); + const writes = stateWrites(src); + assert('updateWindowBar writes no state.* field (it is L3, not L2)', + writes.length === 0); +} + +// setLedgerProgress is the second writer into the same bar. Two writers is already the shape D8 +// warned about; what D8 needs is for them to be in the same layer, which is what this asserts. +{ + const src = functionSource('setLedgerProgress'); + assert('setLedgerProgress exists', src !== null); + const writes = stateWrites(src); + assert('setLedgerProgress writes no state.* field either', writes.length === 0); +} + +// The converse, and the reason declaration 1 exists at all: a control presented in L5 must not +// quietly reach back into L2. The brushed range is the one L2 field the overview owns. +{ + const bar = functionSource('updateWindowBar') || ''; + const progress = functionSource('setLedgerProgress') || ''; + assert('neither window-bar writer touches state.overviewSelection', + bar.indexOf('state.overviewSelection') < 0 && progress.indexOf('state.overviewSelection') < 0); +} + +// ---------------------------------------------------------------- declaration 2: overview + +// It is its own section, not a panel — the visual language already says it is not part of the +// ledger, so nothing in the layout is carrying the declaration for it. +{ + assert('#overview is its own section rather than a panel', + /
Date: Mon, 12 Oct 2026 12:11:43 +0800 Subject: [PATCH 21/88] =?UTF-8?q?refactor(mmc-trajectory):=20=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E8=BD=B4=E6=94=B6=E6=88=90=E4=B8=80=E4=B8=AA=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=20state.anomaly=20{mode,=20rules,=20last}=EF=BC=88Sta?= =?UTF-8?q?ge=202-a=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 三步里的第一步:先让状态是一个模型,再谈入口(D5 / LAYERS.md)。 原来 axis 上有三个顶层字段 —— anomalyRules、multiAnomaly、anomalyLast。 「有没有规则开着」这一个判断要从三个地方重新推导,于是「关掉其中一个而 没关另外两个」读起来不像 bug、像另一个状态,它们之间的协调只能活在调用 顺序里,因为没有共同的模型可以住。现在是一个字段,rules / mode / last 三 个读点都在同一个对象上。 mode 用 'single' | 'multi' 字符串而不是布尔:每个读都是一次显式比较, 而不是 `multiAnomaly || !multiAnomaly`。`!state.multiAnomaly` 那种写法读 起来像「不是多选」,而它在 单选 与 未知 之间是同义的。 清零走工厂而不是共享常量:defaultAnomaly() 每次给一个新对象。一个被两处 共享的常量就是同一个双写者缺陷换了个位置。 零产品行为变化(这是 2-a 的口径): - 账本读点 2 处、strip 读点 1 处、按钮读点 2 处 —— 全部原样改写 - 合计口径不动:anomalyCounts() 仍走自己的计数通道 - isFiltering() 仍只看 rules.length —— mode 不是 filter,勾上 多选 不该让 「清除筛选」变成可点 新增 tests/verify-anomaly-model.mjs(15 条)守这个模型本身,刻意不守 「已收敛」—— 那个断言现在过不了,写成「当前 N 个」又恒真。守的是可以被 证伪的四条:一个字段、两条重建路径、一个工厂、mode 不是布尔。 mutate-anomaly-model.py:7 条变异,覆盖新建点被绕过、工厂被共享对象顶替、 mode 被写成布尔、mode 泄漏进 isFiltering。 重锚(不是新增断言,是把旧断言的锚点从旧字段名换到新字段名): - verify-anomaly.mjs:fixture 声明合并成 anomaly 对象;19 处 multiAnomaly 的读写换成 mode 的字符串比较;10 处形状断言改锚 - mutate-anomaly.py:20 条变异的锚点重锚。两条变异(strip 分支前置于另一 个轴、单击关掉已开的规则)在重锚过程中 mutant 侧一度被写成和 old 侧等价 —— 那是空变异,跑出来是「NOT CAUGHT」而不是失败。已改回。 39/39 绿(原 37/39)。index.html 310,348 → 311,217 bytes,纯 LF。 --- .../avatasia/mmc-trajectory/docs/LAYERS.md | 42 +---- .../mmc-trajectory/miniapp/client/index.html | 117 +++++++------ .../mmc-trajectory/tests/mutate-anomaly.py | 64 +++---- .../tests/verify-anomaly-model.mjs | 154 +++++++++++++++++ .../mmc-trajectory/tests/verify-anomaly.mjs | 158 +++++++++--------- 5 files changed, 339 insertions(+), 196 deletions(-) create mode 100644 plugins/avatasia/mmc-trajectory/tests/verify-anomaly-model.mjs diff --git a/plugins/avatasia/mmc-trajectory/docs/LAYERS.md b/plugins/avatasia/mmc-trajectory/docs/LAYERS.md index 1ee39de..047c3aa 100644 --- a/plugins/avatasia/mmc-trajectory/docs/LAYERS.md +++ b/plugins/avatasia/mmc-trajectory/docs/LAYERS.md @@ -411,39 +411,6 @@ D12 把等待的**形状**改了(进度可见、分批交还主线程、在途 **守卫** `tests/verify-intrinsic-size.mjs`(14 条)+ `tests/mutate-intrinsic-size.py`(9 条变异全捕),连同 `verify-anchor-carry.mjs` / `mutate-anchor-carry.py` 一起构成 D14 + D15 的不变量。 -### D16 — 在途打断被当成失败上报 **已归因,未修(与 Stage 4 的 B 绑定,等指示)** - -**现象** 浏览器里 `net::ERR_ABORTED` 与「更新失败,正在重试」横幅同时出现。今天做 D15 实测时反复撞上:页面每 2.5 s 轮询一次,会话一有新增就整包重下 41 MB,下一轮轮询把上一轮取消掉。 - -**代码路径** `index.html:3304-3316`: - -```js -return response.json().catch(function () { return null; }) // 3304-3307 - .then(function (body) { - if (!body || typeof body !== 'object') throw new Error('api'); // 3316 -``` - -fetch 层的打断是被正确区分的——`index.html:3340` 有 `if (error && error.name === 'AbortError') return;`。但**响应头已到、正文在传输途中被取消**时,`response.json()` 的拒绝落进上面那个 `.catch()`,被折成 `null`,再被 3316 行重新抛成一个**不带 `.code` 的普通 `Error('api')`**。`handleTrajectoryError()`(3372)分不清它和真失败:已有 payload 就 `state.stale = true`(3377)→ `staleBar`;没有 payload 就 `state.hardError`(3388)→ `errorBar`。 - -**已观察与未分离的部分**:横幅与 `ERR_ABORTED` 我是在浏览器里**看到的**(今天 D15 实测,10-13 时段反复出现)。但「究竟走的是 3340 的早退还是 3305→3316」我没有分离出来——3340 那条会**不**显示横幅,所以看到横幅本身就是「不是 3340」的证据。仍是推断的部分:41 MB 的传输时长是否真是取消发生的时间点。 - -**代价** D12 设计的在途打断是**特性**,不是故障。它今天每次都产出一条「更新失败」的读者可见信号,而下一轮成功就会把它抹掉——读者看到的是一条假警报,以及一个会被误当成服务不稳定的页面。 - -**四问** - -1. **归层** 不属于 L0–L6 任何一层——它是「一次轮询失败之后该显示什么」的判定,位置在 `applyPayload` 与 `renderBars` 之间。 -2. **关联** 与 D12(分批 + 在途可打断)**从属**。D12 制造打断,本条规定打断不是失败。不改 D12 的任何一个字。 -3. **布局** 不新增 DOM。只改 `#stale-bar` / `#error-bar` 的**触发条件**,横幅本身不动。 -4. **口径** 改变一个读者可见的信号(「更新失败,正在重试」的出现条件),所以先列读点: - -| 字段 | 写点 | 读点 | -| --- | --- | --- | -| `state.stale` | 3:`applyPayload` 3352(清)、`handleTrajectoryError` 3377(置)、3387(清) | **1**:`renderBars` 6614 `dom.staleBar.hidden = !state.stale` | -| `state.hardError` | 2:3351(清)、3388(置) | 3:5193(账本内空状态)、6606–6609(错误横幅) | - -读点少,所以这一问是**可穷举**的:把「正文读取失败」与「传输被取消」分成两条路,第二条不置 `stale`,`staleBar` 的其余触发条件一个不动。 - -**为什么没顺手做**:它与 Stage 4 的 A 是同一处代码的两端(都在 `fetchTrajectory` 的续体里),但风险等级差很远。本条 5 行、独立可回退;A 动协议与 `applyPayload` 的替换语义。捆在一起会让这 5 行也被 A 的回退拖累。Stage 4 推荐先 B 后 A 就是为这个。 --- @@ -594,10 +561,12 @@ fetch 层的打断是被正确区分的——`index.html:3340` 有 `if (error && **取舍(需要指示,我不替这条决定)** - **A 做完整 `?since=`**:server 加参数与尾部分支、client `applyPayload` 改合并、协议文档、专属守卫与变异。收益:传输 41 MB → 几百 KB。风险:触及替换语义,且 41 MB 的 `stats` 之外没有可省的 CPU。 -- **B 只修 D16**:约 5 行,让页面不再把在途打断误报成「更新失败」。41 MB 照旧。 -- **C 先 B 后 A**,各自独立提交。 -**推荐 C。** 理由:D16 是今天浏览器实测里反复撞到的那一个,代价只有 5 行,收益确定;A 动的是协议与 `applyPayload` 的替换语义,风险等级完全不同,捆在一起会让 B 也被 A 的回退拖累。A 的真实收益还要实测(41 MB → 几百 KB 到底救回多少秒),而 B 的收益不需要量。 +**没有 B 了。** 原本这一档是「只修 D16(把在途打断误报成失败,约 5 行)」——**D16 是误判,已撤回**:D15 实测时我反复在浏览器里看到「更新失败,正在重试」,于是归因到 `index.html:3304-3316` 把「正文传输被取消」折成 `Error('api')`。用探针在 `handleTrajectoryError` 入口记录 error 形状实测:正常轮询、6 个并发 × 41 MB 压测之下,**它一次都没有被调用**。 + +误判的来源值得记下来,因为它是工具行为而不是代码行为:**`query(kind:"text")` 与辅助树都不过滤 `hidden` 元素。** `#error-bar` 与 `#stale-bar` 的标题(`index.html:2020` 与 `2031`)是**硬编码在 HTML 里的**,而 `renderBars()` 只切 `hidden` 属性。于是「辅助树里出现了这段文案」与「这段文案对读者可见」是两件事——我当时把前者当成了后者,串出一条不存在的缺陷。判据应该是读 `hidden` 属性(`query(kind:"dom")`),或者读一个**只由 JS 写**的节点。 + +现在这一档只剩 A 一个选项。 --- @@ -621,7 +590,6 @@ fetch 层的打断是被正确区分的——`index.html:3340` 有 `if (error && | 2 | **增量协议 `?since=`**(只发新增尾部) | 代码 + 协议 | **设计已完成,等指示** | 调研结论改变了收益估计:payload 只有 `session` / `stats` / `turns` 三个键,41 MB 全在 `turns`;而 **`stats` 是累加器,必须全量重算**——所以 `?since=` 省的是**传输与客户端 `JSON.parse`,不是服务端 CPU**,缓存里仍然躺着 41 MB。游标选 `record.index`(稠密、只增不改、已是 `/raw` 的公开键)。这是本轮唯一一处真口径变更(`applyPayload` 从整体替换变追加合并),读点已列全——**写侧只有 1 处**,读侧 `stats` 6 / `turns` 5 / `session` 8。详见 Stage 4。 | | 3 | ~~给「显示全部」封顶~~ **已否决,不做** | 口径 | — | **chli 2026-10-12 定:不封顶。** 理由是「无限大无非是极端情况下假死,碰到的几率很小」。补充一条事实(已告知,不影响决定):18,924 行不是极端情况,就是本会话现在的规模,3,956 ms 就是在它上面测出来的。封顶还会把「读完整个会话」从点一次变成点约 69 次「向上加载更早的 200 条」,而那个控件今天只在 `hiddenAbove > 0` 时出现,等于新增一条路。此路不再讨论。 | | 4 | **Stage 2 — 异常轴建模**(见第 5 节) | 代码 | **设计已完成,等指示** | 四问已答,读点已数清:`anomalyRules` 4 写 13 读、`multiAnomaly` 3 写 2 读、`anomalyLast` 7 写 1 读。R2 单一入口**成立**——`flattenTurns()` 产出 `{turn, record}`,所以两个入口拿到的本来就是同一个 turn 对象。对外口径全部不变,但 `state` 形状变,`verify-anomaly.mjs`(53 KB)与 `mutate-anomaly.py`(29 KB)里**守形状**的断言要逐条改写、守行为的原样保留,**一条都不删**。建议分三次独立提交。详见 Stage 2。 | -| 2b | **D16 — 在途打断被当成失败上报** | 代码 | 与 #2 的 B 绑定,等指示 | `index.html:3304-3316` 把「正文传输被取消」折成 `Error('api')`,与真失败无法区分,于是每次 D12 设计的在途打断都产出一条「更新失败,正在重试」。约 5 行,读点只有 4 处且可穷举。详见 D16。 | | 5 | ~~Stage 3 — 声明跨层关系(R6)~~ **已做** | 代码 | — | `verify-cross-layer.mjs`(12 条)+ `mutate-cross-layer.py`(6 条变异全捕),为「window-bar 在 L5 内写 L3」与「overview 持有写 L2 的控件」各加一组断言。调研中撞见 **R5 未闭合**:`state.overviewSelection` 有 5 个写点,没有单一 setter——这条守卫刻意不覆盖它(断言「已收敛」过不了,断言「当前 5 个」恒真),收它属于 D4。详见 Stage 3。 | | 6 | **窄屏 sticky 只有代码证据,没有浏览器实测** | 验证 | 环境 | in-app 浏览器视口固定 735×900 且不可 resize。目前窄屏那条路只有源码和网络请求证据。 | | 7 | **跨家族验证只有 codex 一条腿** | 验证 | 环境 | claude OAuth 过期、codebuddy 要 `/login`,需 chli 交互式处理。 | diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index 7e79bd2..f18b0d1 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -3082,18 +3082,24 @@

检视

kinds: KIND_ORDER.slice(), // Which 异常 rules are ticked. Empty means the filter is off — not 「nothing matches」, // which is the distinction that keeps a turned-off filter from blanking the ledger. - // Kept as a list of rule ids rather than a boolean so a later rule is one more entry - // here and one more button, not a second piece of state that has to agree with this. - anomalyRules: [], - // Whether a second rule adds to the selection or replaces it. Off by default: a plain - // click answers 「点什么就显示什么」, and adding to what is already ticked breaks that. - // Turned on, the buttons union. It is a mode and not a rule, so it lives beside - // `anomalyRules` rather than in it. - multiAnomaly: false, - // The rule the reader ticked last. Only used when 多选 is switched off while several - // rules are standing: one of them has to survive, and the one they were last looking at - // is the one they meant. - anomalyLast: null, + // The anomaly axis is one thing, so it is one field. Three top-level fields meant that + // 「no rule is on」 had to be re-derived from three places, and that turning one of them + // off without the other two read as a different state rather than as a bug: the + // coordination could only live in call order, because there was no shared model for it to + // live in. See docs/LAYERS.md D5. + anomaly: { + // 'single' or 'multi'. Single by default: a plain click answers 「点什么就显示什么」, + // and adding to what is already ticked breaks that. In 'multi' the buttons union. It + // is a mode and not a rule, so it lives beside `rules` rather than inside it. + mode: 'single', + // A list of rule ids rather than a boolean, so a later rule is one more entry here and + // one more button, not a second piece of state that has to agree with this. + rules: [], + // The rule the reader ticked last. Only used when 多选 is switched off while several + // rules are standing: one of them has to survive, and the one they were last looking + // at is the one they meant. + last: null + }, query: '', // Time range, in milliseconds since the session started — the same zero the ledger's // `+893 分 21 秒` column counts from. `null` on either side means that end is open, @@ -4587,7 +4593,7 @@

检视

} var any = 0; var selected = 0; - var sel = state.anomalyRules; + var sel = state.anomaly.rules; var live = liveTurn(); var counted = {}; for (var j = 0; j < state.flat.length; j += 1) { @@ -4647,11 +4653,11 @@

检视

function visibleEntries() { var out = []; var selection = state.overviewSelection; - var anomalyMode = state.anomalyRules.length > 0; + var anomalyMode = state.anomaly.rules.length > 0; for (var i = 0; i < state.flat.length; i += 1) { var entry = state.flat[i]; if (anomalyMode) { - if (!passesAnomaly(entry.record, anomalyContext(entry), state.anomalyRules)) continue; + if (!passesAnomaly(entry.record, anomalyContext(entry), state.anomaly.rules)) continue; } else if (!passesBaseFilter(entry.record, selection)) { continue; } @@ -4901,7 +4907,7 @@

检视

return state.query !== '' || state.kinds.length !== KIND_ORDER.length || state.overviewSelection !== null || hasTimeRange() - || state.anomalyRules.length !== 0 + || state.anomaly.rules.length !== 0 ; } @@ -4923,8 +4929,8 @@

检视

// this function exists to prevent. Running the kind, query and range tests first and // the anomaly test last — the order this used to be in — drew spans for records the // list had stopped filtering on the moment a rule was ticked. - if (state.anomalyRules.length > 0) { - return passesAnomaly(record, { turn: owner || null, liveTurn: liveTurn() }, state.anomalyRules); + if (state.anomaly.rules.length > 0) { + return passesAnomaly(record, { turn: owner || null, liveTurn: liveTurn() }, state.anomaly.rules); } if (!hasKind(record, state.kinds)) return false; if (hasQuery && !matchesQuery(record, state.query)) return false; @@ -5717,7 +5723,7 @@

检视

*/ function applyFilterScope() { if (!dom.kpiScope) return; - if (state.anomalyRules.length > 0) { + if (state.anomaly.rules.length > 0) { // This line exists to name the filters the KPI does not follow. In 异常 mode there are // none of them in force, so the usual wording would be pointing at two controls that // are switched off and filtering nothing. @@ -5824,12 +5830,21 @@

检视

inspectorSay(''); } + /** + * The anomaly axis at rest. + * + * A factory rather than a constant, because two callers assign this to `state.anomaly`. + * A shared object would let ticking a rule write through to whichever of them was still + * holding it — which is the two-writers defect this field was collapsed to avoid. + */ + function defaultAnomaly() { + return { mode: 'single', rules: [], last: null }; + } + function clearFilters() { state.kinds = KIND_ORDER.slice(); state.query = ''; - state.anomalyRules = []; - state.anomalyLast = null; - state.multiAnomaly = false; + state.anomaly = defaultAnomaly(); clearTimeRange(); resetWindowMode(); if (dom.searchInput) dom.searchInput.value = ''; @@ -6743,19 +6758,19 @@

检视

// with 多选 on is not the state 重置 describes, so it must not claim to be it — and // 重置 is the only control that turns 多选 off, so a reader told the row is already at // its default would never press the one button that would put it back. - on = state.anomalyRules.length === 0 && !state.multiAnomaly; + on = state.anomaly.rules.length === 0 && state.anomaly.mode !== 'multi'; noun = ANOMALY_NONE_LABEL; label = noun + ' · ' + (on ? '已是默认状态 · 表格按上一行的条件筛选' : '点击回到默认状态 · 表格按上一行的条件筛选'); n = null; } else if (isAll) { - on = state.anomalyRules.length === ANOMALY_RULES.length; + on = state.anomaly.rules.length === ANOMALY_RULES.length; n = counts.any; noun = '全部异常'; unit = '条'; label = ruleLabelFor(noun, n, unit, on); } else { - on = state.anomalyRules.indexOf(id) >= 0; + on = state.anomaly.rules.indexOf(id) >= 0; n = counts.byRule[id] || 0; noun = anomalyRuleLabel(id); unit = counts.unit[id] || '条'; @@ -6787,29 +6802,29 @@

检视

var next; if (ruleId === null) { // 全部异常: every rule, or none of them. - next = state.anomalyRules.length === ANOMALY_RULES.length ? [] : ANOMALY_RULE_IDS.slice(); - state.anomalyLast = next.length ? null : state.anomalyLast; - } else if (state.multiAnomaly) { + next = state.anomaly.rules.length === ANOMALY_RULES.length ? [] : ANOMALY_RULE_IDS.slice(); + state.anomaly.last = next.length ? null : state.anomaly.last; + } else if (state.anomaly.mode === 'multi') { // Union mode. A click adds the rule, or takes it back off — unless it was the last // one standing, because a filter that refuses to turn off is a filter nobody can leave. - next = state.anomalyRules.filter(function (id) { + next = state.anomaly.rules.filter(function (id) { return id !== ruleId; }); - if (next.length === state.anomalyRules.length) next = next.concat([ruleId]); - state.anomalyLast = ruleId; - } else if (state.anomalyRules.length === 1 && state.anomalyRules[0] === ruleId) { + if (next.length === state.anomaly.rules.length) next = next.concat([ruleId]); + state.anomaly.last = ruleId; + } else if (state.anomaly.rules.length === 1 && state.anomaly.rules[0] === ruleId) { next = []; - state.anomalyLast = null; + state.anomaly.last = null; } else { // Single mode is a replacement, not an addition: you asked for this rule, and this is // what you get. Anything already ticked was a previous question, not a standing one. next = [ruleId]; - state.anomalyLast = ruleId; + state.anomaly.last = ruleId; } next.sort(function (a, b) { return ANOMALY_RULE_IDS.indexOf(a) - ANOMALY_RULE_IDS.indexOf(b); }); - state.anomalyRules = next; + state.anomaly.rules = next; if (next.length > 0) resetBaseFilters(); resetWindowMode(); render(); @@ -6825,14 +6840,14 @@

检视

* the first in table order, which would be an answer to a question they did not ask. */ function onAnomalyMultiChange(checked) { - state.multiAnomaly = checked; - if (!checked && state.anomalyRules.length > 1) { - var last = state.anomalyLast; - state.anomalyRules = [last && state.anomalyRules.indexOf(last) >= 0 + state.anomaly.mode = checked ? 'multi' : 'single'; + if (!checked && state.anomaly.rules.length > 1) { + var last = state.anomaly.last; + state.anomaly.rules = [last && state.anomaly.rules.indexOf(last) >= 0 ? last - : state.anomalyRules[0]]; + : state.anomaly.rules[0]]; } - if (state.anomalyRules.length === 0) state.anomalyLast = null; + if (state.anomaly.rules.length === 0) state.anomaly.last = null; render(); } @@ -6846,13 +6861,11 @@

检视

* and the table follows from there. */ function onAnomalyReset() { - if (state.anomalyRules.length === 0 && !state.multiAnomaly) return; - state.anomalyRules = []; - state.anomalyLast = null; - // 多选 is back to its default too. 重置 means the default state, and 单选 is what this - // axis opens in — leaving a mode switch on is not a filter the reader would think to - // clear by pressing the button they just pressed. - state.multiAnomaly = false; + if (state.anomaly.rules.length === 0 && state.anomaly.mode !== 'multi') return; + // One assignment, not three. 多选 goes back to its default with it — 重置 means the + // default state, and 单选 is what this axis opens in; leaving a mode switch on is not a + // filter the reader would think to clear by pressing the button they just pressed. + state.anomaly = defaultAnomaly(); if (dom.anomalyMulti) dom.anomalyMulti.checked = false; resetWindowMode(); render(); @@ -6884,13 +6897,13 @@

检视

* records of it, so they have to keep working while an anomaly rule is ticked. */ function syncAnomalyMode(counts) { - var on = state.anomalyRules.length > 0; + var on = state.anomaly.rules.length > 0; document.body.setAttribute('data-anomaly-mode', on ? 'on' : 'off'); var scopes = document.querySelectorAll('[data-filter-scope]'); for (var i = 0; i < scopes.length; i += 1) scopes[i].inert = on; // The checkbox is state too, and a poll re-renders from state. Writing it here means // turning 多选 off by hand cannot leave it drawn on while the buttons are single again. - if (dom.anomalyMulti) dom.anomalyMulti.checked = state.multiAnomaly; + if (dom.anomalyMulti) dom.anomalyMulti.checked = state.anomaly.mode === 'multi'; if (!dom.anomalyNote) return; dom.anomalyNote.hidden = !on; if (on) { @@ -6903,9 +6916,9 @@

检视

// 叠加 is 多选's word. In 单选 the only way more than one rule is ever on is 全部异常, // which is not the reader stacking anything, so it is described as all of them being in // force rather than as a stack they did not build. - var n = state.anomalyRules.length; + var n = state.anomaly.rules.length; var stacked = n > 1 - ? (state.multiAnomaly + ? (state.anomaly.mode === 'multi' ? ' · 已叠加 ' + formatInt(n) + ' 条规则 · 合计 ' + formatInt(counts.selected) + ' 条' : ' · 全部 ' + formatInt(n) + ' 条规则同时生效 · 合计 ' + formatInt(counts.selected) + ' 条') : ''; diff --git a/plugins/avatasia/mmc-trajectory/tests/mutate-anomaly.py b/plugins/avatasia/mmc-trajectory/tests/mutate-anomaly.py index 1fab8b7..e4a189c 100644 --- a/plugins/avatasia/mmc-trajectory/tests/mutate-anomaly.py +++ b/plugins/avatasia/mmc-trajectory/tests/mutate-anomaly.py @@ -95,17 +95,17 @@ " var owner = record ? turnById.get(record.turnId) : null;", " var owner = null;"), ("the strip stopped applying 异常 at all", "the strip applies it", - " return passesAnomaly(record, { turn: owner || null, liveTurn: liveTurn() }, state.anomalyRules);", + " return passesAnomaly(record, { turn: owner || null, liveTurn: liveTurn() }, state.anomaly.rules);", " return true;"), ("the ledger stopped applying 异常", "the ledger applies it", - " if (!passesAnomaly(entry.record, anomalyContext(entry), state.anomalyRules)) continue;\n", ""), + " if (!passesAnomaly(entry.record, anomalyContext(entry), state.anomaly.rules)) continue;\n", ""), # --- the state has to remember the ticks, or a poll re-renders the buttons from nothing --- ("the filter is not counted as a filter", "the filter counts as a filter", - " || state.anomalyRules.length !== 0", " || false"), + " || state.anomaly.rules.length !== 0", " || false"), ("清除筛选 leaves the filter on", "清除筛选 drops it too", - " state.anomalyRules = [];\n state.anomalyLast = null;\n state.multiAnomaly = false;\n clearTimeRange();", - " state.anomalyLast = null;\n state.multiAnomaly = false;\n clearTimeRange();"), + " state.anomaly = defaultAnomaly();\n clearTimeRange();", + " clearTimeRange();"), ("清除筛选 leaves the buttons showing the old state", "and re-syncs the buttons with it", " syncKindButtons();\n syncAnomalyButtons(anomalyCounts());\n render();", " syncKindButtons();\n render();"), @@ -120,12 +120,12 @@ # The guard it names moved into the 多选 block when 单选 became the default, which is where the # untick actually happens now: single mode replaces, so removal only exists with the checkbox on. ("unticking re-adds the rule it just removed", "多选: unticking removes only that one", - " if (next.length === state.anomalyRules.length) next = next.concat([ruleId]);", - " if (next.length <= state.anomalyRules.length) next = next.concat([ruleId]);"), + " if (next.length === state.anomaly.rules.length) next = next.concat([ruleId]);", + " if (next.length <= state.anomaly.rules.length) next = next.concat([ruleId]);"), ("全部异常 ticks the rule table itself instead of a copy", "全部异常 ticks a copy, not the table itself", - " next = state.anomalyRules.length === ANOMALY_RULES.length ? [] : ANOMALY_RULE_IDS.slice();", - " next = state.anomalyRules.length === ANOMALY_RULES.length ? [] : ANOMALY_RULE_IDS;"), + " next = state.anomaly.rules.length === ANOMALY_RULES.length ? [] : ANOMALY_RULE_IDS.slice();", + " next = state.anomaly.rules.length === ANOMALY_RULES.length ? [] : ANOMALY_RULE_IDS;"), # --- what the reader is shown --- ("the turn head stops naming the rule", "with the rule name, not a bare colour", @@ -147,8 +147,8 @@ # --- into a stack, which is the shape that produced "283 on the button, 1,494 in the list". ("the mode is read backwards, so the search stops filtering at all", "but with no rule ticked the search is still a filter for the list", - " var anomalyMode = state.anomalyRules.length > 0;", - " var anomalyMode = state.anomalyRules.length >= 0;"), + " var anomalyMode = state.anomaly.rules.length > 0;", + " var anomalyMode = state.anomaly.rules.length >= 0;"), ("the two axes stacked instead of alternated", "and treats the two axes as alternatives, not a stack", " } else if (!passesBaseFilter(entry.record, selection)) {", @@ -159,7 +159,7 @@ " var hits = state.query ? [] : recordAnomalyRules(entry.record, { turn: entry.turn, liveTurn: live });"), ("the strip branches after the other axis instead of before it", "and branches on the mode before the other axis, so strip and list agree", - " if (state.anomalyRules.length > 0) {\n return passesAnomaly(", + " if (state.anomaly.rules.length > 0) {\n return passesAnomaly(", " if (false) {\n return passesAnomaly("), # --- a turn rule is counted in turns. Counting its records is the original lie. @@ -177,8 +177,8 @@ " none.id = 'anomaly-none';", " none.id = 'anomaly-all-data';"), ("重置 ticks the whole set instead of clearing it", "重置 returns the row to its default", - " if (state.anomalyRules.length === 0 && !state.multiAnomaly) return;\n state.anomalyRules = [];", - " if (state.anomalyRules.length === 0 && !state.multiAnomaly) return;\n state.anomalyRules = ANOMALY_RULE_IDS.slice();"), + " state.anomaly = defaultAnomaly();\n if (dom.anomalyMulti) dom.anomalyMulti.checked = false;\n resetWindowMode();", + " state.anomaly = { mode: 'single', rules: ANOMALY_RULE_IDS.slice(), last: null };\n if (dom.anomalyMulti) dom.anomalyMulti.checked = false;\n resetWindowMode();"), # 重置 has no count of its own: 「怎么多会留下多少」 is a question about a rule, and a control # that keeps whatever the row above says has no answer to it. Printing one anyway invites # adding it up against the numbers beside it. @@ -218,39 +218,39 @@ # back there — silently, and with a selection the controls can no longer express. ("单选 stacks instead of replacing", "单选: ticking a second replaces the first instead of stacking", - " next = [ruleId];\n state.anomalyLast = ruleId;", - " next = state.anomalyRules.concat([ruleId]);\n state.anomalyLast = ruleId;"), + " next = [ruleId];\n state.anomaly.last = ruleId;", + " next = state.anomaly.rules.concat([ruleId]);\n state.anomaly.last = ruleId;"), ("单选 cannot be turned off by pressing the rule that is on", "单选: pressing the rule already on turns it off again", - " } else if (state.anomalyRules.length === 1 && state.anomalyRules[0] === ruleId) {\n next = [];", + " } else if (state.anomaly.rules.length === 1 && state.anomaly.rules[0] === ruleId) {\n next = [];", " } else if (false) {\n next = [];"), ("多选 stacks without removing anything", "多选: unticking removes only that one", - " next = state.anomalyRules.filter(function (id) {\n return id !== ruleId;\n });\n if (next.length === state.anomalyRules.length) next = next.concat([ruleId]);", - " next = state.anomalyRules.concat([ruleId]);"), + " next = state.anomaly.rules.filter(function (id) {\n return id !== ruleId;\n });\n if (next.length === state.anomaly.rules.length) next = next.concat([ruleId]);", + " next = state.anomaly.rules.concat([ruleId]);"), ("the union branch is never taken, so 多选 is only a label", "多选: ticking a second stacks instead of replacing", - " } else if (state.multiAnomaly) {", + " } else if (state.anomaly.mode === 'multi') {", " } else if (false) {"), # Which rule survives is a decision, so it is asserted. Table order is the wrong answer to # "which one was I looking at": it silently moves the reader to a filter they did not pick. ("turning 多选 off keeps the first rule in table order instead of the last one ticked", "turning 多选 off keeps the rule ticked last, not the first in table order", - " state.anomalyRules = [last && state.anomalyRules.indexOf(last) >= 0\n ? last\n : state.anomalyRules[0]];", - " state.anomalyRules = [state.anomalyRules[0]];"), + " state.anomaly.rules = [last && state.anomaly.rules.indexOf(last) >= 0\n ? last\n : state.anomaly.rules[0]];", + " state.anomaly.rules = [state.anomaly.rules[0]];"), # The collapse is gated on turning 多选 *off*. Ungated, 全部异常 — which is legal in 单选 — # would lose three of its four rules the moment the reader asked for the union. ("turning 多选 on also collapses the selection", "turning 多选 on changes the mode and nothing else", - " if (!checked && state.anomalyRules.length > 1) {", - " if (state.anomalyRules.length > 1) {"), + " if (!checked && state.anomaly.rules.length > 1) {", + " if (state.anomaly.rules.length > 1) {"), ("重置 leaves 多选 on", "and 多选 goes off with it", - " state.multiAnomaly = false;\n if (dom.anomalyMulti) dom.anomalyMulti.checked = false;", - " if (dom.anomalyMulti) dom.anomalyMulti.checked = false;"), + " state.anomaly = defaultAnomaly();\n if (dom.anomalyMulti) dom.anomalyMulti.checked = false;\n resetWindowMode();", + " state.anomaly = defaultAnomaly();\n state.anomaly.mode = 'multi';\n if (dom.anomalyMulti) dom.anomalyMulti.checked = false;\n resetWindowMode();"), ("重置 clears the state but leaves the box drawn as ticked", "and the checkbox stops reading as on", - " state.multiAnomaly = false;\n if (dom.anomalyMulti) dom.anomalyMulti.checked = false;\n resetWindowMode();", - " state.multiAnomaly = false;\n resetWindowMode();"), + " state.anomaly = defaultAnomaly();\n if (dom.anomalyMulti) dom.anomalyMulti.checked = false;\n resetWindowMode();", + " state.anomaly = defaultAnomaly();\n resetWindowMode();"), # The checkbox sits outside `#anomaly-controls` precisely so it cannot be swept up by the # inert-marking that switches the other axis off. ("the 多选 checkbox marked as a filter control, so it goes inert with the rest", @@ -259,11 +259,11 @@ '
@@ -3493,7 +3569,11 @@

检视

var ICON_PATHS = { chevronDown: ['M4 6.2 8 10.2 12 6.2'], copy: ['M5.6 5.6h7.8v7.8H5.6z', 'M2.6 2.6h7.8v7.8'], - arrowRight: ['M2.6 8h9.2', 'M8.6 4.6 12 8l-3.4 3.4', 'M13.4 2.6v10.8'] + arrowRight: ['M2.6 8h9.2', 'M8.6 4.6 12 8l-3.4 3.4', 'M13.4 2.6v10.8'], + // D44: the ledger's two-ends jump. A shaft and a head, mirrored about the 8px centre, so the + // two directions cannot drift apart the way two separately-drawn glyphs would. + arrowDown: ['M8 2.8v10.4', 'M4.6 9.6 8 13l3.4-3.4'], + arrowUp: ['M8 13.2V2.8', 'M4.6 6.4 8 3l3.4 3.4'] }; function icon(name) { @@ -3740,6 +3820,8 @@

检视

dom.overviewTip = document.getElementById('overview-tip'); dom.overviewClear = document.getElementById('overview-clear'); dom.ledger = document.getElementById('ledger'); + dom.ledgerEnd = document.getElementById('ledger-end'); + dom.ledgerJump = document.getElementById('ledger-jump'); dom.ledgerMeta = document.getElementById('ledger-meta'); dom.inspectorMeta = document.getElementById('inspector-meta'); dom.inspectorLocator = document.getElementById('inspector-locator'); @@ -5902,19 +5984,42 @@

检视

// Both answers belong to this exit rather than to each of its callers: whether the record // the inspector is showing is still in the table, and how tall the pinned strips above it // are now. Nine writers reach the table without going through render(), and judging the - // selection here is what stops the inspector describing a row that is no longer on screen. + // selection here is what stops the inspector describing a row the filters have removed. + // + // `matched`, not `windowed` — and this line used to say the opposite, so the reason it + // changed is worth more than the reason it was written. // - // `windowed`, not `matched`: what the reader can see is the window, not the filter result. - // Judged before the slice this used to raise `state.limit` to reach a selection below the - // window, which made the window a thing the inspector could widen — and left the promise - // 「已显示 200 条」 true at the moment the reader pressed 显示最新 200 条 and false a - // second later. See docs/LAYERS.md D13. + // It said: what the reader can see is the window, not the filter result. That was written + // to fix a real thing (D13): this call used to RAISE `state.limit` to reach a selection + // below the window, which made the window a thing the inspector could widen, and left the + // promise 「已显示 200 条」 true at the moment the reader pressed 显示最新 200 条 and false a + // second later. **That part is still true and the prohibition still stands** — the window + // never widens to reach a selection, and it still does not below. + // + // What was wrong was the next inference: that a record leaving the window means the reader + // can no longer see it. The window is a *projection*, and it moves on its own — every new + // record slides the whole tail forward by one row. So 「not in the window」 was measuring + // the projection's motion, not the reader's attention. Measured on a real session (32,386 + // records, 216 turns, the reader touching nothing): a record selected at position p of the + // 200-row tail survived exactly `200 - p` new records, and p=0 died after ONE of them, + // inside a single poll. On a session that is still being written, that is every selection + // that is not already the newest — which is why it read as 「过一会检视会消失」. + // + // The cost of the old judgement is visible in one measurement: on a narrow screen the scroll + // that pins the inspector to the viewport bottom is also the scroll that slides the selected + // row out of the window, so the reader could not look at a record and scroll to the panel at + // the same time. The inspector did not need the record to be on screen to describe it; it + // needed the record to still be something the reader chose. + // + // What `matched` does still do, and is the half of the promise that survives: a record the + // filters removed loses its selection, because then the inspector really would be describing + // something the reader cannot get back by clearing a filter. // // The redraw is part of that, not a follow-up: clearing the id on its own is not enough. // The search box, the type axis and the overview brush call renderLedger() and nothing // else, so the panel kept showing the record that had just been dropped — no row carried // the selection mark and the inspector still named one. - if (revealSelection(windowed)) renderInspector(); + if (revealSelection(matched)) renderInspector(); if (matched.length === 0) { var reset = el('button', 'btn', '清除筛选'); @@ -5938,6 +6043,18 @@

检视

updateWindowBar(windowed, hiddenAbove, total); + // D44: the two-ends jump is re-decided here rather than only from its observer. Measured on + // a real session: wired to the observer alone, the button stayed `hidden` for 24 of 30 scroll + // positions, because the observer only speaks when the intersection CHANGES and going from + // an empty ledger to 32,000 rows never changed it — the sentinel was simply somewhere else + // the whole time. The label was wrong at the top for the same reason, and stayed wrong until + // the next intersection change. + // + // So the render is the trigger, and the observer is the cheap hint for the scrolls in + // between. Both go through this one function, and it re-reads the sentinel rather than + // trusting the callback's own flag: a missed callback then costs one render, not one session. + setLedgerJumpLabel(); + var groups = visibleTurns(windowed); // The title states the total, and — when the window does not start at the first turn — @@ -6200,6 +6317,153 @@

检视

return { actions: actions, axis: axis, more: more, mode: mode, count: count }; } + /* ---------------- D44: the ledger's two ends ---------------- */ + + /** + * Whether the ledger's end is on screen, which is the whole of 「到最低 / 到最上」. + * + * A reading rather than a computation, and the reason is that 「the end」 moves every poll: + * the window is the newest N records and each new one slides it forward, so the bottom of the + * ledger is a different document position every 2.5 seconds. Anything derived from + * scrollHeight against a remembered value would be correct only between polls. + * + * One IntersectionObserver on one sentinel, created once. The alternative — a scroll listener — + * would be the third reader of the same scroll position on this page (the other two are + * scheduleInspectorDock and scheduleStickyOffsets), and it would exist only to relabel a + * button. + * + * @returns {boolean} + */ + function ledgerEndInView() { + // Re-read the sentinel rather than returning the observer's flag. The flag is right when the + // callback fires and silently stale when it does not, and the states it describes — an empty + // ledger, a ledger that grew by 32,000 rows — are exactly the ones that never make it fire. + var sentinel = dom.ledgerEnd; + if (!sentinel || typeof sentinel.getBoundingClientRect !== 'function') return atLedgerEnd; + var r = sentinel.getBoundingClientRect(); + var viewH = window.innerHeight || 0; + // Both edges, or neither: the first version tested `r.height > 0 || r.top < viewH`, and the + // sentinel is one pixel tall — so `r.height > 0` was always true and the label read 「到最上」 + // at every scroll position. A trivially-true term inside an `||` is the same defect as a + // tautology in an assertion: it looks like a test and measures nothing. + atLedgerEnd = r.top < viewH && r.bottom > 0; + return atLedgerEnd; + } + + var atLedgerEnd = false; + var ledgerEndObserver = null; + + /** + * A picture of the trip, not its name. + * + * The first version of this control printed 「到最低」 / 「到最上」. Text is the wrong shape + * for it twice over: the two labels are different widths, so a circle drawn around them + * changes size when it flips, and answering 「which end will this take me to」 costs a + * translation and a reading. The arrow answers it in one glance, and `aria-label` still + * carries the full sentence — this is a change of presentation, not of what the control + * announces to anyone who is not looking at it. + * + * The arrow names where a click would take you, so the control is never a 「go to where you + * already are」 button — which is the failure mode of a fixed two-state label, and the reason + * up is only the right arrow when up is the action and down only when it is. + * + * It moves the page and nothing else. It does not touch `state.limit`, so it cannot make the + * window wider — the prohibition D13 exists to enforce, and the one the window bar's + * 「已显示 N 条」 depends on. Both ends are already loaded: down reaches the newest 200, up + * reaches the oldest of them. + */ + function setLedgerJumpLabel() { + var button = dom.ledgerJump; + if (!button) return; + var empty = !state.payload || !state.flat || state.flat.length === 0; + button.hidden = empty; + if (empty) return; + var atEnd = ledgerEndInView(); + var name = atEnd ? 'arrowUp' : 'arrowDown'; + // Rebuilt rather than swapped in place: there is no attribute selector that exchanges two + // `d` values, and a cross-fade on a 16px glyph reads as a flicker rather than as motion. + // The `data-arrow` read is what keeps this from rebuilding on every one of the scroll + // positions where the answer did not change. + var current = button.firstElementChild; + if (!current || current.getAttribute('data-arrow') !== name) { + button.textContent = ''; + var glyph = icon(name); + glyph.setAttribute('data-arrow', name); + button.appendChild(glyph); + } + button.setAttribute('aria-label', atEnd ? '跳到轨迹的最上(最早一条已加载的记录)' : '跳到轨迹的最低(最新一条已加载的记录)'); + } + + /** + * How much of the viewport the pinned filter band covers right now. + * + * Measured rather than read out of `--sticky-band-h`. The band is sticky, so its own + * rectangle already answers the question at the moment it is asked, and the variable is what + * the other pinned rows position themselves from — subtracting it here would be the second + * place on this page that knows the band is there. + */ + function pinnedBandBottom() { + if (!dom.filterSticky || typeof dom.filterSticky.getBoundingClientRect !== 'function') return 0; + var bottom = dom.filterSticky.getBoundingClientRect().bottom; + return bottom > 0 ? bottom : 0; + } + + /** + * The ledger's top, in document coordinates. + * + * 轨迹的最上, and not the page's — those are different places and the difference is most of + * the travel. `window.scrollTo(0, 0)` lands the reader above the filter band and the overview + * strip with the trajectory still entirely off screen below, so the control that claims to go + * 「up」 ends up showing the one place in this app that contains no records at all. The + * band's height is subtracted so the panel's own title clears it. + */ + function ledgerTopOffset() { + var panel = dom.ledgerJump && typeof dom.ledgerJump.closest === 'function' + ? dom.ledgerJump.closest('.panel') + : null; + if (!panel) return 0; + var top = panel.getBoundingClientRect().top + (window.scrollY || window.pageYOffset || 0); + return Math.max(0, top - pinnedBandBottom()); + } + + /** + * The ledger's bottom, in document coordinates. + * + * The sentinel's own bottom edge rather than the last row's, for the reason the sentinel + * exists at all: it is one element outside `#ledger`, so its position follows the panel + * instead of whatever the last poll happened to build. No band subtraction here, and the + * asymmetry with ledgerTopOffset() is the point — that one parks a top edge under the band, + * this one parks a bottom edge at the bottom of the viewport, and subtracting the band from + * both would push the last row under the pinned rows for no gain. + */ + function ledgerBottomOffset() { + var sentinel = dom.ledgerEnd; + if (!sentinel || typeof sentinel.getBoundingClientRect !== 'function') return 0; + var bottom = sentinel.getBoundingClientRect().bottom + (window.scrollY || window.pageYOffset || 0); + return Math.max(0, bottom); + } + + /** One-time. See setLedgerJumpLabel() for why the observer and not a scroll listener. */ + function buildLedgerJump() { + var button = dom.ledgerJump; + var sentinel = dom.ledgerEnd; + if (!button || !sentinel || typeof IntersectionObserver !== 'function') return; + + ledgerEndObserver = new IntersectionObserver(function (entries) { + for (var i = 0; i < entries.length; i += 1) { + atLedgerEnd = entries[i].isIntersecting; + } + setLedgerJumpLabel(); + }, { rootMargin: '0px 0px -1px 0px' }); + ledgerEndObserver.observe(sentinel); + + button.addEventListener('click', function () { + window.scrollTo(0, ledgerEndInView() ? ledgerTopOffset() : ledgerBottomOffset()); + }); + + setLedgerJumpLabel(); + } + /** * The mode button names the action; the count beside it names the state. * @@ -6728,6 +6992,43 @@

检视

// landing exactly on the window's reads as cut off rather than as 「there is more below」, and // the region it scrolls in is only worth having if the reader can see that it scrolls. var INSPECTOR_WIDE_BOTTOM_GAP = 12; + // D44: the hair of viewport between the ledger's two-ends jump and the top of a pinned + // inspector. It is a gap rather than a butt joint because a button flush against another + // control's border reads as one control with two labels. Measured on the narrow layout: the + // panel renders 360px and the button must sit 368px up, so the clearance is counted from the + // panel's own top edge rather than merely made non-negative. + var LEDGER_JUMP_CLEARANCE = 8; + var bottomReserveFrame = 0; + + /** + * The viewport bottom the pinned inspector is occupying, measured and written once a frame + * after the dock state changed. + * + * Written to the root rather than to the panel, because the reader is in the OTHER panel: in + * the other column on a wide screen and below it on a narrow one, and a custom property only + * reaches descendants. + * + * Measured rather than derived from the caps, and the reader can drag one of those caps and + * folded's height is its header's height rather than any cap at all — so a formula here would + * be a third derivation of a number two other places already own. + * + * Coalesced into one frame because this runs from applyInspectorDock(), which runs on scroll. + */ + function scheduleInspectorBottomReserve() { + if (bottomReserveFrame) return; + bottomReserveFrame = window.requestAnimationFrame(function () { + bottomReserveFrame = 0; + var panel = dom.inspectorPanel; + var reserve = 0; + if (panel && !panel.hidden + && panel.className.indexOf('is-inspector-') !== -1 + && window.matchMedia(INSPECTOR_DOCK_QUERY).matches) { + reserve = Math.round(panel.getBoundingClientRect().height) + LEDGER_JUMP_CLEARANCE; + } + document.documentElement.style.setProperty('--inspector-bottom-reserve', reserve + 'px'); + }); + } + // …but never so little that the rows become a sliver. Below this the reader cannot read a // row, which defeats the point of keeping any. var INSPECTOR_MIN_LEDGER_H = 140; @@ -6846,6 +7147,17 @@

检视

dom.inspectorPanel.classList.toggle('is-inspector-folded', folded); } + // How much of the viewport bottom the panel is sitting on, for the one reader that needs it: + // the ledger's two-ends jump, which is sticky to the bottom of the same viewport and would + // otherwise land on top of the panel's action row. + // + // SCHEDULED, not written here, and the reason is the measurement. Reading + // `getBoundingClientRect()` in the same turn that toggles the class measures the layout as it + // was: measured on a real session the reserve came out 177px against a panel rendering at + // 360px, which put the button 32px inside the panel at eleven of eighteen scroll positions. So + // the read waits a frame, by which time the sticky layout has settled. + scheduleInspectorBottomReserve(); + // The grip exists only while the panel is pinned: in flow there is no height to decide, and // a grip that does nothing is worse than no grip. Folded has no height to drag either — its // cap is the header, and dragging a header that cannot grow is the same dead control. @@ -8236,6 +8548,11 @@

检视

render(); }); + // D44: the ledger's two-ends jump. Wired here with everything else, once — the observer it + // creates outlives every rebuild of #ledger, which is why its sentinel is a sibling of + // #ledger rather than a child. + buildLedgerJump(); + // Built before it is bound, so the buttons and their handlers come from the same // table and cannot be two lists that drift. buildAnomalyButtons(); From 32cf36e01d2f88bdea60dbef490d1a84ef4c8d08 Mon Sep 17 00:00:00 2001 From: avatasia Date: Tue, 13 Oct 2026 19:54:16 +0800 Subject: [PATCH 72/88] Guards for D44, and the two rules they had been asserting backwards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-selection.mjs asserted the criterion D44 replaces, so those assertions were rewritten rather than added to: the behaviour case now covers a selection the window slid past but the filters kept, and the source assertion is inverted to demand revealSelection(matched) and the absence of revealSelection(windowed). The ordering assertion was split out from the argument one -- a version that judges before the slice passes the argument and fails the order, and needs its own break. Two mutations are new: one moves the judgement ahead of the slice, the other freezes the tail into a prefix. That second one is a rule D44 made necessary. Only the judgement was being watched, so a version that quietly stopped sliding would have stayed green -- and a frozen tail is the one thing that would turn the window bar's 「更早的 K 条未加载」 into a sentence with no way to tell it from the truth. New: verify-ledger-jump.mjs (38) with mutate-ledger-jump.py (30), all caught. Four of its rules guard bugs that had already shipped once: * a flat bottom offset, which measured a 35px overlap at 12 of 18 positions; * scrollTo(0, 0) for 「最上」, which showed a screen with no records on it; * r.height > 0 || r.top < viewH against a one-pixel sentinel, where the left term is true at every scroll position -- a trivially-true term inside an || is the same defect as a tautology in an assertion; * two words for one glyph, so a circle drawn around them changed size when it turned. mutate-cross-layer.py's first anchor was the three lines between the window bar and the ledger section's close. D44 put a sentinel and a button there, so the anchor stopped resolving and the control refused to start. Re-anchored to the whole panel body, with the reason written down, because the next change will do it again. Three of the new mutations survived the first run and all three were the guard's fault: assertions scoped to "anywhere in the function" are satisfied by that function's own comment prose, and by its own typeof null check, after the expression the rule is about has been deleted. A fourth asserted a landing point the page was too short to reach. Both are written up in METHODOLOGY 8.9. Suite: 55/55, twice. Fixtures unchanged. --- .../mmc-trajectory/tests/METHODOLOGY.md | 31 ++ .../tests/mutate-cross-layer.py | 23 ++ .../tests/mutate-ledger-jump.py | 279 ++++++++++++++++++ .../mmc-trajectory/tests/mutate-selection.py | 47 ++- .../tests/verify-ledger-jump.mjs | 224 ++++++++++++++ .../mmc-trajectory/tests/verify-selection.mjs | 61 ++-- 6 files changed, 631 insertions(+), 34 deletions(-) create mode 100644 plugins/avatasia/mmc-trajectory/tests/mutate-ledger-jump.py create mode 100644 plugins/avatasia/mmc-trajectory/tests/verify-ledger-jump.mjs diff --git a/plugins/avatasia/mmc-trajectory/tests/METHODOLOGY.md b/plugins/avatasia/mmc-trajectory/tests/METHODOLOGY.md index 1e0b68f..35422c5 100644 --- a/plugins/avatasia/mmc-trajectory/tests/METHODOLOGY.md +++ b/plugins/avatasia/mmc-trajectory/tests/METHODOLOGY.md @@ -586,6 +586,37 @@ screenshot of a wide window is not a wide viewport — measure the shortest cap on both sides of the breakpoint, and say out loud which tabs cannot scroll at all instead of letting them pad the pass count. +### 8.9 An assertion scoped to a whole function is satisfied by that function's prose + +The ledger's two-ends control shipped with 30 mutations and 38 assertions. Three mutations survived +the first run, and all three were the guard's fault rather than the mutation's — which is the shape +that costs the most, because the next move is usually "the mutation is wrong". + +- **Two were scoped too wide.** `assert(/data-arrow/.test(setLedgerJumpLabelSrc))` reads *somewhere in + the function*; the rule is about the `if` guard clause, and `glyph.setAttribute('data-arrow', …)` + sits two lines below it. So a mutation that replaced the whole guard with `if (true)` left the + substring in place and the rule uncaught. Same failure, different disguise: + `/getBoundingClientRect/.test(ledgerEndInViewSrc)` is satisfied by the function's own + `typeof sentinel.getBoundingClientRect !== 'function'` null check, so deleting the actual + `sentinel.getBoundingClientRect()` call changed nothing the assertion could see. Both fixes are the + same move: **narrow the assertion to the expression the rule is about** — the `if (…)` clause, the + `var r = …` read, the `setAttribute(…)` argument — never the whole function. +- **The same trap with words instead of identifiers.** A rule "the label says which end" written as + `/最上/.test(src)` is satisfied by the function's own comment explaining which labels it *used to* + have. Assertions that look for prose have to be scoped to the line that writes the prose. +- **One mutation was a no-op.** It moved the sentinel markup to immediately *before* the button — which + is exactly where it already was. A mutation that changes nothing proves nothing and reads as a + survived guard, which sends you to fix the guard instead of the edit. Re-read what the shipped order + actually is before writing an edit that depends on it. + +One more, from the browser probe rather than the guard: an assertion can be **correct in shape and +impossible to satisfy**. `endBottom ≈ viewH` asserted that the ledger's sentinel lands on the viewport +bottom after pressing 「到最低」, and measured `391` against `900`. Nothing was broken — the document was +8,740px tall, the maximum scroll was 7,840, and the sentinel sat 3,231px down, so `scrollTo` was +clamped. The promise 「到最低」 actually makes is "as far as the page goes, with the newest records and +the control still on screen and clear of the panel", and that is what the assertion now says. **Before +asserting where something lands, check the page is tall enough to land there.** + ## 9. Say what you did not manage to check Some things cannot be driven through the browser harness. The honest form is the useful one: diff --git a/plugins/avatasia/mmc-trajectory/tests/mutate-cross-layer.py b/plugins/avatasia/mmc-trajectory/tests/mutate-cross-layer.py index bcb7d21..a431515 100644 --- a/plugins/avatasia/mmc-trajectory/tests/mutate-cross-layer.py +++ b/plugins/avatasia/mmc-trajectory/tests/mutate-cross-layer.py @@ -23,11 +23,34 @@ MUTATIONS = [ # Declaration 1, position half: the window bar leaves the L5 panel entirely. + # + # The anchor spans the whole panel body because the replacement has to put the bar *outside* + # `
`, so the edit has to reach the close. D44 is what made it long: the ledger's + # two-ends control put a sentinel and a button between the bar and the close, and the previous + # three-line anchor stopped resolving. A broken anchor is a loud failure — this harness refuses + # to start rather than silently mutating nothing — but it is still a failure a change caused and + # did not mean to. ("the window bar is presented outside the ledger panel", '
\n' '
\n' + ' \n' + ' \n' + ' \n' + ' \n' ' ', '
\n' + ' \n' + ' \n' + ' \n' + ' \n' ' \n' '
', "#window-bar is presented inside the ledger panel (L5)"), diff --git a/plugins/avatasia/mmc-trajectory/tests/mutate-ledger-jump.py b/plugins/avatasia/mmc-trajectory/tests/mutate-ledger-jump.py new file mode 100644 index 0000000..ec5d289 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/tests/mutate-ledger-jump.py @@ -0,0 +1,279 @@ +"""Mutation control for the ledger's two-ends control. + +A guard that cannot fail is not a guard. Each mutation below breaks one of the rules in +verify-ledger-jump.mjs the way it would actually break in the file, and requires that guard to fail +with the right reason before the file is restored byte for byte. + +Three of these break a bug that had already shipped once, so they are worth naming: + + * the flat `bottom: 12px` — a 35px overlap at 12 of 18 scroll positions on a real 22,527-message + session, which the one position anybody spot-checked happened to be free of; + * `window.scrollTo(0, 0)` for 「最上」 — the control claimed to go up and landed on the only screen + in the app with no records on it; + * `r.height > 0 || r.top < viewH` — the left term is true at every scroll position against a + one-pixel sentinel, so the arrow pointed up everywhere. That one is a trivially-true term + inside an `||`, which is the same defect as a tautology in an assertion: it looks like a test + and measures nothing. + +One breaks the rule this control's own reason for existing, which is why the offset writer is +asserted to have exactly one owner. A second writer of `--inspector-bottom-reserve` is a number +written twice from two places that each believe they own it, which is the shape every two-writer +defect in this client has taken. +""" +import io +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +CLIENT = os.path.join(ROOT, "miniapp", "client", "index.html") +PROBE = os.path.join(HERE, "verify-ledger-jump.mjs") + +MUTATIONS = [ + # ---------- the markup ---------- + + ("the button ships with words in it", + [('', + '')], + "the jump button ships with no text in it"), + + ("the button ships visible, before it has anything to show", + [('id="ledger-jump" hidden aria-label=', 'id="ledger-jump" aria-label=')], + "the jump button ships hidden, so there is no empty circle on first paint"), + + ("the button is not typed as a button", + [('', + '\n ')], + "the sentinel and the button ship together, sentinel first"), + + ("the sentinel is announced to a reader", + [('id="ledger-end" aria-hidden="true"', 'id="ledger-end"')], + "the sentinel is inert to a reader, since it is a measuring mark and not content"), + + # ---------- the two arrows ---------- + + ("one direction has no icon", + [("\n arrowUp: ['M8 13.2V2.8', 'M4.6 6.4 8 3l3.4 3.4']", "")], + "both directions ship as icons"), + + ("an arrow loses its head", + [("arrowDown: ['M8 2.8v10.4', 'M4.6 9.6 8 13l3.4-3.4']", "arrowDown: ['M8 2.8v10.4']")], + "each arrow is a shaft and a head, not a single stroke"), + + # The names differ while the pictures are the same: the code reads correctly and the control + # does nothing a reader could tell apart. + ("the two arrows are the same picture under two names", + [("arrowUp: ['M8 13.2V2.8', 'M4.6 6.4 8 3l3.4 3.4']", "arrowUp: ['M8 2.8v10.4', 'M4.6 9.6 8 13l3.4-3.4']")], + "the two arrows are different shapes"), + + # ---------- what the control shows ---------- + + ("the control goes back to words", + [(" var glyph = icon(name);\n", " var glyph = document.createTextNode(atEnd ? '\u5230\u6700\u4e0a' : '\u5230\u6700\u4f4e');\n")], + "the control shows an arrow rather than a word"), + + ("the arrow stops following the reading", + [("var name = atEnd ? 'arrowUp' : 'arrowDown';", "var name = 'arrowDown';")], + "which arrow it is comes from the reading, not from a counter"), + + # Scoped to the attribute: the function's own comment names both old labels, so a mutation that + # only changed the string would still be satisfied by the prose. + ("the label no longer says which end", + [("'\u8df3\u5230\u8f68\u8ff9\u7684\u6700\u4e0a\uff08\u6700\u65e9\u4e00\u6761\u5df2\u52a0\u8f7d\u7684\u8bb0\u5f55\uff09'", + "'\u8df3\u5230\u8f68\u8ff9\u7684\u9876\u90e8'")], + "the control still says in words where a click would go"), + + ("the empty case leaves an arrow pointing nowhere", + [(" button.hidden = empty;\n", "")], + "the control is absent rather than wrong when there is nothing to jump between"), + + ("the glyph is rebuilt on every scroll position", + [(" var current = button.firstElementChild;\n" + " if (!current || current.getAttribute('data-arrow') !== name) {\n", + " if (true) {\n")], + "the glyph is not rebuilt on every scroll position"), + + # ---------- the reading ---------- + + ("the end of the ledger is remembered instead of measured", + [("var r = sentinel.getBoundingClientRect();", "var r = dom.ledgerEnd;")], + "the end of the ledger is re-measured rather than remembered"), + + # The bug this rule exists for. `r.height > 0` is true of a one-pixel sentinel at every scroll + # position, so the `||` made the reading constant and the arrow pointed up everywhere. + ("one edge of the sentinel is enough", + [("atLedgerEnd = r.top < viewH && r.bottom > 0;", "atLedgerEnd = r.height > 0 || r.top < viewH;")], + "both edges are required, so a one-pixel sentinel cannot read as always present"), + + # ---------- the two targets ---------- + + ("up goes to the top of the page", + [("window.scrollTo(0, ledgerEndInView() ? ledgerTopOffset() : ledgerBottomOffset());", + "window.scrollTo(0, 0);")], + "the click never scrolls to the top of the document"), + + ("the two ends are swapped", + [("window.scrollTo(0, ledgerEndInView() ? ledgerTopOffset() : ledgerBottomOffset());", + "window.scrollTo(0, ledgerEndInView() ? ledgerBottomOffset() : ledgerTopOffset());")], + "the click chooses between the two ends from the same reading the arrow does"), + + ("up is measured against the whole document", + [(" var panel = dom.ledgerJump && typeof dom.ledgerJump.closest === 'function'\n" + " ? dom.ledgerJump.closest('.panel')\n" + " : null;\n", + " var panel = document.body;\n")], + "up means the ledger panel, not the page"), + + ("up ignores the pinned band", + [("return Math.max(0, top - pinnedBandBottom());", "return Math.max(0, top);")], + "up clears the pinned band, measured rather than assumed"), + + ("the band clearance is measured off the wrong element", + [(" if (!dom.filterSticky || typeof dom.filterSticky.getBoundingClientRect !== 'function') return 0;\n" + " var bottom = dom.filterSticky.getBoundingClientRect().bottom;", + " if (!dom.overview || typeof dom.overview.getBoundingClientRect !== 'function') return 0;\n" + " var bottom = dom.overview.getBoundingClientRect().bottom;")], + "the band clearance is measured off the band itself"), + + ("down reads the last row instead of the sentinel", + [("var bottom = sentinel.getBoundingClientRect().bottom", "var bottom = sentinel.lastElementChild.getBoundingClientRect().bottom")], + "down means the sentinel at the ledger's own bottom"), + + ("the control is wired twice", + [(" buildLedgerJump();\n", " buildLedgerJump();\n buildLedgerJump();\n")], + "the jump is built once, on the wire path"), + + # ---------- the offset, and its one writer ---------- + + ("the button is pinned to the viewport instead of the panel", + [(" position: sticky;\n bottom: calc(12px + var(--inspector-bottom-reserve, 0px));\n", + " position: fixed;\n bottom: calc(12px + var(--inspector-bottom-reserve, 0px));\n")], + "the button is sticky inside the panel rather than fixed on the viewport"), + + # The mutation that measured 35px of overlap at 12 of 18 scroll positions on a real session. + ("the offset goes back to a number written down", + [("bottom: calc(12px + var(--inspector-bottom-reserve, 0px));", "bottom: 12px;")], + "the bottom offset is measured, not written down"), + + ("the button stops being a circle", + [(" margin: 0 auto;\n padding: 0;\n border: 1px solid var(--mcode-border);\n" + " border-radius: 50%;\n", + " margin: 0 auto;\n padding: 0;\n border: 1px solid var(--mcode-border);\n" + " border-radius: 8px;\n")], + "the button is a circle, which is what makes an arrow inside it read as a direction"), + + ("the button goes under the panel it has to clear", + [(" z-index: 6;\n display: grid;\n place-items: center;\n", " z-index: 2;\n display: grid;\n place-items: center;\n")], + "the button is out-levelled above the panel it has to clear"), + + # `display: grid` beats the `hidden` attribute, so without this the empty case renders an empty + # circle instead of nothing — the exact hole the markup's own comment is about. + ("`hidden` loses to the button's own display", + [(" .ledger-jump[hidden] {\n display: none;\n }\n", "")], + "`hidden` still wins over the button's own display"), + + ("the button cannot be reached from the keyboard", + [(" .ledger-jump:focus-visible {\n outline: 2px solid var(--mcode-accent, currentColor);\n" + " outline-offset: 2px;\n }\n", "")], + "the button is reachable from the keyboard, with a ring that is its own"), + + ("a second writer of the offset", + [(" scheduleInspectorBottomReserve();\n", + " scheduleInspectorBottomReserve();\n document.documentElement.style.setProperty('--inspector-bottom-reserve', '0px');\n")], + "the offset has exactly one writer"), + + ("the offset stops being the panel's own height", + [("var panel = dom.inspectorPanel;\n var reserve = 0;", "var panel = dom.ledgerJump;\n var reserve = 0;")], + "the writer is the function that already owns the panel's height"), + + # Measured in the same turn that toggled the dock class, the panel has not been laid out yet: + # 177px against a panel that then rendered 360px. + ("the offset is read before the panel has been laid out", + [(" bottomReserveFrame = window.requestAnimationFrame(function () {\n bottomReserveFrame = 0;\n", + " bottomReserveFrame = 0;\n (function () {\n"), + (" document.documentElement.style.setProperty('--inspector-bottom-reserve', reserve + 'px');\n });\n", + " document.documentElement.style.setProperty('--inspector-bottom-reserve', reserve + 'px');\n })();\n")], + "the offset is read after layout, in a later frame"), + + ("the dock stops re-measuring the offset", + [(" scheduleInspectorBottomReserve();\n", "")], + "the dock is what re-measures it"), + + # A poll rebuilds the ledger without firing any scroll event, so the observer alone leaves the + # label describing the end the reader is no longer at. + ("only a scroll re-labels the control", + [(" setLedgerJumpLabel();\n\n var groups = visibleTurns(windowed);", + " var groups = visibleTurns(windowed);")], + "a render re-labels the control, not only a scroll"), +] + +original = io.open(CLIENT, encoding="utf-8", newline="").read() + + +def run(): + proc = subprocess.run(["node", PROBE], cwd=HERE, + capture_output=True, text=True, encoding="utf-8", errors="replace") + return proc.returncode, proc.stdout + proc.stderr + + +ok = True +code, baseline = run() +base_ok = code == 0 and "0 failed" in baseline +print(("ok " if base_ok else "FAIL ") + "baseline green") +ok = ok and base_ok + +caught = [] +for title, edits, expect in MUTATIONS: + for old, _ in edits: + hits = original.count(old) + if hits != 1: + raise SystemExit("FAIL anchor for %r matched %d times" % (title, hits)) + try: + mutated = original + for old, new in edits: + mutated = mutated.replace(old, new, 1) + io.open(CLIENT, "w", encoding="utf-8", newline="").write(mutated) + during_code, during = run() + named = expect in during + red = during_code != 0 + # A guard that crashes is not a guard that caught anything: the rule has to be reported by + # name, or the mutation only proved that the file no longer parses. + print(("ok " if (named and red) else "FAIL ") + "guard catches: " + title) + if not named: + print(" no assertion reported %r" % expect) + for line in during.splitlines()[-4:]: + print(" " + line.strip()) + elif not red: + print(" the rule was reported but the guard still exited 0") + caught.append(named and red) + finally: + io.open(CLIENT, "w", encoding="utf-8", newline="").write(original) + +restored = io.open(CLIENT, encoding="utf-8", newline="").read() +after_code, after = run() +after_ok = after_code == 0 and "0 failed" in after +print(("ok " if restored == original else "FAIL ") + "client restored byte for byte") +print(("ok " if after_ok else "FAIL ") + "green again after restore") + +sys.exit(0 if (ok and all(caught) and restored == original and after_ok) else 1) \ No newline at end of file diff --git a/plugins/avatasia/mmc-trajectory/tests/mutate-selection.py b/plugins/avatasia/mmc-trajectory/tests/mutate-selection.py index 4c1a5ed..2591026 100644 --- a/plugins/avatasia/mmc-trajectory/tests/mutate-selection.py +++ b/plugins/avatasia/mmc-trajectory/tests/mutate-selection.py @@ -17,6 +17,13 @@ "the window is never pulled down to the selection" was guarding a behaviour that was itself the defect: it pinned the `state.limit` write in place. It now breaks the rule that replaced it, and "the selection is judged after the window is sliced" guards the ordering the other way round. + +D44 inverted one of these again, and the mutation followed the rule rather than staying where it +was. "the selection is judged against the window again" used to be *the* rule: the window was the +tail `state.limit` slice, the slice slides a row on every new record, and a selection on the oldest +loaded record therefore died after one poll with the reader touching nothing. The judgement now runs +against `matched`, the filter result, and two more mutations guard what that must not take with it — +the ordering, and the fact that the tail keeps sliding. """ import io import os @@ -67,15 +74,15 @@ # The poll path was the only caller, which is why a local filter change never reached it. ("the selection is never re-judged when the table changes", - [(" if (revealSelection(windowed)) renderInspector();\n", "")], + [(" if (revealSelection(matched)) renderInspector();\n", "")], "the selection is re-judged at the one exit every filter passes through"), # The half that is easy to miss, because the state is correct afterwards and only the DOM is # stale: nine writers reach renderLedger() without redrawing the panel, so the search box left # the inspector naming a record that had just been dropped. ("the judgement is made but the panel is never redrawn", - [(" if (revealSelection(windowed)) renderInspector();\n", - " revealSelection(windowed);\n")], + [(" if (revealSelection(matched)) renderInspector();\n", + " revealSelection(matched);\n")], "the panel is redrawn when the selection is dropped"), # And the other half: a verdict nobody is told about cannot be acted on. @@ -84,14 +91,32 @@ " state.selectedId = null;\n return false;\n")], "dropping the selection is reported so the panel can be redrawn"), - # Judged against `matched` — the filter result — rather than `windowed`. A record that survived - # every filter and was still cut off by the window keeps its selection, and the inspector goes - # on describing a row that is not on screen. That was this mutation's premise before the - # ordering changed; the defect it names has simply moved to the other side of the slice. - ("the selection is judged against the filtered rows rather than the window", - [(" if (revealSelection(windowed)) renderInspector();\n", - " if (revealSelection(matched)) renderInspector();\n")], - "the selection is judged against the window, not the filter result"), + # Judged against `windowed` — the tail slice — rather than `matched`, the filter result. That + # was the D37 defect: the window slides forward by a row on every new record, so a selection on + # the oldest loaded record died after ONE poll, with the reader touching nothing. Reinstating the + # argument must be caught by name — this mutation's premise used to be the rule, and D44 + # inverted it. + ("the selection is judged against the window again", + [(" if (revealSelection(matched)) renderInspector();\n", + " if (revealSelection(windowed)) renderInspector();\n")], + "the selection is judged against the filter result, not against the window"), + + # And the ordering is a separate rule from the argument. A version that judges before the slice + # sees a list that is not the one the table is about to draw, and passes the argument rule + # while failing this one — so it needs its own break rather than sharing the mutation above. + ("the selection is judged before the window is sliced", + [(" if (revealSelection(matched)) renderInspector();\n", ""), + (" var windowed = matched.length", + " if (revealSelection(matched)) renderInspector();\n var windowed = matched.length")], + "the re-judgement runs after the window is sliced"), + + # The window keeps sliding. D44 changed which list is judged; it did not freeze the tail, and a + # guard that only watched the judgement would stay green through a version that quietly stopped + # sliding — which is the one thing that would make the window bar's 「更早的 K 条未加载」 a lie. + ("the window stops sliding and becomes a frozen prefix", + [("matched.length > state.limit ? matched.slice(matched.length - state.limit) : matched", + "matched.length > state.limit ? matched.slice(0, state.limit) : matched")], + "the window is still a sliding tail slice, not a frozen prefix"), # Two writers. The poll path judged the selection again, and a reader could be told two # different things depending on which one ran last. diff --git a/plugins/avatasia/mmc-trajectory/tests/verify-ledger-jump.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-ledger-jump.mjs new file mode 100644 index 0000000..87e493a --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/tests/verify-ledger-jump.mjs @@ -0,0 +1,224 @@ +/** + * The ledger's two-ends control: one round button, an arrow that turns, and two ends that are the + * ledger's rather than the page's. + * + * It exists because D37 killed the selection a poll could not see, and the fix — judge the + * selection against the filter result instead of against the window — left the reader with 200 + * loaded records spread over 7,800 px and no way to cross that distance except the scrollbar. + * + * Four things about it are easy to get wrong in ways no comment prevents, so each is a rule here: + * + * 1. The offset is measured. `bottom` is `calc(12px + var(--inspector-bottom-reserve))` because + * the thing it clears is a panel the reader drags, in three states, on a screen where the two + * share one column. A flat 12px measured a 35px overlap at 12 of 18 scroll positions on a real + * 22,527-message session — and a single spot check said there was none, because that one spot + * was the last one, where the ledger's own edge had already passed above the panel. + * 2. The sentinel is outside `#ledger`, which is cleared and rebuilt on every poll. Inside it, + * the observer would have to be rebuilt with the table; outside it, one observer, created + * once, and its position follows the panel. + * 3. 「在不在底部」 is re-read, not remembered. `ledgerEndInView()` re-measures the sentinel + * rather than returning the observer's flag, because the flag is right when the callback + * fires and silently stale when it does not. Its first version tested both edges with `||` + * against a one-pixel sentinel, so `r.height > 0` was always true and the arrow read 「up」 + * everywhere. + * 4. 「最上」 is the ledger's top. It was `window.scrollTo(0, 0)`, which lands above the filter + * band and the overview strip with the trajectory still off screen — the control claimed to + * go up and showed the one screen in the app with no records on it. + * + * mutate-ledger-jump.py breaks each of these in the shipped file and requires this guard to catch + * it by name. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const HTML = fileURLToPath(new URL('../miniapp/client/index.html', import.meta.url)); +const html = readFileSync(HTML, 'utf8'); + +let pass = 0; +const failures = []; +function assert(name, cond) { + if (cond) pass += 1; + else failures.push(name); +} + +function functionSource(name) { + const at = html.indexOf('function ' + name + '('); + if (at < 0) return null; + let i = html.indexOf('{', at); + let depth = 0; + for (let j = i; j < html.length; j += 1) { + if (html[j] === '{') depth += 1; + else if (html[j] === '}') { + depth -= 1; + if (depth === 0) return html.slice(at, j + 1); + } + } + return null; +} + +function cssRule(pattern) { + const m = new RegExp(pattern + '\\s*\\{([\\s\\S]*?)\\n\\s*\\}', 'm').exec(html); + return m ? m[1] : null; +} + +/* ---------- the shape of the control ---------- */ + +// No text in the markup, and hidden until it has something to show. The first version shipped the +// literal 「到最低」, which meant the button read one thing for a moment before the script decided +// whether that was true; the second shipped an empty circle, which is a hole in the page. +const jumpTag = /]*id="ledger-jump"[^>]*>/.exec(html); +const jumpTagEnd = jumpTag ? html.indexOf('', jumpTag.index) : -1; +assert('the jump button ships', !!jumpTag); +assert('the jump button ships with no text in it', + !!jumpTag && jumpTagEnd > 0 + && html.slice(jumpTag.index, jumpTagEnd + 9) === jumpTag[0] + ''); +assert('the jump button ships hidden, so there is no empty circle on first paint', + !!jumpTag && /hidden/.test(jumpTag[0])); +assert('the jump button is a button and not a link, so Enter and Space both work', + !!jumpTag && /type="button"/.test(jumpTag[0])); +assert('the jump button carries a label of its own for anyone not looking at it', + !!jumpTag && /aria-label="[^"]+"/.test(jumpTag[0])); + +/* ---------- the sentinel, and where it lives ---------- */ + +const atLedgerOpen = html.indexOf('id="ledger"'); +const ledgerClose = html.indexOf('', atLedgerOpen); +const atSentinel = html.indexOf('id="ledger-end"'); +assert('the sentinel is outside #ledger, which is cleared and rebuilt on every poll', + atLedgerOpen > 0 && ledgerClose > 0 && atSentinel > ledgerClose); + +// The button and the sentinel are siblings at the panel's bottom, in that order: the button's +// sticky box is positioned against the panel's content edge, and the sentinel is what says where +// that edge currently is. +const atJump = html.indexOf('id="ledger-jump"'); +const panelClose = html.indexOf('', atSentinel); +assert('the sentinel and the button ship together, sentinel first', + atJump > atSentinel && atJump < panelClose); +assert('the sentinel is inert to a reader, since it is a measuring mark and not content', + /class="ledger-end" id="ledger-end" aria-hidden="true"/.test(html)); + +/* ---------- the two arrows ---------- */ + +const iconTable = /var ICON_PATHS = \{([\s\S]*?)\n {6}\};/.exec(html); +const arrowDown = iconTable ? /arrowDown:\s*\[([^\]]*)\]/.exec(iconTable[1]) : null; +const arrowUp = iconTable ? /arrowUp:\s*\[([^\]]*)\]/.exec(iconTable[1]) : null; +assert('both directions ship as icons', !!arrowDown && !!arrowUp); +assert('each arrow is a shaft and a head, not a single stroke', + !!arrowDown && !!arrowUp && arrowDown[1].split(',').length === 2 && arrowUp[1].split(',').length === 2); +assert('the two arrows are different shapes', + !!arrowDown && !!arrowUp && arrowDown[1].trim() !== arrowUp[1].trim()); + +/* ---------- what the control shows ---------- */ + +const labelSrc = functionSource('setLedgerJumpLabel'); +assert('the label setter is in the shipped script', !!labelSrc); + +// A picture, not a name. `aria-label` still carries the sentence, so this is a change of +// presentation and not of what the control announces to anyone not looking at it. +assert('the control shows an arrow rather than a word', + !!labelSrc && /icon\(name\)/.test(labelSrc) && !/textContent = atEnd/.test(labelSrc)); +assert('which arrow it is comes from the reading, not from a counter', + !!labelSrc && /var name = atEnd \? 'arrowUp' : 'arrowDown'/.test(labelSrc)); +// Scoped to the attribute itself, not to the function: this file's own comment explains what the +// two labels used to be, so a search for the words anywhere in the source is satisfied by prose. +const ariaLine = labelSrc ? /setAttribute\('aria-label',[^\n]*\)/.exec(labelSrc) : null; +assert('the control still says in words where a click would go', + !!ariaLine && /最上/.test(ariaLine[0]) && /最低/.test(ariaLine[0])); +assert('the control is absent rather than wrong when there is nothing to jump between', + !!labelSrc && /button\.hidden = empty/.test(labelSrc) + && /!state\.payload \|\| !state\.flat \|\| state\.flat\.length === 0/.test(labelSrc)); +// Scoped to the guard clause, not to the function: `data-arrow` is also written just below, so a +// search for it anywhere in the source is satisfied by a version that rebuilds on every scroll. +assert('the glyph is not rebuilt on every scroll position', + !!labelSrc && /if \(!current \|\| current\.getAttribute\('data-arrow'\) !== name\) \{/.test(labelSrc)); + +/* ---------- the reading ---------- */ + +const inViewSrc = functionSource('ledgerEndInView'); +const verdict = inViewSrc ? /atLedgerEnd = [^;]*;/.exec(inViewSrc) : null; +// Scoped to the read for the same reason: the function's own `typeof … getBoundingClientRect` guard +// is a typeof check, and a search for the name anywhere would be satisfied by that alone. +assert('the end of the ledger is re-measured rather than remembered', + !!verdict && /var r = sentinel\.getBoundingClientRect\(\);/.test(inViewSrc) + && /r\.top < viewH/.test(verdict[0])); +// The first version read `r.height > 0 || r.top < viewH` against a one-pixel sentinel: the left +// term is true at every scroll position, so the arrow pointed up everywhere. A trivially-true +// term inside an `||` is the same defect as a tautology in an assertion. +assert('both edges are required, so a one-pixel sentinel cannot read as always present', + !!verdict && /&&/.test(verdict[0]) && !/\|\|/.test(verdict[0])); + +/* ---------- the two targets ---------- */ + +const topSrc = functionSource('ledgerTopOffset'); +const bottomSrc = functionSource('ledgerBottomOffset'); +const bandSrc = functionSource('pinnedBandBottom'); +const buildSrc = functionSource('buildLedgerJump'); + +assert('up means the ledger panel, not the page', + !!topSrc && /closest\('\.panel'\)/.test(topSrc) && !/document\.body/.test(topSrc)); +assert('up clears the pinned band, measured rather than assumed', + !!topSrc && /pinnedBandBottom\(\)/.test(topSrc)); +assert('the band clearance is measured off the band itself', + !!bandSrc && /dom\.filterSticky/.test(bandSrc)); +// Scoped to the read as well as to the element: a version that reads the sentinel's *last child* +// names the same variable and the same element, and answers with the last row's edge instead. +assert("down means the sentinel at the ledger's own bottom", + !!bottomSrc && /dom\.ledgerEnd/.test(bottomSrc) + && /var bottom = sentinel\.getBoundingClientRect\(\)\.bottom/.test(bottomSrc) + && !/lastElementChild/.test(bottomSrc)); + +assert('the click chooses between the two ends from the same reading the arrow does', + !!buildSrc && /window\.scrollTo\(0, ledgerEndInView\(\) \? ledgerTopOffset\(\) : ledgerBottomOffset\(\)\)/.test(buildSrc)); +assert('the click never scrolls to the top of the document', + !!buildSrc && !/scrollTo\(0, 0\)/.test(buildSrc)); +assert('the jump is built once, on the wire path', + (html.match(/buildLedgerJump\(\);/g) ?? []).length === 1 + && (functionSource('wire') ?? '').includes('buildLedgerJump();')); + +/* ---------- the offset, and its one writer ---------- */ + +const jumpCss = cssRule('^\\s*\\.ledger-jump'); +assert('the button ships a stylesheet rule', !!jumpCss); +assert('the button is sticky inside the panel rather than fixed on the viewport', + !!jumpCss && /position:\s*sticky/.test(jumpCss) && !/position:\s*fixed/.test(jumpCss)); +// The whole reason the rule reads a variable: a flat offset measured a 35px overlap at 12 of 18 +// positions, and a version that drops the variable still looks right at the one spot checked. +assert('the bottom offset is measured, not written down', + !!jumpCss && /bottom:\s*calc\(\s*12px\s*\+\s*var\(--inspector-bottom-reserve/.test(jumpCss)); +assert('the button is a circle, which is what makes an arrow inside it read as a direction', + !!jumpCss && /border-radius:\s*50%/.test(jumpCss) && /width:\s*40px/.test(jumpCss) + && /height:\s*40px/.test(jumpCss) && /padding:\s*0/.test(jumpCss)); +assert('the button is out-levelled above the panel it has to clear', + !!jumpCss && Number((/z-index:\s*(\d+)/.exec(jumpCss) ?? [])[1]) > 5); + +const hiddenCss = cssRule('^\\s*\\.ledger-jump\\[hidden\\]'); +assert("`hidden` still wins over the button's own display", + !!jumpCss && /display:\s*grid/.test(jumpCss) && !!hiddenCss && /display:\s*none/.test(hiddenCss)); +assert('the button is reachable from the keyboard, with a ring that is its own', + !!cssRule('^\\s*\\.ledger-jump:focus-visible')); + +assert('the offset has exactly one writer', + (html.match(/setProperty\('--inspector-bottom-reserve'/g) ?? []).length === 1); +const reserveSrc = functionSource('scheduleInspectorBottomReserve'); +assert("the writer is the function that already owns the panel's height", + !!reserveSrc && /dom\.inspectorPanel/.test(reserveSrc) && /getBoundingClientRect\(\)\.height/.test(reserveSrc)); +// Measured in a later frame, not this one: the dock class is switched and the panel has not been +// laid out yet, so the same code read 177px on a frame where the panel then rendered 360. +assert('the offset is read after layout, in a later frame', + !!reserveSrc && /requestAnimationFrame/.test(reserveSrc)); +assert('the dock is what re-measures it', + (functionSource('applyInspectorDock') ?? '').includes('scheduleInspectorBottomReserve();')); + +/* ---------- the render path, not only the observer ---------- */ + +// The observer alone is a cheap hint during scrolling; the label has to be right on every render +// too, because a poll rebuilds the ledger under the reader without firing any scroll event. +assert('a render re-labels the control, not only a scroll', + (functionSource('renderLedger') ?? '').includes('setLedgerJumpLabel();')); + +console.log('[ledger-jump] ' + pass + ' passed, ' + failures.length + ' failed'); +if (failures.length) { + console.log('FAILURES:\n - ' + failures.join('\n - ')); + process.exit(1); +} +console.log('[ledger-jump] OK'); \ No newline at end of file diff --git a/plugins/avatasia/mmc-trajectory/tests/verify-selection.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-selection.mjs index 73a3d1a..9af4943 100644 --- a/plugins/avatasia/mmc-trajectory/tests/verify-selection.mjs +++ b/plugins/avatasia/mmc-trajectory/tests/verify-selection.mjs @@ -20,10 +20,20 @@ * window controls, and a judgement. Every other two-writer defect in this client has had that * shape. * - * So the judgement is now a predicate over the rows that will actually be drawn, and it writes - * `state.selectedId` and nothing else. A selection below the window is *dropped*, not revealed: - * the reader's explicit window choice outranks an earlier click, and it matches what the filter - * axes already do. + * So the judgement writes `state.selectedId` and nothing else. + * + * What changed again in D44 is *which list* it is a predicate over. It was the window — the tail + * `state.limit` slice — and that made the window a second thing that could end a selection without + * the reader doing anything. The window is a tail slice and it slides: every poll that brought one + * new record in pushed the oldest loaded record out of it, and a selection sitting on that record + * was dropped by a poll (D37). One new record was enough, so 「显示最新 200 条」 was never the + * cause, only where a reader happened to notice it. + * + * So the judgement runs against `matched`, the filter result, while `windowed` is still what gets + * drawn. Nothing freezes and `state.limit` does not move, which is the point: the window bar's + * 「已显示 N 条 · 更早的 K 条未加载」 only stays true while the tail is allowed to slide. A selection + * now ends when the *filters* end it — like every other filter axis — and not when the window slides + * underneath it. * * It is checked by *running* revealSelection() against a `state` that carries both the unfiltered * session and the filtered list, so a version that reads the unfiltered one again produces the @@ -104,16 +114,13 @@ const kept = reveal({ selectedId: 'r3', limit: 10, entries: rows, flat: session assert('a record still in the table keeps its selection', kept.error === null && kept.selectedId === 'r3' && kept.limit === 10 && kept.dropped === false); -// Below the window. This is the reported defect: the reader had an old record selected in 全部, -// pressed 「显示最新 200 条」, the record left the table and the inspector went on naming it. -// -// It used to be answered by widening the window to reach the selection — which is why pressing -// 显示最新 200 条 could leave the bar reading 「已显示 15,224 条」 a second after promising 200. -// The selection is dropped instead, and the window is left exactly where the reader put it. -const belowWindow = reveal({ selectedId: 'r2', limit: 3, entries: rows.slice(7), flat: session }); -assert('a selection below the window is dropped, and the window is left alone', - belowWindow.error === null && belowWindow.selectedId === null - && belowWindow.limit === 3 && belowWindow.dropped === true); +// The window slid past it. This is D44, and it is the case the old judgement got wrong: the record +// is loaded, it passes every filter, and it has simply been pushed off the end of the last 200 by +// newer arrivals. `limit: 3` with ten filtered rows means `windowed` is r7..r9 — r2 is not in it — +// while `entries` is the whole filtered list, which is what the exit now hands in. +const slid = reveal({ selectedId: 'r2', limit: 3, entries: rows, flat: session }); +assert('a selection the window slid past, but the filters kept, survives — and the window is left alone', + slid.error === null && slid.selectedId === 'r2' && slid.limit === 3 && slid.dropped === false); const none = reveal({ selectedId: null, limit: 10, entries: rows, flat: session }); assert('no selection means nothing to reveal', @@ -149,17 +156,25 @@ const ledgerBody = ledgerSrc ? ledgerSrc.slice(ledgerSrc.indexOf('{') + 1, ledge const ledgerCalls = ledgerBody.match(/revealSelection\(/g) ?? []; assert('the selection is re-judged at the one exit every filter passes through', ledgerCalls.length === 1); -// Judged against the rows the table will actually draw, and after they are sliced. Judging the -// filtered list would leave a selection standing on a record that survived the filters and was -// still cut off by the window — the same page-contradicts-itself defect, one layer further in. +// Judged after the slice is taken, and against `matched` rather than `windowed`. Both halves matter +// and they are independent: the order says the judgement sees the window the table is really +// showing, and the argument says a record the window slid past is still the reader's selection +// until a filter says otherwise. The first version of D44 put the call before the slice, which +// passed the order and failed the argument. const atMatched = ledgerBody.indexOf('var matched = visibleEntries();'); const atSlice = ledgerBody.indexOf('var windowed'); -const atReveal = ledgerBody.indexOf('revealSelection(windowed)'); -assert('the re-judgement runs against the rows the table will actually draw', +const atReveal = ledgerBody.indexOf('revealSelection(matched)'); +assert('the re-judgement runs after the window is sliced', atMatched >= 0 && atSlice > atMatched && atReveal > atSlice); -assert('the selection is judged against the window, not the filter result', - /revealSelection\(windowed\)/.test(ledgerBody) && !/revealSelection\(matched\)/.test(ledgerBody)); +assert('the selection is judged against the filter result, not against the window', + /revealSelection\(matched\)/.test(ledgerBody) && !/revealSelection\(windowed\)/.test(ledgerBody)); + +// The tail is still a tail. D44 changed which list is judged; it did not freeze the window, and a +// guard that only checked the judgement would stay green through a version that quietly stopped +// sliding — which is the one thing that would make the window bar's 「更早的 K 条未加载」 a lie. +assert('the window is still a sliding tail slice, not a frozen prefix', + /var windowed = matched\.length > state\.limit\s*\n?\s*[\s\S]{0,80}?matched\.slice\(matched\.length - state\.limit\)/.test(ledgerBody)); // The poll path used to be the only caller, which is precisely why a local filter change — none of // which go through applyPayload — never touched it. @@ -184,10 +199,10 @@ assert('the rows on screen are built from the same list', // itself again, one layer further along. This is the half that is easy to miss, because the state // is correct after the judgement and only the DOM is stale. assert('the panel is redrawn when the selection is dropped', - /if\s*\(\s*revealSelection\(windowed\)\s*\)\s*renderInspector\(\);/.test(ledgerBody)); + /if\s*\(\s*revealSelection\(matched\)\s*\)\s*renderInspector\(\);/.test(ledgerBody)); console.log('[selection] ' + pass + ' passed, ' + failures.length + ' failed'); -for (const [label, run] of Object.entries({ gone, kept, belowWindow, none, empty })) { +for (const [label, run] of Object.entries({ gone, kept, slid, none, empty })) { if (run.error) console.log(' ' + label + ' threw: ' + run.error); } if (failures.length) { From dfa37321a9d196278b8a9438cb8e23f806bb720b Mon Sep 17 00:00:00 2001 From: avatasia Date: Tue, 13 Oct 2026 20:05:07 +0800 Subject: [PATCH 73/88] The session list's cache could not be hit even once LIST_CACHE_TTL_MS was 1000 while the page polls every 2500 ms, so the cache expired before the next tick arrived. Every poll re-walked the session tree, reopened the runtime database, and rebuilt the visible slice. Measured on the machine that reported it, that was most of the 16 GB this process read in 68 minutes, and the allocation behind it held the process at a 1.2-2.5 GB high-water mark for as long as the page stayed open. No guard asserted the number. A suite reporting 53 green guards could not see it, because a config value that silently disables the cache it belongs to is not a wrong answer to any question anybody had asked. Three changes in server.mjs: * LIST_CACHE_TTL_MS 1000 -> 3000. Against a 2500 ms poll this turns every-poll misses into every-other-poll, so the tree walk and the database open each happen half as often. * LIST_PEEK_BYTES 256 KB -> 64 KB, matching GENERATION_PROBE_BYTES. The generation marker lives on the first row, and the counters the list derives are approximate by design, so a narrower prefix costs accuracy nobody was treating as exact rather than correctness. * readSessionTitles had no cache at all. It reopens the runtime database on every call and the list route called it once per poll, so raising the walk's TTL alone would have left a 700 MB store reopened on a schedule regardless. It now shares the walk's budget, and dispose releases it. verify-list-cache.mjs (10) asserts the relation -- LIST_CACHE_TTL_MS >= POLL_INTERVAL, across two files -- rather than a literal, the same call made by D42's guards. The number is not the invariant; a cache shorter than the thing that invalidates it is. Asserting === 3000 would fail for a poll interval tuned down, which is an improvement, and would let this defect back the moment one was. Its other four rules cover what the first fix needs to be worth anything: the route reaching titles through the cache rather than the database, that cache existing at all, dispose releasing it, and the peek staying within a small multiple of the generation probe. mutate-list-cache-ttl.py (5) breaks each of them by name. One thing the original diagnosis got wrong, recorded because it changed the fix. The 256 KB peek was attributed to every poll, and it is not: descriptorCache already held it for 5000 ms, so it was re-reading every 5 s rather than every 2.5 s. What happened on every poll was the tree walk and the database open -- which is why the title cache, not the peek, is the larger half of this. Not done here: decoupling the poll itself. The list does not need refreshing every 2.5 s, and a 10 s TTL would cut the walk four times rather than two, but client-check.mjs:355 asserts POLL_INTERVAL = 2500, and that is a frontend behaviour change, which needs the four questions answered before code. Suite: 57/57. Fixtures unchanged. --- .../mmc-trajectory/miniapp/node/server.mjs | 47 ++++++- .../tests/mutate-list-cache-ttl.py | 118 ++++++++++++++++++ .../tests/verify-list-cache.mjs | 101 +++++++++++++++ 3 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 plugins/avatasia/mmc-trajectory/tests/mutate-list-cache-ttl.py create mode 100644 plugins/avatasia/mmc-trajectory/tests/verify-list-cache.mjs diff --git a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs index b436f37..eb4ce8a 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs +++ b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs @@ -28,13 +28,32 @@ const COMPACTION_TURN_SUFFIX = ':compaction'; // Enough of a file to read its opening line. A generation marker lives on the first row, so // deciding whether a snapshot belongs to the lineage never needs more than this. const GENERATION_PROBE_BYTES = 64 * 1024; -const LIST_PEEK_BYTES = 256 * 1024; +/** + * Bytes of `messages.jsonl` the session list reads to derive one label and one set of counters. + * + * Kept at the generation probe's budget on purpose: the generation marker lives on the first row, + * so a list that needed more than that to find one would be a list wanting the whole session. + * The counters this buys are approximate by design (see countRolesInPrefix), so a narrower prefix + * costs accuracy, not correctness — 256 KB was reading four times what the label needed, on every + * cache miss, for counts nobody treats as exact. + */ +const LIST_PEEK_BYTES = 64 * 1024; const MAX_FIELD_CHARS = 20000; const MAX_SUMMARY_CHARS = 160; const MAX_ARGUMENT_BYTES = 32000; const MAX_LABEL_CHARS = 60; const MAX_RAW_BYTES = 65536; -const LIST_CACHE_TTL_MS = 1000; +/** + * How long the session list may keep its scan of the store before walking it again. + * + * This has to stay at or above the page's poll interval, and that relationship — not this + * number — is the invariant tests/verify-list-cache.mjs holds. Below the interval the cache + * cannot survive a single poll: every tick expires it, so the list re-walks the session tree + * and reopens the runtime database on every tick. Measured on this machine, that was most of + * the 16 GB this process read in 68 minutes, and the allocation behind it held the process at + * a 1.2-2.5 GB high-water mark for as long as the page stayed open. + */ +const LIST_CACHE_TTL_MS = 3000; const SESSION_CACHE_TTL_MS = 5000; const SESSION_CACHE_ENTRIES = 80; const TRAJECTORY_CACHE_ENTRIES = 4; @@ -1845,6 +1864,26 @@ export async function start(context) { return entries; }; + /** + * Titles and kinds for the visible slice, cached on the same budget as the walk above. + * + * readSessionTitles opens the runtime database on every call, and the list route calls it once + * per poll for rows that only change when somebody renames a session. Raising the walk's TTL + * without this would still reopen a 700 MB store every 2.5 s forever, so the two caches answer + * the same poll or neither does. + * @type {{ at: number, key: string, map: Map }} + */ + let titleCache = { at: 0, key: '', map: new Map() }; + /** @param {readonly string[]} ids */ + const sessionTitles = async (ids) => { + const key = ids.join(''); + const now = Date.now(); + if (key === titleCache.key && now - titleCache.at < LIST_CACHE_TTL_MS) return titleCache.map; + const map = await readSessionTitles(location.root, ids); + titleCache = { at: now, key, map }; + return map; + }; + const describeSession = async (entry) => { const cacheKey = `${entry.dir}|${entry.sizeBytes}|${entry.lastActiveAt}`; const cached = descriptorCache.get(cacheKey); @@ -1956,8 +1995,7 @@ export async function start(context) { const visible = entries.slice(0, limit); // Real titles from the runtime database; sub-agent and cron sessions get a kind badge so // the picker never presents them as if they were the user's conversation. - const titles = await readSessionTitles( - location.root, + const titles = await sessionTitles( visible.map((entry) => decodeSessionIdFromDirName(basename(entry.dir))).filter(Boolean), ); for (const entry of visible) { @@ -2384,6 +2422,7 @@ export async function start(context) { if (disposal) return disposal; context.signal.removeEventListener('abort', onAbort); listCache = { at: 0, entries: null }; + titleCache = { at: 0, key: '', map: new Map() }; descriptorCache.clear(); trajectoryCache.clear(); // The parsed-row cache is the largest of the three and was the only one not released here, so a diff --git a/plugins/avatasia/mmc-trajectory/tests/mutate-list-cache-ttl.py b/plugins/avatasia/mmc-trajectory/tests/mutate-list-cache-ttl.py new file mode 100644 index 0000000..7ff13f1 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/tests/mutate-list-cache-ttl.py @@ -0,0 +1,118 @@ +"""Shorten the session list's cache below the poll interval and require verify-list-cache.mjs to +report each way it comes back by name. + +The defect is not a wrong number. It is a cache that outlives nothing: LIST_CACHE_TTL_MS was 1000 +while the page polls every 2500 ms, so the list could not be hit even once between two consecutive +polls. Every tick walked the session tree again and reopened the runtime database, and measured on +the machine that reported it that was most of the 16 GB the miniapp process read in 68 minutes. No +guard asserted the relationship, so a value that silently disables the cache it belongs to cost +nothing to introduce and nothing to notice. + +The mutations below are the ways back. Two of them (the TTL, the peek budget) are the original +defect and its quieter cousin; the other three are what has to survive for the first fix to mean +anything — a title read that still opens the database every poll is the same I/O wearing a new +constant, and a cache the dispose path forgets is the leak this project has already paid for once. + +Anchors are multi-line and server.mjs is LF, so they are written with bare \\n and the file is read +and written with newline='' — matching on normalised text while preserving the file's own endings. +No anchor may contain a line ending of its own: one did, and it stopped resolving the moment the +.gitattributes pinning LF landed. +""" +import io +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +PLUGIN = os.path.dirname(HERE) +SERVER = os.path.join(PLUGIN, "miniapp", "node", "server.mjs") +PROBE = os.path.join(HERE, "verify-list-cache.mjs") + +MUTATIONS = [ + # The defect verbatim: the cache is shorter than the interval that expires it, so it is never + # hit. Not a slow cache — a cache that cannot be reached. + ("the list TTL drops below the poll interval", + [("const LIST_CACHE_TTL_MS = 3000;", "const LIST_CACHE_TTL_MS = 1000;")], + "is at or above POLL_INTERVAL"), + + # The quieter cousin: the label and counters come from a prefix of messages.jsonl, and a + # generation marker lives on the first row. Reading far past it costs a full page of I/O on + # every miss to produce counts that were approximate to begin with. + ("the list peek grows past the generation probe", + [("const LIST_PEEK_BYTES = 64 * 1024;", "const LIST_PEEK_BYTES = 1024 * 1024;")], + "stays within 4x GENERATION_PROBE_BYTES"), + + # Without this the first fix buys nothing: readSessionTitles opens the runtime database on every + # call and the list route calls it once per poll, so the store keeps getting reopened even after + # the walk stopped repeating. + ("the titles read goes back to the database on every poll", + [("const titles = await sessionTitles(", + "const titles = await readSessionTitles(\n location.root,")], + "the list route reaches titles through the cache"), + + # ...and the cache that answers that poll is removed entirely. + ("the title cache is removed", + [(" let titleCache = { at: 0, key: '', map: new Map() };\n", "")], + "the titles read is cached rather than taken from the database on every poll"), + + # The parsed-row cache leaked here once — a disposed runtime held ~43 MB for a 39 MB session — + # and the line that fixed it is in server.mjs precisely so the next cache cannot repeat it. + ("dispose stops releasing the title cache", + [("titleCache = { at: 0, key: '', map: new Map() };\n descriptorCache.clear();", + "descriptorCache.clear();")], + "dispose releases the title cache"), +] + +original = io.open(SERVER, encoding="utf-8", newline="").read() + + +def run(): + proc = subprocess.run(["node", PROBE], cwd=HERE, + capture_output=True, text=True, encoding="utf-8", errors="replace") + return proc.returncode, proc.stdout + proc.stderr + + +ok = True +code, baseline = run() +base_ok = code == 0 and "0 failed" in baseline +print(("ok " if base_ok else "FAIL ") + "baseline green") +ok = ok and base_ok + +caught = [] +for title, edits, expect in MUTATIONS: + for old, new in edits: + hits = original.count(old) + if hits != 1: + raise SystemExit("FAIL anchor for %r matched %d times" % (title, hits)) + if old == new: + raise SystemExit("FAIL empty mutation for %r: the anchor equals its replacement" % title) + try: + mutated = original + for old, new in edits: + mutated = mutated.replace(old, new, 1) + if mutated == original: + raise SystemExit("FAIL mutation for %r changed nothing" % title) + io.open(SERVER, "w", encoding="utf-8", newline="").write(mutated) + during_code, during = run() + named = expect in during + red = during_code != 0 + # A guard that crashes is not a guard that caught anything: the rule has to be reported by + # name, or the mutation only proved that the file no longer parses. + print(("ok " if (named and red) else "FAIL ") + "guard catches: " + title) + if not named: + print(" no assertion reported %r" % expect) + for line in during.splitlines()[-4:]: + print(" " + line.strip()) + elif not red: + print(" the rule was reported but the guard still exited 0") + caught.append(named and red) + finally: + io.open(SERVER, "w", encoding="utf-8", newline="").write(original) + +restored = io.open(SERVER, encoding="utf-8", newline="").read() +after_code, after = run() +after_ok = after_code == 0 and "0 failed" in after +print(("ok " if restored == original else "FAIL ") + "server restored byte for byte") +print(("ok " if after_ok else "FAIL ") + "green again after restore") + +sys.exit(0 if (ok and all(caught) and restored == original and after_ok) else 1) diff --git a/plugins/avatasia/mmc-trajectory/tests/verify-list-cache.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-list-cache.mjs new file mode 100644 index 0000000..09fffc0 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/tests/verify-list-cache.mjs @@ -0,0 +1,101 @@ +/** + * The session list re-walked the store on every poll, and nothing was holding it to that. + * + * `LIST_CACHE_TTL_MS` was 1000 while the page polls every 2500 ms, so the cache could not + * survive a single tick: every poll expired it, and the list walked the session tree again, + * reopened the runtime database, and rebuilt the visible slice from scratch. Measured on the + * machine that reported it, that was most of the 16 GB the miniapp process read in 68 minutes, + * and the allocation behind it held the process at a 1.2-2.5 GB high-water mark for as long as + * the page stayed open. No existing guard asserted the number, which is why a config value that + * defeats the cache it belongs to survived every run of a suite that reports 52 green guards. + * + * The invariants, and what each one is for: + * + * 1. LIST_CACHE_TTL_MS >= POLL_INTERVAL. This is the fix. It is asserted as a relationship + * between two files rather than as a literal, because the number itself is not the + * invariant — a cache shorter than the interval that invalidates it is. Asserting + * `=== 3000` would let the same defect back the moment anybody tuned the poll interval, + * and would fail for a change that is actually an improvement. + * 2. the titles read is cached under that same budget. `readSessionTitles` opens the runtime + * database on every call and the list route calls it once per poll, so fixing (1) alone + * leaves a 700 MB store reopened every 2.5 s forever. The two caches answer one poll + * together or neither does. + * 3. the route reaches titles through that cache. Asserted as the identity of the callee + * rather than as the absence of a call, because the failure mode here is a second caller + * quietly reintroducing the per-poll open, and an absence-check passes the moment somebody + * renames the function. + * 4. dispose releases the title cache. The parsed-row cache once leaked here — a disposed + * runtime held ~43 MB for a 39 MB session — and the line that fixed it is in server.mjs + * precisely so the next cache cannot repeat the omission silently. + * 5. the list peek stays within a small multiple of the generation probe. A generation marker + * lives on the first row, so a prefix that cannot hold one is a prefix that wants the whole + * session. This bounds the read without pinning a byte count the list does not depend on: + * the counters it produces are approximate by design. + * + * `mutate-list-cache-ttl.py` breaks each of these in the shipped source and requires this guard to + * catch it by name, so nothing here can be an assertion nothing could fail. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const SERVER = fileURLToPath(new URL('../miniapp/node/server.mjs', import.meta.url)); +const HTML = fileURLToPath(new URL('../miniapp/client/index.html', import.meta.url)); +const server = readFileSync(SERVER, 'utf8'); +const html = readFileSync(HTML, 'utf8'); + +let pass = 0; +const failures = []; +function assert(name, cond) { + if (cond) pass += 1; + else failures.push(name); +} + +/* ---------- 1: the TTL outlives the poll that expires it ---------- */ + +const ttl = /const LIST_CACHE_TTL_MS\s*=\s*(\d+)/.exec(server); +const poll = /var POLL_INTERVAL\s*=\s*(\d+)/.exec(html); + +assert('the list TTL is still declared in the server', ttl !== null); +assert('the poll interval is still declared in the client', poll !== null); + +if (ttl && poll) { + assert('LIST_CACHE_TTL_MS (' + ttl[1] + ') is at or above POLL_INTERVAL (' + poll[1] + ') — a cache below the poll interval can never be hit', + Number(ttl[1]) >= Number(poll[1])); +} + +/* ---------- 2 & 3: one poll, one walk, one database open ---------- */ + +assert('the titles read is cached rather than taken from the database on every poll', + /let titleCache\s*=/.test(server) && /const sessionTitles\s*=\s*async/.test(server)); + +assert('the title cache shares the walk\'s budget, so both expire on the same poll', + /now - titleCache\.at < LIST_CACHE_TTL_MS/.test(server)); + +const routeTitles = /const titles = await (\w+)\(/.exec(server); +assert('the list route reaches titles through the cache', + routeTitles !== null && routeTitles[1] === 'sessionTitles'); + +/* ---------- 4: dispose releases what the poll built ---------- */ + +assert('dispose releases the title cache', + /titleCache = \{ at: 0, key: '', map: new Map\(\) \};[\s\S]{0,240}?descriptorCache\.clear\(\)/.test(server)); + +/* ---------- 5: the peek that backs the list is bounded ---------- */ + +const probe = /const GENERATION_PROBE_BYTES\s*=\s*(\d+)\s*\*\s*1024/.exec(server); +const peek = /const LIST_PEEK_BYTES\s*=\s*(\d+)\s*\*\s*1024/.exec(server); + +assert('the generation probe budget is still declared', probe !== null); +assert('the list peek budget is still declared', peek !== null); + +if (probe && peek) { + assert('LIST_PEEK_BYTES (' + peek[1] + ' KB-blocks) stays within 4x GENERATION_PROBE_BYTES (' + probe[1] + ') — the marker is on the first row', + Number(peek[1]) <= Number(probe[1]) * 4); +} + +console.log('[list-cache] ' + pass + ' passed, ' + failures.length + ' failed'); +if (failures.length) { + console.log('FAILURES:\n - ' + failures.join('\n - ')); + process.exit(1); +} +console.log('[list-cache] OK'); From 1bd056cb7075895e14090c3c292f481408dea46b Mon Sep 17 00:00:00 2001 From: avatasia Date: Tue, 13 Oct 2026 20:20:48 +0800 Subject: [PATCH 74/88] The two READMEs said 1.0.0 while the manifest said 1.1.0 The reviewer's report is right, and the reason it survived this long is worth recording: the guard for this rule read plugin.json and stopped there. It was checking the one field nobody complained about, and not either of the two files that were named. A rule shaped like that is the same shape as the sticky-unit defect earlier in this PR -- a list of three where the rule pinned one. So the assertion is not "the READMEs do not say 1.0.0". It is that each README carries exactly the version plugin.json declares. That form fails on the *next* bump too, which is the failure that was actually going to happen next: manifest moves to 1.2.0, READMEs stay behind, nothing goes red. Three assertions, three mutations. One mutation puts each README back to the old version; the third moves the manifest to 1.2.0 and leaves the READMEs alone, which is the case the equality form exists for and which the original rule could not have seen. verify-review16.mjs 29 -> 32. mutate-review16.py 24 -> 27, all caught. Suite 57/57. --- plugins/avatasia/mmc-trajectory/README.md | 2 +- .../avatasia/mmc-trajectory/README.zh-CN.md | 2 +- .../mmc-trajectory/tests/mutate-review16.py | 22 +++++++++++++++++++ .../mmc-trajectory/tests/verify-review16.mjs | 16 ++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/README.md b/plugins/avatasia/mmc-trajectory/README.md index 96b43af..46765fe 100644 --- a/plugins/avatasia/mmc-trajectory/README.md +++ b/plugins/avatasia/mmc-trajectory/README.md @@ -4,7 +4,7 @@ English | [简体中文](README.zh-CN.md) Browse the model trajectory of a MiniMax Code conversation: messages, reasoning, tool calls and their results, Token usage, and per-turn timing. -Author: [avatasia](https://github.com/avatasia) · Version: `1.0.0` +Author: [avatasia](https://github.com/avatasia) · Version: `1.1.0` ### Before you change this page diff --git a/plugins/avatasia/mmc-trajectory/README.zh-CN.md b/plugins/avatasia/mmc-trajectory/README.zh-CN.md index 682582d..cfe8634 100644 --- a/plugins/avatasia/mmc-trajectory/README.zh-CN.md +++ b/plugins/avatasia/mmc-trajectory/README.zh-CN.md @@ -4,7 +4,7 @@ 按轮次浏览一段 MiniMax Code 对话的模型轨迹:消息流、思考过程、工具调用与结果、token 消耗与每轮耗时。 -作者:[avatasia](https://github.com/avatasia) · 版本:`1.0.0` +作者:[avatasia](https://github.com/avatasia) · 版本:`1.1.0` ### 动手改这个页面前 diff --git a/plugins/avatasia/mmc-trajectory/tests/mutate-review16.py b/plugins/avatasia/mmc-trajectory/tests/mutate-review16.py index 7524c44..da8d185 100644 --- a/plugins/avatasia/mmc-trajectory/tests/mutate-review16.py +++ b/plugins/avatasia/mmc-trajectory/tests/mutate-review16.py @@ -169,6 +169,28 @@ '"version": "1.1.0",', '"version": "1.0.0",', "the plugin version was bumped off 1.0.0"), + + # The two the reviewer actually named. The mutation above only touches plugin.json, and it was + # the only one: the guard read plugin.json and called the rule done while both READMEs kept + # announcing 1.0.0 to every reader who opens the plugin's front page. A rule that checks the + # field nobody complained about is not the rule that was asked for. + ("the English README goes back to announcing the old version", + 'Version: `1.1.0`', + 'Version: `1.0.0`', + "the English README carries the shipped version"), + + ("the Chinese README goes back to announcing the old version", + '版本:`1.1.0`', + '版本:`1.0.0`', + "the Chinese README carries the shipped version"), + + # And the next bump, which is the one that would have happened next anyway: plugin.json moves + # on and the READMEs stay behind. Asserted as equality with plugin.json rather than as "not + # 1.0.0", so this is the case the rule is actually for. + ("the manifest moves on and the READMEs do not follow", + '"version": "1.1.0",', + '"version": "1.2.0",', + "the English README carries the shipped version"), ] diff --git a/plugins/avatasia/mmc-trajectory/tests/verify-review16.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-review16.mjs index ca491d7..efeafdc 100644 --- a/plugins/avatasia/mmc-trajectory/tests/verify-review16.mjs +++ b/plugins/avatasia/mmc-trajectory/tests/verify-review16.mjs @@ -193,6 +193,22 @@ assert('the plugin version was bumped off 1.0.0', plugin.version !== '1.0.0', `s assert('the plugin version is a valid semver minor bump', /^1\.[1-9]\d*\.\d+$/.test(plugin.version), plugin.version); +// The two READMEs were the actual complaint, and this block covered neither of them: it read +// `plugin.json` and called that done, while both READMEs still announced `1.0.0` to every reader +// who opens the plugin's front page. A rule that checks the field nobody complained about and not +// the two files that were named is the same shape as the D43 bug — a list of three where the rule +// pinned one. +// +// Asserted as *equality with plugin.json*, not as "not 1.0.0": the failure this prevents is the +// next bump, where plugin.json moves to 1.2.0 and the READMEs quietly stay behind again. +const versionTag = '`' + plugin.version + '`'; +assert('the English README carries the shipped version', + readme.includes('Version: ' + versionTag), `looking for Version: ${versionTag}`); +assert('the Chinese README carries the shipped version', + readmeZh.includes('版本:' + versionTag), `looking for 版本:${versionTag}`); +assert('neither README still announces the old version', + !/1\.0\.0/.test(readme) && !/1\.0\.0/.test(readmeZh)); + console.log('[review16] ' + pass + ' passed, ' + failures.length + ' failed'); if (failures.length) { console.log('FAILURES:\n - ' + failures.join('\n - ')); From 48461a78ddbde95ba34d59afe1194cd9ca5e8244 Mon Sep 17 00:00:00 2001 From: avatasia Date: Tue, 13 Oct 2026 21:47:19 +0800 Subject: [PATCH 75/88] A tool call in a delta kept a blank duration until a refresh A turn's durationMs and its result preview are written onto the record after it was first pushed, when the result message arrives. The copy that carries a new value is therefore an OLD record whose index the page has already seen, and the delta never contained it. Two causes, each run against the shipped functions to prove it rather than to reason about it. Server: buildDeltaPayload() cut at record granularity and took only records with index > since, so the record that gains a value was exactly the record that could not travel. It now cuts at turn granularity -- a turn with anything new after the cursor travels whole. A call and its result sit in the same turn, so the turn that gains a record is the turn that gets written back onto. A result that landed in a later turn than its call would still wait for a refresh; that is the one shape this does not cover. delta.records still counts what is NEW, not what travels, because three assertions pin it as the count of additions. The cut and the count are computed apart so the count does not inherit the cut. Client: mergeTurnRecords() read if (seen[added[i].index]) continue; which is a correct way to keep a delta from doubling a turn and an incorrect way to merge one: a delta replayed against a copy that already has a record must leave it unchanged, and it must also be allowed to change it. Those are different requirements and one line cannot serve both. It now replaces by index, which is still keyed by the same index, so replaying the same delta lays down the identical object rather than a second one. Measured on the live endpoint, cursor at 19880 of a 33491-record session: whole 65.4 MB, delta 27.4 MB, 97 records below the cursor travelling, that turn's 241 records all present, delta.records equal to the count above the cursor. Feeding the real delta through the real merge functions turns a null duration into 7738 ms with no duplicate index, and replaying it changes nothing. --- .../mmc-trajectory/miniapp/client/index.html | 51 ++++++++++++++++--- .../mmc-trajectory/miniapp/node/server.mjs | 42 +++++++++++---- 2 files changed, 77 insertions(+), 16 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index 9431f56..2a01946 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -4015,16 +4015,55 @@

检视

* records are unioned by index rather than appended, so a delta replayed against a * copy that already has it changes nothing instead of doubling a turn. */ + /** + * One turn's records plus whatever the incoming copy says about the turn itself. + * + * The turn-level fields are taken from the incoming copy rather than kept, because + * `closed` flips as the conversation moves on and the old copy still says `false`. The + * records are merged by index, and a record the page already has is **replaced** rather + * than skipped. + * + * Replacing is the whole point, and it is the second of two causes. A tool call's + * `durationMs` and its 「→ 结果预览」 are written onto the record after it was first + * pushed, when the result message arrives — so the copy that carries a new value is an old + * record with an index the page has already seen. This used to read + * + * if (seen[added[i].index]) continue; + * + * which is a correct way to keep a delta from doubling a turn and an incorrect way to + * merge one: a delta replayed against a copy that already has a record must leave it + * unchanged, and it must also be allowed to *change* it. Those are different requirements + * and one line cannot serve both. Running it — old record with `durationMs: null`, incoming + * copy with 1533 — left the reader looking at a call row with no time on it until they + * refreshed. + * + * Replacing is safe against the doubling this line used to prevent, because the incoming + * copy is keyed by the same index: laying the same delta down twice replaces a record with + * the identical object rather than adding a second one. `verify-incremental.mjs` asserts + * that replay is a fixed point. + * + * @param {any} existing + * @param {any} incoming + * @returns {any} + */ function mergeTurnRecords(existing, incoming) { - var records = Array.isArray(existing.records) ? existing.records.slice() : []; - var seen = {}; + var records = []; + var at = {}; var i; - for (i = 0; i < records.length; i += 1) seen[records[i].index] = true; + var kept = Array.isArray(existing.records) ? existing.records : []; + for (i = 0; i < kept.length; i += 1) { + at[kept[i].index] = records.length; + records.push(kept[i]); + } var added = Array.isArray(incoming.records) ? incoming.records : []; for (i = 0; i < added.length; i += 1) { - if (seen[added[i].index]) continue; - seen[added[i].index] = true; - records.push(added[i]); + var index = added[i].index; + if (at[index] === undefined) { + at[index] = records.length; + records.push(added[i]); + } else { + records[at[index]] = added[i]; + } } records.sort(function (a, b) { return a.index - b.index; }); var merged = {}; diff --git a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs index eb4ce8a..1d6be03 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs +++ b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs @@ -1603,11 +1603,26 @@ export function parseSince(raw) { * hundred bytes and answering it from the previous copy would make each payload depend on * a history the server does not keep. * - * A turn travels whole too, with only its `records` cut down. That repeats fields the page - * already has, deliberately: a turn's `closed` flips as the conversation moves on, and - * 无答复 is decided by it. A partial turn would leave the page holding a stale `closed` for - * the one turn that is still being written into — the turn where it decides whether rows - * are anomalies at all. + * A turn travels whole. Its turn-level fields were always sent whole — a turn's `closed` flips as + * the conversation moves on, and 无答复 is decided by it — but its `records` used to be cut down + * to the ones after the cursor, and that cut is what dropped a tool call's duration. + * + * `durationMs` and the result preview are written onto the call record *after* it was first + * pushed, when its result message arrives. So the record that gains a value is an OLD one, with + * an index below the cursor: it was never in the tail, and the row the reader was already looking + * at stayed blank until they refreshed. Measured by running both halves: with the cut in place + * the page holds `durationMs: null` while the server holds 1533. + * + * The fix is to cut at turn granularity — a turn with anything new after the cursor travels whole + * — and the cost is bounded rather than open-ended, because `?since=` never saved the server any + * work to begin with: the whole body is serialized either way for the cache entry and `/raw`. This + * only moves bytes on the wire and work into the page's `JSON.parse`. + * + * A call and its result sit in the SAME turn — a turn opens on a prompt and closes when the agent + * finishes responding, so the tool cycle is inside one span. That is what makes turn granularity + * sufficient rather than a narrowing: the turn that gains a record is the turn that gets written + * back onto. A result that landed in a later turn than its call would still wait for a refresh, + * and that is the one shape this does not cover. * * Returns null when a delta would not be a faithful answer; that is the caller's signal to * send the whole payload instead. It happens when the payload carries no records, and when @@ -1632,13 +1647,20 @@ export function buildDeltaPayload(payload, since) { } if (since > upTo) return null; const tail = []; + // `records` counts what is NEW, not what travels. A turn that ships whole with 300 records + // contributes 1 here. The page reads `delta.upTo` for its next cursor and never reads this + // number, but three guards pin it as the count of additions — and that is what it is, so the + // count and the cut are computed apart instead of the count inheriting the cut. let records = 0; for (const turn of turns) { - const kept = (turn && Array.isArray(turn.records) ? turn.records : []) - .filter((record) => typeof record.index === 'number' && record.index > since); - if (kept.length === 0) continue; - records += kept.length; - tail.push({ ...turn, records: kept }); + const all = turn && Array.isArray(turn.records) ? turn.records : []; + let fresh = 0; + for (const record of all) { + if (typeof record.index === 'number' && record.index > since) fresh += 1; + } + if (fresh === 0) continue; + records += fresh; + tail.push({ ...turn, records: all.slice() }); } return { session: payload.session, From b131f7847fca25b6f615787b0b4ae7fe06912304 Mon Sep 17 00:00:00 2001 From: avatasia Date: Tue, 13 Oct 2026 21:47:30 +0800 Subject: [PATCH 76/88] Pin the delta round-trip, and close a hole found while doing it Two assertions had to be rewritten rather than deleted, because the fix changed what they were about. The one reading "the tail from cursor 3 is a turn carrying one record" asserted records.length === 1, which is now the whole turn carrying its own records; the delta.records === 1 line above it is kept verbatim, because "ships a whole turn" and "counts one addition" have to hold at the same time. Two mutation anchors on `kept` no longer resolved after the rename, and were re-anchored to `fresh` and `all`, which carry the same decisions. A guard hole turned up while adding to the same file. The block asserting that turns which grew nothing are absent from the tail asserted once and then broke out of its loop as soon as it found the first such turn, so a mutation that made every turn grow ran zero assertions in that block and passed. The precondition is now about the fixture instead of about the delta -- a precondition that the thing it guards can break is not a precondition -- and one assertion covers every untouched turn, printing the leaked ids in the detail. verify-incremental.mjs 63 -> 71 checks. mutate-incremental.py 16 -> 19 mutations, all caught. --- .../tests/mutate-incremental.py | 29 ++++- .../tests/verify-incremental.mjs | 102 +++++++++++++++--- 2 files changed, 116 insertions(+), 15 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/tests/mutate-incremental.py b/plugins/avatasia/mmc-trajectory/tests/mutate-incremental.py index 9c77bf1..ab17e27 100644 --- a/plugins/avatasia/mmc-trajectory/tests/mutate-incremental.py +++ b/plugins/avatasia/mmc-trajectory/tests/mutate-incremental.py @@ -37,7 +37,7 @@ " if (at[incoming.id] === undefined) {", " if (true) {"), ("replaying a tail duplicates the records it already had", "replaying the same tail changes nothing", - " if (seen[added[i].index]) continue;", " if (false) continue;"), + " if (at[index] === undefined) {", " if (true) {"), ("the merge keeps the turn fields it already had instead of the fresh ones", "the merge takes the closed flag from the tail, not from the copy it already had", " if (Object.prototype.hasOwnProperty.call(incoming, key)) merged[key] = incoming[key];", @@ -63,10 +63,10 @@ " if (value < 0) return null;"), ("a turn that gained nothing is sent anyway", "a turn that gained no records is absent from the tail", - " if (kept.length === 0) continue;", " // every turn, gained or not"), + " if (fresh === 0) continue;", " // every turn, gained or not"), ("a tail turn travels only its records and loses its identity", "the merge takes the closed flag from the tail, not from the copy it already had", - " tail.push({ ...turn, records: kept });", " tail.push({ records: kept });"), + " tail.push({ ...turn, records: all.slice() });", " tail.push({ records: all.slice() });"), ("stats are thinned down to one counter on the way out", "stats travel whole, because a tail cannot be added to an accumulator", " stats: payload.stats,", " stats: { messages: (payload.stats || {}).messages },"), @@ -80,6 +80,29 @@ "the server cuts a tail only where it has just built the payload", " const delta = since === null ? null : buildDeltaPayload(payload, since);", " const delta = buildDeltaPayload(payload, since || 0);"), + + # ---- the cut moves back to record granularity ---- + # This is the reported defect. The count is what decides *whether* a turn travels, and + # re-applying the filter to what travels undoes the whole point: the record that gained a + # duration has an index below the cursor, so a filter puts it back out of the tail. Both of + # these keep `delta.records` correct, which is why the count is asserted apart from the cut. + ("the cut goes back to per record, so the rewritten record is not sent", + "the tail carries the old record that just gained a duration", + " tail.push({ ...turn, records: all.slice() });", + " tail.push({ ...turn, records: all.filter((r) => typeof r.index === 'number' && r.index > since) });"), + ("a whole turn ships and the count inherits the whole turn instead of the additions", + "while still counting only what is new", + " records += fresh;", " records += all.length;"), +] + +# The page side. A delta replayed against a copy that already has a record must be allowed to +# CHANGE it, and `continue` is the line that stops it — the same line that used to keep a replay +# from doubling a turn. Both jobs cannot live in one line, and replacing by index does both. +CLIENT_CASES += [ + ("the page goes back to skipping a record it already has", + "a record the page already had takes the incoming copy, not the stale one", + " } else {\n records[at[index]] = added[i];\n }\n", + " } else {\n continue;\n }\n"), ] diff --git a/plugins/avatasia/mmc-trajectory/tests/verify-incremental.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-incremental.mjs index ff09557..c38b439 100644 --- a/plugins/avatasia/mmc-trajectory/tests/verify-incremental.mjs +++ b/plugins/avatasia/mmc-trajectory/tests/verify-incremental.mjs @@ -126,17 +126,29 @@ assert('stats travel whole, because a tail cannot be added to an accumulator', assert('and so does session', JSON.stringify(delta.session) === JSON.stringify(full.session)); // A turn that gained nothing is not in the tail at all. +// +// This block used to walk `full.turns`, assert on the FIRST turn that gained nothing, and break. +// That is the same defect twice over: it checked one turn instead of all of them, and when the +// mutation under test made every turn appear to have gained something, the loop found nothing to +// check and the whole block passed **without running a single assertion**. A guard that checks +// nothing when the thing it guards is broken is not a guard — so the precondition is asserted +// first, and every untouched turn is checked rather than the first one. { - const gained = new Set(); - for (const turn of delta.turns) for (const record of turn.records) gained.add(record.index); - for (const turn of full.turns) { - const any = turn.records.some((record) => gained.has(record.index)); - if (!any) { - assert('a turn that gained no records is absent from the tail', - !delta.turns.some((incoming) => incoming.id === turn.id)); - break; - } - } + // The precondition is a fact about the FIXTURE and the cursor, not about the delta — computing + // it from `delta` made it depend on the code under test, so the mutation that sends every turn + // made the precondition fail and the real assertion below never ran. A precondition that can be + // broken by the thing it guards is not a precondition. + const untouched = full.turns.filter((turn) => + !turn.records.some((record) => typeof record.index === 'number' && record.index > CUT)); + assert('the fixture has a turn that gained nothing, or this block would check nothing', + untouched.length > 0, `turns=${full.turns.length} untouched=${untouched.length}`); + // One assertion covering every untouched turn, with the offenders named in the detail. Labelling + // each one separately read better in the output but gave the assertion a different name per + // turn, which a mutation harness matching on the name cannot use. + const present = untouched.filter((turn) => delta.turns.some((incoming) => incoming.id === turn.id)); + assert('a turn that gained no records is absent from the tail', + present.length === 0, + present.length ? `leaked: ${present.map((turn) => turn.id).join(', ')}` : `${untouched.length} checked`); } // A turn travels whole, with only its records cut down. `closed` in particular: the live turn @@ -262,9 +274,15 @@ assert('the cursor of an empty payload is zero, not NaN', const tail = buildDeltaPayload(after, 3); assert('the tail from cursor 3 is one turn with one record', tail !== null && tail.turns.length === 1 && tail.delta.records === 1); - assert('and that turn is t2, closed, carrying only r4', + // The cut used to be per record, so this turn carried r4 alone. It travels whole now — see the + // block below for why — and the rewrite keeps the shape of the claim while pointing it at the + // whole turn. `delta.records` on the line above is the other half: the turn grew by four bytes + // of bookkeeping and by exactly one record, and those two numbers stopped agreeing when the + // cut moved. They have to be computed apart. + assert('and that turn is t2, closed, carrying its own records whole', tail.turns[0].id === 't2' && tail.turns[0].closed === true - && tail.turns[0].records.length === 1 && tail.turns[0].records[0].id === 'r4'); + && tail.turns[0].records.length === 2 + && tail.turns[0].records[0].id === 'r3' && tail.turns[0].records[1].id === 'r4'); assert('and the untouched turn t1 is not in the tail at all', !tail.turns.some((turn) => turn.id === 't1')); @@ -280,6 +298,66 @@ assert('the cursor of an empty payload is zero, not NaN', assert('and the merge of before+tail equals after', JSON.stringify(merged) === JSON.stringify(after)); } +/** + * The second cause, and the reason the cut moved to turn granularity. + * + * A tool call's `durationMs` and its 「→ 结果预览」 are written onto the record AFTER it was first + * pushed — the value appears when its result message arrives, on a record whose index is well + * below the cursor. So the copy carrying a new value is an OLD record the page already has, and + * two separate things have to hold for that value to reach a reader's screen: + * + * 1. the server has to send that record, which means cutting at turn granularity rather than + * record granularity — the record's own index will never be above the cursor; + * 2. the page has to take the incoming copy instead of skipping it, which means replacing by + * index rather than `continue`. + * + * The first version of this protocol broke both, and the symptom was a call row with no time on + * it until the reader refreshed. Nothing here is about idempotence: replaying a delta is still a + * fixed point (asserted above and again below), because the replacement is keyed by the same + * index, so the same record replaces itself with an identical object. + */ +{ + const call = { + id: 'r10', index: 10, kind: 'toolCall', title: 'read', summary: '读取文件', durationMs: null, + }; + const fresh = { + id: 'r11', index: 11, kind: 'assistant', title: 'reply', summary: '好的', durationMs: null, + }; + const held = { + session: { id: 's1' }, stats: { records: 1 }, + turns: [{ id: 't9', closed: false, records: [call] }], + }; + // What the server holds once the result message has been parsed. + const rewritten = { + session: { id: 's1' }, stats: { records: 2 }, + turns: [{ + id: 't9', + closed: false, + records: [{ ...call, durationMs: 1533, summary: '读取文件 → file contents' }, fresh], + }], + }; + + const tail = buildDeltaPayload(rewritten, 10); + assert('the tail carries the old record that just gained a duration', + tail !== null && tail.turns.length === 1 + && tail.turns[0].records.some((record) => record.index === 10)); + assert('while still counting only what is new', + tail !== null && tail.delta.records === 1, `records=${tail && tail.delta.records}`); + + const merged = page.mergeDelta(held, tail); + const onScreen = merged.turns[0].records.find((record) => record.index === 10); + assert('a record the page already had takes the incoming copy, not the stale one', + onScreen.durationMs === 1533, `durationMs=${onScreen.durationMs}`); + assert('and the result preview arrives with it', + onScreen.summary === '读取文件 → file contents', JSON.stringify(onScreen.summary)); + assert('the new record is there too', + merged.turns[0].records.some((record) => record.index === 11)); + assert('and no record was doubled', + merged.turns[0].records.length === 2, `records=${merged.turns[0].records.length}`); + assert('laying the same delta down again changes nothing', + JSON.stringify(page.mergeDelta(merged, tail)) === JSON.stringify(merged)); +} + // --------------------------------------------------------------- the wiring // A cursor is only ever asked alongside the validator it was taken against. Sending one From c7682ee3f8ca8ffd2ace500b502a01ff76a77102 Mon Sep 17 00:00:00 2001 From: avatasia Date: Wed, 14 Oct 2026 00:09:52 +0800 Subject: [PATCH 77/88] Keep the inspector's resize grip where a grip can be reached The reported symptom was that the grip scrolls away once the panel is scrolled. Measured at 900x780 on a real session, that is true and it is the milder half: the grip could not be reached at ANY scroll position, including scrollTop 0. scrollTop=0 overlap with the panel 8px, 45 of 45 sampled points hit the header scrollTop=174 overlap 0px scrollTop=349 overlap 0px Two independent causes, either of which is enough on its own. It was positioned absolutely inside the panel, which is the scroll container, so it travelled with the content and left the scrollport on the first scroll. `position: sticky` pins a box to the scrollport instead, which is the thing a grip has to survive. Sticky puts the strip back in the flow, so `margin-bottom: -14px` takes back exactly the height it now costs; the panel's scrollHeight measured 1007 before and after, and the header sits 1px inside the panel border before and after. And it was painted under `.inspector-chrome`, which is sticky at the same edge with an opaque background at z-index 2, while the grip was at z-index 1. That is the half that survived scrollTop 0: of the 8px where they overlapped, every sampled point hit the header, so the reader could not drag the panel taller at all. The grip is now z-index 3. `left`/`right` went with the absolute positioning. They never applied -- a block-level box in flow sizes itself -- and keeping them would read as "this is how wide it is" when it is not. Dragging through the browser's own input pipeline at scrollTop 697 grows the panel 310 -> 382, and at all five sampled scroll positions 45 of 45 points on the strip's visible part hit the grip. --- .../mmc-trajectory/miniapp/client/index.html | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index 2a01946..be95c7d 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -2411,14 +2411,36 @@ script says, and the grip keeps a 0x0 box no matter how correctly the state is decided. */ #inspector-panel .inspector-resize { display: none; - position: absolute; - left: 0; - right: 0; + /* Sticky to the PANEL's own scrollport, not the page's — the panel is the scroll + container, so `top` is measured against it. This used to be `position: absolute`, + which put the strip in the flow's coordinate system but not the flow itself: inside a + scrolling box an absolutely positioned child travels WITH the content, so the grip + left the scrollport on the first scroll and there was nothing left to grab. Measured + 2026-10-13 at 900x780: at scrollTop 174 the grip's overlap with the panel was already + 0px, and at 697 it sat 235px above the panel entirely. + + `left`/`right` went with it. They never applied — a block-level box in flow sizes + itself — and keeping them would read as "this is how wide it is" when it is not. */ + position: sticky; top: -7px; height: 14px; + /* Sticky puts the strip back IN the flow, which costs 14px the panel did not used to + pay. Take exactly that back, so the panel's internal layout — and its scrollHeight, + which is what a reader scrolls to the end of — is unchanged. The same number as + `height`, and it has to stay that number: a strip taller than its own negative margin + pushes the chrome down by the difference, and a shorter one leaves a seam. */ + margin-bottom: -14px; cursor: ns-resize; touch-action: none; - z-index: 1; + /* Above `.inspector-chrome` (z-index 2), and this is not cosmetic — it is the second of + two reasons the grip was unusable, and the one that survived scrollTop 0. The chrome is + sticky at the panel's top edge with an opaque background, and the strip lives on that + same edge: of the 8px where the two overlapped, 45 of 45 sampled points hit the header + and none hit the grip. So the reader could not drag the panel taller on ANY scroll + position, not only after scrolling. Same family as D27 (a `display: none` that put the + grip outside the hit tree): every assertion checked that a property appeared in some + rule, and none asked whether a later sibling could cover the result. */ + z-index: 3; } /* The one thing that turns the grip on, and the only one. `hidden` is written by From e5c200ffa45075e178e0a47dc1156b8215864f9c Mon Sep 17 00:00:00 2001 From: avatasia Date: Wed, 14 Oct 2026 00:10:06 +0800 Subject: [PATCH 78/88] Ask whether the grip can be reached, not whether it has properties The grip had `position`, `z-index`, `touch-action`, `display` and a `top`, and this file pinned all five. Every one was present and every one was correct in isolation, and the reader still could not drag the panel taller. That is D27's shape one layer down: a guard that checks a property appears in a rule cannot see a later rule undo it. Four assertions, each aimed at a different way this could come back: - the grip is `position: sticky`, and EXACTLY ONE rule positions it. The enumeration is what makes the second half meaningful, and the pseudo-elements are excluded from it deliberately rather than by accident: `.inspector-resize::after` is the visible hairline and legitimately carries `position: absolute` against the strip, which is the one thing the strip itself must not be. - the grip's z-index is above `.inspector-chrome`'s. - the negative margin equals minus the strip's height, compared as two numbers rather than against a constant, so growing the strip and forgetting the margin is caught too. The z-index assertion was wrong on its first run and the mutation found it: both values were read out of the narrow media block, but the chrome rule lives in the base stylesheet, so it read null, `Number(null)` is 0, and `3 > 0` was true whatever the grip said. Two values from two blocks written under one variable name -- the smallest possible instance of an assertion that is wider than it looks. verify-sticky.mjs gains the grip in `pinnedStacks`, which is the enumeration of pinned strips: a list that quietly omits one member is how a z-index 1 grip shipped under a z-index 2 header. Because an enumeration cannot test its own completeness, mutate-sticky.py gets its own mutation for it -- raising the header's z-index -- rather than reusing the one that lowers the grip's. verify-inspector-dock.mjs 135 -> 139 checks. mutate-inspector-dock.py 129 -> 133 mutations, all caught. verify-sticky.mjs 28 -> 29 checks. mutate-sticky.py 20 -> 21 mutations, all caught. --- .../tests/mutate-inspector-dock.py | 42 ++++++++++++++ .../mmc-trajectory/tests/mutate-sticky.py | 11 ++++ .../tests/verify-inspector-dock.mjs | 58 +++++++++++++++++++ .../mmc-trajectory/tests/verify-sticky.mjs | 17 +++++- 4 files changed, 126 insertions(+), 2 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/tests/mutate-inspector-dock.py b/plugins/avatasia/mmc-trajectory/tests/mutate-inspector-dock.py index 894dbab..18cde86 100644 --- a/plugins/avatasia/mmc-trajectory/tests/mutate-inspector-dock.py +++ b/plugins/avatasia/mmc-trajectory/tests/mutate-inspector-dock.py @@ -388,6 +388,48 @@ " #inspector-panel .inspector-resize.is-open {\n", "the grip is shown by removing the very attribute that hides it"), + # review #5. Three causes, three mutations, and each one leaves the file looking like a grip + # that works. Measured at 900x780 before the fix: overlap with the panel 8px at scrollTop 0 and + # 0px by scrollTop 174, and of the 8px that was left, 45 of 45 sampled points hit the header. + # So the reader could not resize on ANY scroll position — the report said 「scrolled away」, + # which is true and is the milder half. + # + # This first one is the shape the guard file kept missing twice (D27 and now this): every + # property is present and every property is correct in isolation. Nothing about the declarations + # says the strip travels with the content, because that is not a property — it is what + # `position: absolute` means INSIDE a scrolling box. + ("the grip is carried along by the panel's content instead of pinned to it", + " position: sticky;\n top: -7px;\n", + " position: absolute;\n left: 0;\n right: 0;\n top: -7px;\n", + "the grip is pinned to the panel scrollport, not carried along by its content"), + + # The second cause, and the one that survived scrollTop 0. `.inspector-chrome` is sticky at the + # panel's top edge with an opaque background at z-index 2; the grip sat below it. Every + # declaration still said what it was supposed to say. + ("the grip is painted under the header it shares an edge with", + " z-index: 3;\n", + " z-index: 1;\n", + "the grip paints above the header it shares an edge with"), + + # The third: sticky puts the strip back in the flow, and the panel did not used to pay for it. + # Dropping the negative margin is not a subtle layout drift, it is 14px of chrome pushed down — + # and every other assertion in this file still passes, because none of them ever asked where the + # header starts. + ("the strip's own height is no longer paid back in the flow", + " margin-bottom: -14px;\n", + "", + "the negative margin cancels the height the strip now takes in the flow"), + + # …and the other direction of the same pair: the height changes and the margin does not. The + # guard compares the two numbers to each other rather than to a constant, so a taller strip with + # a stale margin is caught too. The anchor is the two adjacent declarations above the comment + # that explains them — `height` and `margin-bottom` are not adjacent in the file, and an anchor + # spanning the comment matches nothing. + ("the strip gets taller and its negative margin is left behind", + " top: -7px;\n height: 14px;\n", + " top: -7px;\n height: 20px;\n", + "the negative margin cancels the height the strip now takes in the flow"), + # D28. The share drifts back to a quarter: the two numeric assertions stop agreeing with each # other only if both are read, and the stylesheet fallback is what catches it, because 40vh no # longer describes the same promise as INSPECTOR_DOCK_SHARE. diff --git a/plugins/avatasia/mmc-trajectory/tests/mutate-sticky.py b/plugins/avatasia/mmc-trajectory/tests/mutate-sticky.py index 13ab30b..781ae82 100644 --- a/plugins/avatasia/mmc-trajectory/tests/mutate-sticky.py +++ b/plugins/avatasia/mmc-trajectory/tests/mutate-sticky.py @@ -97,6 +97,17 @@ " z-index: 2;\n background: var(--mcode-bg);\n", "every pinned strip has something scrolling into its seam"), + # The same rule, the other way round, on the pair that was missing from the list until the grip + # stopped being `position: absolute`. The header is opaque and sticky at the panel's top edge, + # so climbing it over the grip does not move anything a reader can see — it just makes the grip + # unreachable: 45 of 45 sampled points on the 8px they shared hit the header. `verify-inspector-dock.mjs` + # guards the same pair from the grip's side; this one proves the list in THIS file can fail, + # because a list of pairs cannot test its own completeness. + ("the inspector's header climbs above the grip it covers", + " #inspector-panel > .inspector-chrome {\n position: sticky;\n top: 0;\n z-index: 2;\n", + " #inspector-panel > .inspector-chrome {\n position: sticky;\n top: 0;\n z-index: 4;\n", + "every pinned strip has something scrolling into its seam"), + ("the window bar goes transparent", " z-index: 3;\n padding: 8px 16px;\n background: var(--mcode-bg);\n border-bottom: 1px solid var(--mcode-border);\n }\n", " z-index: 3;\n padding: 8px 16px;\n border-bottom: 1px solid var(--mcode-border);\n }\n", diff --git a/plugins/avatasia/mmc-trajectory/tests/verify-inspector-dock.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-inspector-dock.mjs index bc1c3a1..d136b14 100644 --- a/plugins/avatasia/mmc-trajectory/tests/verify-inspector-dock.mjs +++ b/plugins/avatasia/mmc-trajectory/tests/verify-inspector-dock.mjs @@ -1133,6 +1133,64 @@ assert('the grip is offered exactly where there is a height to drag', assert('pinned means both docked and expanded, so the grip survives expanding', !!applyFn && /var\s+docked\s*=\s*state\.inspectorDock\s*!==\s*'flow'/.test(applyFn)); +// review #5. The grip used to ship `position: absolute` on the panel's top edge, and every +// assertion above still passed — the same shape as D27 one layer down. Inside a scrolling box an +// absolutely positioned child travels WITH the content, so the strip left the scrollport on the +// first scroll: measured at 900x780, its overlap with the panel was 8px at scrollTop 0 and 0px by +// scrollTop 174. `sticky` is what pins a box to the SCROLLPORT instead, which is the thing a grip +// has to survive. +// +// Not D45. That number is already taken by the endpoint-count mismatch recorded in docs/LAYERS.md, +// and a defect number that points at two different things is a number that will be cited wrong. +assert('the grip is pinned to the panel scrollport, not carried along by its content', + narrow ? valueIn(narrow, '#inspector-panel .inspector-resize', 'position') === 'sticky' : false, + narrow ? `position: ${valueIn(narrow, '#inspector-panel .inspector-resize', 'position')}` : 'no narrow block'); + +// …and it has to be pinned there BY ONE RULE. The D27 lesson is that a later rule can undo the one +// keeping a property, so reading the base rule is not enough: enumerate every `position` that can +// land on the grip and require it to be exactly the one value. Same shape as the display-pair +// enumeration at the end of this section. +// +// The pseudo-elements are excluded, and deliberately so rather than by accident: `.inspector-resize::after` +// is the visible hairline and it legitimately carries `position: absolute` against the strip, which is +// the one thing the strip itself must not be. Counting it would make this assertion demand the strip +// lose the thing that draws it. +const gripPositions = [...(narrow || '').matchAll(/^([^{}@][^{}]*?)\{([^{}]*)\}/gm)] + .filter((m) => m[1].split(',').map((s) => s.trim()).some((s) => + s.includes('.inspector-resize') && !s.includes('::'))) + .map((m) => (/^[ \t]*position\s*:\s*([^;]+)/m.exec(m[2]) || [, ''])[1].trim()) + .filter(Boolean); +assert('exactly one rule positions the grip itself, and it is the one that pins it', + gripPositions.length === 1 && gripPositions[0] === 'sticky', + JSON.stringify(gripPositions)); + +// The second cause, and the one that outlived scrollTop 0. The grip shares its edge with +// `.inspector-chrome`, which is sticky at the panel's top with an opaque background at z-index 2. +// The grip sat at z-index 1, so of the 8px where the two overlapped, 45 of 45 sampled points hit +// the header: the reader could not drag the panel taller on ANY scroll position, not only after +// scrolling. Same family as D27 — the properties were all present and all correct in isolation. +// +// The chrome is read out of `css` and not out of `narrow`, and that is not tidiness. The chrome rule +// lives in the base stylesheet and the grip rule lives in the dock query, so reading both from +// `narrow` finds nothing for the chrome, `Number(null)` is 0, and `3 > 0` is true whatever the grip +// says — an assertion that cannot fail, produced by writing the same variable twice. The first +// version of this line did exactly that and caught none of the mutations below. +const gripZ = narrow ? Number(valueIn(narrow, '#inspector-panel .inspector-resize', 'z-index')) : NaN; +const chromeZ = Number(valueIn(css, '#inspector-panel > .inspector-chrome', 'z-index')); +assert('the grip paints above the header it shares an edge with, so it can be reached', + Number.isFinite(gripZ) && Number.isFinite(chromeZ) && gripZ > chromeZ, + `grip z-index ${gripZ}, chrome z-index ${chromeZ}`); + +// Sticky puts the strip back IN the flow, which costs height the panel did not used to pay, so the +// margin has to take exactly that back. Two numbers that have to be one rule: change `height` +// without changing the margin and the chrome is pushed down by the difference, which is a layout +// change nobody asked for and no assertion would otherwise catch. +const gripHeight = narrow ? parseFloat(valueIn(narrow, '#inspector-panel .inspector-resize', 'height')) : NaN; +const gripMargin = narrow ? parseFloat(valueIn(narrow, '#inspector-panel .inspector-resize', 'margin-bottom')) : NaN; +assert('the negative margin cancels the height the strip now takes in the flow', + Number.isFinite(gripHeight) && Number.isFinite(gripMargin) && gripMargin === -gripHeight, + `height ${gripHeight}px, margin-bottom ${gripMargin}px`); + /* ---------- D34: the third height, and the one that was missing ---------- * * the reviewer: 「不是三态么?- 折叠到最小 这个按钮没做。」 diff --git a/plugins/avatasia/mmc-trajectory/tests/verify-sticky.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-sticky.mjs index d1ff428..1ff641e 100644 --- a/plugins/avatasia/mmc-trajectory/tests/verify-sticky.mjs +++ b/plugins/avatasia/mmc-trajectory/tests/verify-sticky.mjs @@ -153,10 +153,23 @@ assert('the turn head is opaque', isOpaqueBackground(value('.turn-head', 'backgr // The strip is the only pinned box here that sits directly on top of another one. That is what // makes its own background load-bearing rather than cosmetic: everything below it in the chain // scrolls into its seam, so an opaque sibling cannot cover for it. -const pinnedStacks = [['.overview', '.window-bar'], ['.window-bar', '.turn-head']]; +// +// The inspector's grip joined this list when it stopped being `position: absolute` and became +// sticky to the panel's own scrollport. It belongs here rather than only in +// `verify-inspector-dock.mjs` because this list is the ENUMERATION of pinned strips, and a list +// that quietly omits one member is how the grip shipped with z-index 1 under a z-index 2 header: +// 45 of 45 sampled points on the 8px where they overlapped hit the header, at every scroll +// position including 0. The rule was already written down in this file — just not applied to it. +const pinnedStacks = [ + ['.overview', '.window-bar'], + ['.window-bar', '.turn-head'], + ['#inspector-panel .inspector-resize', '#inspector-panel > .inspector-chrome'], +]; assert('every pinned strip has something scrolling into its seam', pinnedStacks.every(([above, below]) => - Number(value(above, 'z-index')) > Number(value(below, 'z-index')))); + Number(value(above, 'z-index')) > Number(value(below, 'z-index'))), + JSON.stringify(pinnedStacks.map(([a, b]) => + [value(a, 'z-index'), value(b, 'z-index')]))); /* ---------- CSS: the offsets come from variables, and chain down the page ---------- */ From cf8360baaf5851efee0da416e1f268a9b3915b37 Mon Sep 17 00:00:00 2001 From: avatasia Date: Wed, 14 Oct 2026 00:23:04 +0800 Subject: [PATCH 79/88] Stop offering a 64 KB prefix as a session''s message count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session picker offered a reader 「(3 分钟前 · 10 条)」 for a session with 23,642 messages. Reconciling the two endpoints field by field for the same session: /api/sessions messageCount 10 turnCount 2 sizeBytes 1037810 /api/trajectory messageCount 23642 turnCount 232 sizeBytes 192012043 Neither endpoint is wrong, which is the part worth recording. The trajectory's numbers are the sum over all 42 generations -- 23,642 and 192,012,043 both check out as exact sums of the per-generation figures. The list's sizeBytes is exactly the active generation's byteLength. So the two differ in SCOPE (whole lineage, versus the active file alone) and the message count differs in PREFIX as well: /api/sessions counts roles in the first 64 KB of messages.jsonl, and this session's active file is 1 MB. The field names are what made it a defect rather than a footnote, because the page then printed the prefix as the session's size. And the constant carrying the comment 「for counts nobody treats as exact」 had become false: something treated them as exact, and it was this. Counting exactly is not on the table -- verify-list-cache.mjs pins the peek to within 4x the generation probe's budget, which is the right constraint and stays. So the prefix stays, and its counters are now only reported when the prefix WAS the whole file. A bounded-but-reported count was the defect; absent is the only other answer available at that budget. Both fields are gated together, because they come out of the same prefix and fixing one would leave the same untrue number under a second name. The flag defaults to false, so a read that threw reports no count rather than zero -- a zero is the same untrue number with a smaller magnitude. On the page, a missing count omits the whole segment instead of printing a placeholder, because 「— 条」 renders the absence of a number in the shape of a number. Label and recency stay, which is what the picker is for. Measured on the live page: of 21 options, 4 still carry an exact count (their active file fits in the budget) and 17 carry none, no option shows a placeholder, and every printed number matches what the endpoint sent. --- .../mmc-trajectory/miniapp/client/index.html | 13 +++++++++- .../mmc-trajectory/miniapp/node/server.mjs | 25 ++++++++++++++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index be95c7d..e603bd6 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -4183,10 +4183,21 @@

检视

/* ---------------- rendering: session select ---------------- */ + /* The count is omitted whole when there is none, rather than printed as a dash. + * + * `/api/sessions` reads the first 64 KB of the active `messages.jsonl` to build this list, so + * it used to send a message count that was really 「how many messages are in the first 64 KB」. + * On a session whose active file is 1 MB that is 10 against 23,642 — and this line put it in + * front of the reader as `(3 分钟前 · 10 条)`, with nothing to say it was a prefix. It now + * sends no count when the prefix was not the whole file, so `— 条` here would be printing the + * absence of a number in the shape of a number. Omitting the segment is the honest rendering + * of 「这个数不存在」: the reader still has the label and the recency, which is what the picker + * is for. See docs/LAYERS.md §21. */ function sessionOptionLabel(session, now) { var label = truncateText(session && session.label ? session.label : '', 42); var when = formatAgo(session ? session.lastActiveAt : null, now); - var tail = '(' + when + ' · ' + formatInt(session ? session.messageCount : null) + ' 条)'; + var count = session ? session.messageCount : null; + var tail = isCount(count) ? '(' + when + ' · ' + formatInt(count) + ' 条)' : '(' + when + ')'; if (!label) return '未命名会话 ' + tail; return label + ' ' + tail; } diff --git a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs index 1d6be03..040c5c2 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs +++ b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs @@ -33,9 +33,18 @@ const GENERATION_PROBE_BYTES = 64 * 1024; * * Kept at the generation probe's budget on purpose: the generation marker lives on the first row, * so a list that needed more than that to find one would be a list wanting the whole session. + * * The counters this buys are approximate by design (see countRolesInPrefix), so a narrower prefix * costs accuracy, not correctness — 256 KB was reading four times what the label needed, on every - * cache miss, for counts nobody treats as exact. + * cache miss. + * + * The last clause used to end "for counts nobody treats as exact", and that is what D45 found + * false: the page printed the message count in the session picker (`(3 分钟前 · 10 条)`), so it + * was treated as exact. On a session whose active file is 1 MB, the first 64 KB held 10 messages + * out of 23,642 — a number that was wrong by three orders of magnitude and carried no hint of it. + * So the prefix stays bounded and the counters it buys are only REPORTED when the prefix was the + * whole file; see {@link describeSession}. Counting exactly is not available: the list reads the + * active file, and an exact count there would still miss the rest of the lineage. */ const LIST_PEEK_BYTES = 64 * 1024; const MAX_FIELD_CHARS = 20000; @@ -1915,23 +1924,33 @@ export async function start(context) { let peek = { messages: 0, userMessages: 0, assistantMessages: 0, toolResults: 0, toolCalls: 0, thinkingBlocks: 0, turns: 0 }; let label = null; let startedAt = identity.createdAtMs; + // Whether the prefix was the whole file, and so whether its counters mean what their names say. + // False until a read proves otherwise: a failed read has counted nothing at all, and reporting + // its zeros as 「0 条」 would be the same lie with a smaller number. + let peekIsWhole = false; try { const head = await readHead(join(entry.dir, 'messages.jsonl'), LIST_PEEK_BYTES); peek = countRolesInPrefix(head.text); label = findFirstUserLabel(head.text, redact); const firstTimestamp = firstTimestampIn(head.text); if (firstTimestamp !== null) startedAt = firstTimestamp; + peekIsWhole = !truncatedByCap(head.size, LIST_PEEK_BYTES); } catch (error) { context.logger.warn('miniapp.trajectory.read_error', { reason: 'peek_failed', code: errorCode(error) }); } const value = { id: identity.id, label: label ?? `会话 ${String(identity.id).slice(4, 12)}`, - messageCount: peek.messages, + // A prefix count is a prefix count. Reported as the session's message count it is simply + // untrue, so when the prefix was not the whole file these are absent rather than approximate — + // and the page's job is to print nothing rather than to print a number it cannot stand behind. + messageCount: peekIsWhole ? peek.messages : null, startedAt, lastActiveAt: entry.lastActiveAt, + // The active file's size, which is exactly what it is: `sizeBytes` is not a lineage total + // here, and /api/trajectory's is. Same name, different scope — see docs/LAYERS.md §21. sizeBytes: entry.sizeBytes, - turnCount: peek.turns, + turnCount: peekIsWhole ? peek.turns : null, cacheId, }; descriptorCache.set(cacheKey, { at: Date.now(), value }); From b86b879a9d4c2055e7352f005bad568c4940569b Mon Sep 17 00:00:00 2001 From: avatasia Date: Wed, 14 Oct 2026 00:23:18 +0800 Subject: [PATCH 80/88] Ask whether a reported count is a count, not whether one exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule that rules out counting exactly is already here: the peek must stay within 4x the generation probe, because a generation marker lives on the first row. D45 is what that constraint implies and the file did not say. If the prefix must stay bounded, then a count it can afford is a count of a PREFIX, and reporting that as the session''s size is not a rounding difference. Five assertions, and the bounded-but-reported pair is the load-bearing one -- absent is the only other answer available at this budget: - the descriptor asks whether its prefix was the whole file, which is a question about the FILE''s size. Asking how many bytes were read cannot distinguish "the whole file fitted in the budget" from "we stopped early", and there is a mutation for that. - messageCount is not sent unless the prefix was the whole file. - turnCount is not sent either. Same prefix, same reason; a mutation gates one and not the other, which is the half-fix worth naming. - a read that threw reports no count rather than zero. - the picker omits the segment instead of printing a placeholder. The last one is read off the client with its comments stripped. The comment above sessionOptionLabel quotes the old string 「(3 分钟前 · 10 条)」, so an assertion made against the raw file would be satisfied by prose describing the defect it exists to catch -- which is what D27 was. The mutation harness now takes an optional fourth element naming which file to mutate, defaulting to the server. One of these five rules lives in the page rather than in the service, so a mutation proving it can fail has to be made there; both files are read once at startup and restored byte for byte in the same finally, and the restored-bytes check covers both. A harness that can only edit one file cannot pair with a guard that reads two, and the symptom is not a red test -- it is an assertion that quietly never gets a mutation. verify-list-cache.mjs 10 -> 15 checks. mutate-list-cache-ttl.py 5 -> 10 mutations, all caught. --- .../tests/mutate-list-cache-ttl.py | 74 +++++++++++++++++-- .../tests/verify-list-cache.mjs | 42 +++++++++++ 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/tests/mutate-list-cache-ttl.py b/plugins/avatasia/mmc-trajectory/tests/mutate-list-cache-ttl.py index 7ff13f1..829b1b7 100644 --- a/plugins/avatasia/mmc-trajectory/tests/mutate-list-cache-ttl.py +++ b/plugins/avatasia/mmc-trajectory/tests/mutate-list-cache-ttl.py @@ -26,8 +26,14 @@ HERE = os.path.dirname(os.path.abspath(__file__)) PLUGIN = os.path.dirname(HERE) SERVER = os.path.join(PLUGIN, "miniapp", "node", "server.mjs") +CLIENT = os.path.join(PLUGIN, "miniapp", "client", "index.html") PROBE = os.path.join(HERE, "verify-list-cache.mjs") +# Each entry is (title, edits, expect) and optionally a fourth element naming the file to mutate, +# defaulting to the server. The fourth element exists because one rule of D45's lives in the page +# rather than in the service: whether a session picker prints a count it does not have is decided on +# the client, so a mutation that proves the rule can fail has to be made there. Both files are read +# once at startup and restored byte for byte in the same `finally` that already covered the server. MUTATIONS = [ # The defect verbatim: the cache is shorter than the interval that expires it, so it is never # hit. Not a slow cache — a cache that cannot be reached. @@ -61,9 +67,59 @@ [("titleCache = { at: 0, key: '', map: new Map() };\n descriptorCache.clear();", "descriptorCache.clear();")], "dispose releases the title cache"), + + # ---- D45: a prefix count is not a count. ---- + # + # The constant's own comment said the counters were for 「counts nobody treats as exact」, and the + # page treated them as exact: a session with 23,642 messages was offered to the reader as + # 「(3 分钟前 · 10 条)」, the count in the first 64 KB of its active file. These five are the + # ways back, and each one restores a number that is untrue in a way the reader cannot detect. + + # The gate removed. This is the original defect: the peek is still bounded, so this is not a + # performance question, it is a "10 条" with no hint of being 10-out-of-23642. + ("a prefix count is sent as the session message count again", + [(" messageCount: peekIsWhole ? peek.messages : null,", + " messageCount: peek.messages,")], + "a prefix message count is not sent as the session message count"), + + # The same lie under the other field name. Half a fix is the outcome to fear most here: the + # session picker shows the message count and the turn count comes from the same prefix, so + # fixing only one leaves a reader-visible number that is still a prefix. + ("the message count is gated but the turn count is not", + [(" turnCount: peekIsWhole ? peek.turns : null,", " turnCount: peek.turns,")], + "a prefix turn count is not sent either"), + + # The flag that decides it, defaulted the other way. A read that threw has counted nothing, and + # this turns 「I could not count it」 into 「there are none」 — the same untrue number, zero. + ("a read that failed is reported as a count of zero", + [(" let peekIsWhole = false;", " let peekIsWhole = true;"), + (" messageCount: peekIsWhole ? peek.messages : null,", + " messageCount: peek.messages,"), + (" turnCount: peekIsWhole ? peek.turns : null,", " turnCount: peek.turns,")], + "a read that failed is not reported as a count of zero"), + + # The gate is asked the wrong question: `read` is the bytes actually filled in, and it equals the + # file's size on every read that reached the end — so it cannot distinguish "the whole file fit in + # the budget" from "we stopped early". Asking about size is what makes the gate mean anything. + ("the gate asks how much was read rather than how big the file is", + [(" peekIsWhole = !truncatedByCap(head.size, LIST_PEEK_BYTES);", + " peekIsWhole = head.read >= LIST_PEEK_BYTES;")], + "the list asks whether its prefix was the whole file"), + + # The client half. Printing a dash for a number that does not exist renders the absence of a + # number in the shape of a number, which is the same defect one layer down: 「— 条」 is still a + # 条 count, just an impossible one. + ("the session picker prints a placeholder instead of omitting the count", + [(" var tail = isCount(count) ? '(' + when + ' · ' + formatInt(count) + ' 条)' : '(' + when + ')';", + " var tail = '(' + when + ' · ' + formatInt(count) + ' 条)';")], + "the session picker omits the count segment instead of printing a placeholder", + CLIENT), ] -original = io.open(SERVER, encoding="utf-8", newline="").read() +ORIGINALS = { + SERVER: io.open(SERVER, encoding="utf-8", newline="").read(), + CLIENT: io.open(CLIENT, encoding="utf-8", newline="").read(), +} def run(): @@ -79,7 +135,10 @@ def run(): ok = ok and base_ok caught = [] -for title, edits, expect in MUTATIONS: +for entry in MUTATIONS: + title, edits, expect = entry[0], entry[1], entry[2] + target = entry[3] if len(entry) > 3 else SERVER + original = ORIGINALS[target] for old, new in edits: hits = original.count(old) if hits != 1: @@ -92,7 +151,7 @@ def run(): mutated = mutated.replace(old, new, 1) if mutated == original: raise SystemExit("FAIL mutation for %r changed nothing" % title) - io.open(SERVER, "w", encoding="utf-8", newline="").write(mutated) + io.open(target, "w", encoding="utf-8", newline="").write(mutated) during_code, during = run() named = expect in during red = during_code != 0 @@ -107,12 +166,13 @@ def run(): print(" the rule was reported but the guard still exited 0") caught.append(named and red) finally: - io.open(SERVER, "w", encoding="utf-8", newline="").write(original) + io.open(target, "w", encoding="utf-8", newline="").write(original) -restored = io.open(SERVER, encoding="utf-8", newline="").read() +restored = all(io.open(path, encoding="utf-8", newline="").read() == text + for path, text in ORIGINALS.items()) after_code, after = run() after_ok = after_code == 0 and "0 failed" in after -print(("ok " if restored == original else "FAIL ") + "server restored byte for byte") +print(("ok " if restored else "FAIL ") + "both files restored byte for byte") print(("ok " if after_ok else "FAIL ") + "green again after restore") -sys.exit(0 if (ok and all(caught) and restored == original and after_ok) else 1) +sys.exit(0 if (ok and all(caught) and restored and after_ok) else 1) diff --git a/plugins/avatasia/mmc-trajectory/tests/verify-list-cache.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-list-cache.mjs index 09fffc0..68fa19d 100644 --- a/plugins/avatasia/mmc-trajectory/tests/verify-list-cache.mjs +++ b/plugins/avatasia/mmc-trajectory/tests/verify-list-cache.mjs @@ -93,6 +93,48 @@ if (probe && peek) { Number(peek[1]) <= Number(probe[1]) * 4); } +/* ---------- 6: what the bounded prefix is allowed to be reported as ---------- */ + +// The rule above is the one that rules out counting exactly: the list must not read the whole +// active file, so a count it can afford is a count of a PREFIX. That used to be fine because the +// constant's own comment said the counters were for 「counts nobody treats as exact」 — and the page +// treated them as exact, printing 「(3 分钟前 · 10 条)」 for a session with 23,642 messages. The +// prefix stays; what changed is that its numbers are only sent when the prefix was the whole file. +// +// So the invariant is two halves and both are load-bearing. Bounded-but-reported is the defect, and +// absent is the only other answer available at this budget: an exact count would need the whole +// active file, and would still miss the rest of the lineage besides. +const gatedOnWholeFile = /messageCount:\s*peekIsWhole\s*\?\s*peek\.messages\s*:\s*null/; +const turnsGatedTheSame = /turnCount:\s*peekIsWhole\s*\?\s*peek\.turns\s*:\s*null/; + +assert('the list asks whether its prefix was the whole file', + /const\s+head\s*=\s*await readHead\([\s\S]{0,120}?LIST_PEEK_BYTES\)/.test(server) + && /peekIsWhole\s*=\s*!truncatedByCap\(head\.size,\s*LIST_PEEK_BYTES\)/.test(server)); + +assert('a prefix message count is not sent as the session message count', gatedOnWholeFile.test(server)); +// Same rule, same reason: turnCount comes out of the same prefix. Fixing one and leaving the other +// is the same lie under a second name, and a mutation below does exactly that. +assert('a prefix turn count is not sent either', turnsGatedTheSame.test(server)); + +// A failed read has counted nothing at all. Reporting its zeros would be the same untrue number +// with a smaller magnitude, so the flag must default to "not the whole file" rather than to true. +assert('a read that failed is not reported as a count of zero', + /let peekIsWhole = false;/.test(server) + && !/let peekIsWhole = true;/.test(server)); + +// The reader-facing half. When there is no count the page omits the whole segment rather than +// printing a dash, because 「— 条」 renders the absence of a number in the shape of a number. +// +// Read off the client with its comments stripped. The comment above `sessionOptionLabel` quotes the +// old string `(3 分钟前 · 10 条)`, so an assertion made against the raw file would be satisfied by +// prose describing the very defect it exists to catch — which is what D27 was. +const clientJs = html + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^[ \t]*\/\/.*$/gm, ''); +assert('the session picker omits the count segment instead of printing a placeholder', + /var tail = isCount\(count\) \?/.test(clientJs) + && /'(' \+ when \+ ')'/.test(clientJs)); + console.log('[list-cache] ' + pass + ' passed, ' + failures.length + ' failed'); if (failures.length) { console.log('FAILURES:\n - ' + failures.join('\n - ')); From a784455804ac8044a667d46b9aa424ee9c34faec Mon Sep 17 00:00:00 2001 From: avatasia Date: Wed, 14 Oct 2026 00:41:56 +0800 Subject: [PATCH 81/88] Give the brushed range one writer, so R5 can be checked at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `state.overviewSelection` had four writers -- dropOverviewSelection, setActualDuration, onOverviewPointerMove, onOverviewPointerUp -- and R5 says L3 writes L2 through exactly one setter. That rule could not be asserted in either direction: a count of four is a guard that cannot fail once the code is fixed, and a count of one is a guard that cannot pass until it is. verify-cross-layer.mjs had said so in its header for a year and then left the rule unchecked. Only the field and the invariant that belongs to the field came in. What each call site does around it differs at all four -- resetting the window, re-rendering one face or both, starting the edge pan -- and folding that in would trade 「one writer per field」 for 「one function that knows everything」. `overviewViewport` is a different field and a different write, L3''s viewport rather than L2''s range, and stays where it is. The invariant is the one the old comment above dropOverviewSelection() already stated as a rule: anything that clears the range has to disarm the gesture, because a brush in flight repaints the range on every pointer move. Two of the four writers honoured it because they were the two that had been written to honour it; the other two set the field directly. One writer makes it true for all of them, which is what a single writer buys -- not a smarter function, but nobody left who can forget. The one behaviour change is that setActualDuration now disarms the brush too. That path is unreachable today -- a reader cannot press the axis button while the strip holds pointer capture -- so this is not a fix for a live defect. It is a rule that was in a comment and now is in the code. Measured on the live page: brushing moves the ledger from 「第 232 轮」 to 「第 148–150 轮」, the clear control puts it back, switching the axis clears it, and brushing again after the switch works and filters to 「第 168–172 轮」. --- .../mmc-trajectory/miniapp/client/index.html | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index e603bd6..62d5cb6 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -4922,6 +4922,33 @@

检视

dom.overviewTrack.removeAttribute('data-panning'); } + /** + * The one writer of `state.overviewSelection` — R5: L3 writes L2 through exactly one setter. + * + * It used to be four writers (`dropOverviewSelection`, `setActualDuration`, + * `onOverviewPointerMove`, `onOverviewPointerUp`), which is a number no guard could assert: + * asserting four is a guard that cannot fail once the code is fixed, and asserting one is a + * guard that cannot pass until it is. The rule was written down and never checked. + * + * Only the field and the invariant that belongs to the field came in. What each call site does + * around it — resetting the window, re-rendering one face or both, starting the edge pan — + * differs at all four, and folding that in would trade 「one writer per field」 for 「one + * function that knows everything」. `overviewViewport` is a different field and a different + * write (L3's viewport, not L2's range); it stays where it is. + * + * The invariant is the one the old comment above dropOverviewSelection() stated as a rule: + * anything that clears the range has to disarm the gesture too. A brush in flight repaints the + * range on every pointer move, so clearing the field while it is armed lets a gesture the + * reader already abandoned keep painting the range back. Two of the four writers honoured that + * because they were the two that had been written to honour it; the other two set the field + * directly. One writer makes it true for all of them, which is the whole thing a single writer + * buys — not a smarter function, but nobody left who can forget. + */ + function setOverviewSelection(value) { + if (value === null) abortOverviewDrag(); + state.overviewSelection = value; + } + /** * Drops the strip selection and nothing else — no zoom, no window re-decision, no render. * @@ -4933,8 +4960,7 @@

检视

* filters had just discarded. */ function dropOverviewSelection() { - abortOverviewDrag(); - state.overviewSelection = null; + setOverviewSelection(null); } function clearOverviewSelection() { @@ -4952,7 +4978,7 @@

检视

writeDurationPreference(value); // The two modes share no axis, so a range or a zoom made in one is meaningless // in the other. Reset rather than reinterpret. - state.overviewSelection = null; + setOverviewSelection(null); state.overviewViewport = null; resetWindowMode(); renderOverview(); @@ -5150,7 +5176,7 @@

检视

} overviewDrag.start = Math.min(overviewDrag.anchorTime, time); overviewDrag.end = Math.max(overviewDrag.anchorTime, time); - state.overviewSelection = { start: overviewDrag.start, end: overviewDrag.end }; + setOverviewSelection({ start: overviewDrag.start, end: overviewDrag.end }); startOverviewEdgePan(); renderOverviewSelection(); return; @@ -5255,7 +5281,7 @@

检视

start = center - minimum / 2; end = center + minimum / 2; } - state.overviewSelection = { start: start, end: end }; + setOverviewSelection({ start: start, end: end }); resetWindowMode(); renderOverviewSelection(); renderLedger(); From 314a3c0c50b4385137f513eb248dbcfa3f651d5a Mon Sep 17 00:00:00 2001 From: avatasia Date: Wed, 14 Oct 2026 00:42:10 +0800 Subject: [PATCH 82/88] Check that there is one writer, rather than how many there are The convergence is asserted by enumerating the functions that assign state.overviewSelection and requiring the SET to be {setOverviewSelection}. The count was the thing that could not be asserted, in either direction; the set of names is assertable at both ends and it also names the offender in the failure detail, which a count cannot. Three more assertions, each aimed at a way it can come back: - a second writer, at the mode switch (the path that used to disagree about the invariant); - a second writer, mid-gesture (the other half of that disagreement); - the writer keeping its single ownership and losing the invariant -- enumeration still shows one name, so only this one can catch it. Four of the call sites are named individually, because a convergence is only worth anything if every path that used to write still reaches it. Two existing assertions were rewritten rather than deleted. They still name the writer''s JOB -- a brush has to land somewhere, the strip control still has to clear the range -- and no longer name the spelling that is gone. Deleting them would have dropped the requirement and kept the silence. Writers are enumerated with the comments stripped, and functionSource now reads the same stripped text. Reading the raw file there while the enumeration read the stripped one put two views of the same file in one guard: a JSDoc block quoting the assignment it describes fails a per-function check and not the enumeration, with nothing between to explain it. MUST_STAY_GREEN exists because of this -- it adds exactly such a comment and requires the guard to stay green, which is the only way to prove the stripping is doing anything. verify-anomaly.mjs loads these functions out of index.html into a vm sandbox, and it was still loading only dropOverviewSelection. With the assignment moved behind the writer, the sandbox hit `setOverviewSelection is not defined`, and the fallout reached two mutation controls -- one of them reporting a MISSED catch and a failed restore -- which is how a change that touched one function turned into three unrelated red lines in the full suite. setOverviewSelection is now loaded verbatim rather than stubbed: a stub would have kept the sandbox green while the shipped reset quietly stopped clearing the brush. The assertion follows the whole chain, because checking only the middle hop would pass a writer that clears nothing. verify-cross-layer.mjs 15 -> 22 checks. mutate-cross-layer.py 9 -> 12 mutations, all caught, plus 1 must-stay-green. --- .../tests/mutate-cross-layer.py | 77 ++++++++- .../mmc-trajectory/tests/verify-anomaly.mjs | 19 ++- .../tests/verify-cross-layer.mjs | 149 +++++++++++++++--- 3 files changed, 210 insertions(+), 35 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/tests/mutate-cross-layer.py b/plugins/avatasia/mmc-trajectory/tests/mutate-cross-layer.py index a431515..34c6485 100644 --- a/plugins/avatasia/mmc-trajectory/tests/mutate-cross-layer.py +++ b/plugins/avatasia/mmc-trajectory/tests/mutate-cross-layer.py @@ -76,8 +76,11 @@ "neither window-bar writer touches state.overviewSelection"), # Declaration 2, write half: the brush stops writing the L2 field entirely. + # Re-anchored 2026-10-14: the landing write went through setOverviewSelection(), so the old + # `state.overviewSelection = { start: start, end: end }` anchor no longer resolves. The job is + # unchanged, so the mutation is the same one pointed at the line that now carries it. ("the overview brush stops writing the L2 range", - ' state.overviewSelection = { start: start, end: end };\n' + ' setOverviewSelection({ start: start, end: end });\n' ' resetWindowMode();\n' ' renderOverviewSelection();', ' resetWindowMode();\n' @@ -103,9 +106,9 @@ # 「清除筛选」 used to do, leaving a brush filtering a ledger the reader had just emptied. ("the shared clear writer stops writing the field", - " abortOverviewDrag();\n state.overviewSelection = null;", - " abortOverviewDrag();", - "dropOverviewSelection exists and is what writes the field to null"), + " setOverviewSelection(null);\n }", + " }", + "dropOverviewSelection exists and clears the field through the one writer"), ("the strip control goes back to assigning the field itself", " dropOverviewSelection();\n state.overviewViewport = null;", @@ -117,6 +120,49 @@ " dropOverviewSelection();\n clearTimeRange();", " clearTimeRange();", "and so does the base-filter reset, which is the one the empty ledger reaches"), + + # ---- R5, closed 2026-10-14: the four writers are one, and that is now assertable ---- + # + # Everything above could be true while the field still had four writers. The count could not be + # asserted (four is unassertable before the fix and unassertable after it), so the rule sat in + # prose. These four are the things that make 「exactly one writer」 fail, one per way it can. + + # A second writer, at the mode switch. This is the path that used to disagree with the others + # about the invariant: it cleared the range without disarming the brush. + ("the mode switch goes back to writing the field itself", + " setOverviewSelection(null);\n state.overviewViewport = null;", + " state.overviewSelection = null;\n state.overviewViewport = null;", + "R5 holds: state.overviewSelection has exactly one writer"), + + # …and one during the gesture, which is the other half of that disagreement. + ("the in-flight brush goes back to writing the field itself", + " setOverviewSelection({ start: overviewDrag.start, end: overviewDrag.end });", + " state.overviewSelection = { start: overviewDrag.start, end: overviewDrag.end };", + "onOverviewPointerMove reaches the field through the one writer"), + + # The writer keeps its single ownership and loses the invariant. Enumeration still shows one + # name, so only the second assertion can catch this — which is the reason it exists separately. + ("the one writer no longer disarms the brush", + " if (value === null) abortOverviewDrag();\n state.overviewSelection = value;", + " state.overviewSelection = value;", + "clearing the range through the writer also disarms the brush in flight"), +] + +# Mutations that must leave the guard GREEN. They exist because a guard that cannot pass is as +# useless as one that cannot fail, and this file needed one claim of the second kind to be provable. +# +# The single-writer assertion counts writers by scanning for assignments with the comments stripped +# out — the JSDoc above dropOverviewSelection() spent years describing `state.overviewSelection = null` +# in prose, which is exactly the shape that would make the count come out wrong. Stripping is not +# observable from a failing mutation: without it this edit would be caught, with it the edit is +# invisible, and the two cases are indistinguishable by anything else in this file. So the edit is +# made and the guard is required to stay green. +MUST_STAY_GREEN = [ + ("a comment quoting the old assignment is not counted as a writer", + " function dropOverviewSelection() {\n setOverviewSelection(null);", + " function dropOverviewSelection() {\n" + " // state.overviewSelection = null; <- what this used to do\n" + " setOverviewSelection(null);"), ] original = io.open(CLIENT, encoding="utf-8", newline="").read() @@ -151,10 +197,31 @@ def run(): finally: io.open(CLIENT, "w", encoding="utf-8", newline="").write(original) +# The mirror image, and the only way to prove a guard is not simply strict. These edits must leave it +# green: they change what the file SAYS about the writers without changing who the writers are. +stayed_green = [] +for title, old, new in MUST_STAY_GREEN: + hits = original.count(old) + if hits != 1: + raise SystemExit("FAIL anchor for %r matched %d times" % (title, hits)) + try: + io.open(CLIENT, "w", encoding="utf-8", newline="").write(original.replace(old, new, 1)) + during = run() + green = GREEN in during + print(("ok " if green else "FAIL ") + "guard ignores: " + title) + if not green: + print(" the guard reacted to an edit that changes no writer:") + for line in during.splitlines()[-5:]: + print(" " + line.strip()) + stayed_green.append(green) + finally: + io.open(CLIENT, "w", encoding="utf-8", newline="").write(original) + restored = io.open(CLIENT, encoding="utf-8", newline="").read() after = run() after_ok = GREEN in after print(("ok " if restored == original else "FAIL ") + "client restored byte for byte") print(("ok " if after_ok else "FAIL ") + "green again after restore") -sys.exit(0 if (ok and all(caught_all) and restored == original and after_ok) else 1) +sys.exit(0 if (ok and all(caught_all) and all(stayed_green) + and restored == original and after_ok) else 1) diff --git a/plugins/avatasia/mmc-trajectory/tests/verify-anomaly.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-anomaly.mjs index b09e5d2..3448e56 100644 --- a/plugins/avatasia/mmc-trajectory/tests/verify-anomaly.mjs +++ b/plugins/avatasia/mmc-trajectory/tests/verify-anomaly.mjs @@ -492,6 +492,13 @@ if (!resetBaseSource) throw new Error('could not extract resetBaseFilters'); // it must do is a property of the strip (verify-overview.mjs), not of 异常. Stubbing it keeps this // file from having to grow the drag's whole dependency tree to test one assignment. page.abortOverviewDrag = () => {}; +// …and setOverviewSelection is loaded verbatim too, because dropOverviewSelection no longer assigns +// the field itself: R5 gave it one writer, so the reset path runs through it. Stubbing it instead +// would have been the smaller change and the wrong one — the sandbox would then pass while the +// shipped reset quietly stopped clearing the brush, which is the property this file exists to hold. +const setSelSource = fnSource('function setOverviewSelection('); +if (!setSelSource) throw new Error('could not extract setOverviewSelection'); +vm.runInContext(setSelSource, page); const dropSelSource = fnSource('function dropOverviewSelection('); if (!dropSelSource) throw new Error('could not extract dropOverviewSelection'); vm.runInContext(dropSelSource, page); @@ -910,13 +917,15 @@ const modeSrc = fnSource('function syncAnomalyMode(counts)'); const baseSrc = fnSource('function resetBaseFilters('); assert('the reset updates the buttons, or the axis would show a condition that is not in force', !!baseSrc && /state\.kinds = KIND_ORDER\.slice\(\);/.test(baseSrc) && /syncKindButtons\(\);/.test(baseSrc)); -// The brush is no longer written inline here — it is written by dropOverviewSelection(), which is -// also what aborts the drag. Re-anchoring must not weaken this: the rule is still "the row above is -// cleared whole", so it now follows the call and then checks the assignment at the far end of it. -// Checking only that the call happens would let a dropOverviewSelection() that clears nothing pass. +// The brush is not written inline here, and it is not written by dropOverviewSelection() either — +// R5 gave it one writer, so the chain is resetBaseFilters -> dropOverviewSelection -> +// setOverviewSelection -> the assignment. Following the call and checking the assignment at the far +// end of it is what keeps this from weakening: checking only that the calls happen would let a +// setOverviewSelection that clears nothing pass, which is the same hole one hop further down. assert('and it clears every part of the row above, not just the types', !!baseSrc && /state\.query = '';/.test(baseSrc) && /dropOverviewSelection\(\);/.test(baseSrc) - && !!dropSelSource && /state\.overviewSelection = null;/.test(dropSelSource) + && !!dropSelSource && /setOverviewSelection\(null\);/.test(dropSelSource) + && !!setSelSource && /state\.overviewSelection = value;/.test(setSelSource) && /clearTimeRange\(\);/.test(baseSrc)); const toggleFull = fnSource('function onAnomalyToggle('); assert('and it runs on every tick into the mode, not only the first', diff --git a/plugins/avatasia/mmc-trajectory/tests/verify-cross-layer.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-cross-layer.mjs index 3d41acd..0931736 100644 --- a/plugins/avatasia/mmc-trajectory/tests/verify-cross-layer.mjs +++ b/plugins/avatasia/mmc-trajectory/tests/verify-cross-layer.mjs @@ -18,18 +18,23 @@ * without declaring it and the pairing stops holding, which is exactly when the document's * claim becomes false. * - * What this guard deliberately does NOT check: R5, the claim that L3 writes L2 through exactly - * one setter. That is a real defect and it is not closed — `state.overviewSelection` has four - * writers today (`dropOverviewSelection`, `setActualDuration`, `onOverviewPointerMove`, - * `onOverviewPointerUp`). Asserting a convergence that has not happened would be a guard that - * cannot pass; asserting the current count would be a guard that cannot fail. See docs/LAYERS.md - * R5 / D4. + * What this guard used NOT to check: R5, the claim that L3 writes L2 through exactly one setter. + * `state.overviewSelection` had four writers (`dropOverviewSelection`, `setActualDuration`, + * `onOverviewPointerMove`, `onOverviewPointerUp`), and a count of four is a guard that cannot fail + * once the code is fixed while a count of one is a guard that cannot pass until it is. So the rule + * was written down and left unchecked. The four are now one (`setOverviewSelection`) and the + * convergence is asserted below, by enumeration rather than by count. * * What it *does* check about R5 is the part that is true and load-bearing: the two paths whose * job is to clear the field go through one writer. They used to assign it in two places, and one * of them — resetBaseFilters, the one the empty ledger's 「清除筛选」 reaches — was not clearing it * at all, which is a defect this guard now names rather than a count it merely records. * + * Two assertions below were rewritten rather than deleted when the four writers became one, and + * both rewrites are the same move: they still name the writer's JOB (a brush has to land somewhere, + * the strip control still has to clear the range) and no longer name the spelling that is gone. + * Deleting them would have dropped the requirement and kept the silence. + * * mutate-cross-layer.py breaks each rule below in the shipped file and requires this guard to * report it by name. */ @@ -48,14 +53,19 @@ function assert(name, cond) { /** Brace-matched body of a top-level `function name(` declaration. */ function functionSource(name) { - const at = html.indexOf('function ' + name + '('); + // Off `htmlNoComments`, not off `html`. Reading the raw file here while the writer enumeration + // below reads the stripped one put two views of the same file in one guard: a JSDoc block that + // quotes the assignment it is describing would fail a per-function check and not the enumeration, + // with nothing in between to explain the disagreement. Found by the MUST_STAY_GREEN case, which + // added exactly such a comment and watched the count stay right while the name check went red. + const at = htmlNoComments.indexOf('function ' + name + '('); if (at < 0) return null; let depth = 0; - for (let j = html.indexOf('{', at); j < html.length; j += 1) { - if (html[j] === '{') depth += 1; - else if (html[j] === '}') { + for (let j = htmlNoComments.indexOf('{', at); j < htmlNoComments.length; j += 1) { + if (htmlNoComments[j] === '{') depth += 1; + else if (htmlNoComments[j] === '}') { depth -= 1; - if (depth === 0) return html.slice(at, j + 1); + if (depth === 0) return htmlNoComments.slice(at, j + 1); } } return null; @@ -80,6 +90,64 @@ function stateWrites(src) { return out; } +// ----------------------------------------------------------------- the one writer (R5) + +// The client with its comments stripped, for enumerating writers. A JSDoc block that quotes the +// line it is describing would otherwise be counted as a writer — and the comment above +// dropOverviewSelection() spent years describing `state.overviewSelection = null` in prose, which +// is exactly the shape that would make this assertion pass for the wrong reason. +// +// Only whole-line comments and block comments are removed, rather than every `//` up to end of line: +// this file contains `http://` inside strings, and a naive pass deletes the rest of those lines. +const htmlNoComments = html + .replace(/\/\*[\s\S]*?\*\//g, '') + .split('\n') + .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line)) + .join('\n'); + +/** Every `function name(` declaration with the byte range of its body. */ +function functionRanges(source) { + const out = []; + const re = /function\s+(\w+)\s*\(/g; + let m; + while ((m = re.exec(source)) !== null) { + const braceAt = source.indexOf('{', m.index); + if (braceAt < 0) continue; + let depth = 0; + let end = -1; + for (let j = braceAt; j < source.length; j += 1) { + if (source[j] === '{') depth += 1; + else if (source[j] === '}') { + depth -= 1; + if (depth === 0) { end = j; break; } + } + } + if (end < 0) continue; + out.push({ name: m[1], start: m.index, end }); + } + return out; +} + +/** The names of the functions that assign `state.`, innermost declaration first. */ +function writersOf(field, source = htmlNoComments) { + const ranges = functionRanges(source); + const names = []; + const re = new RegExp('state\\.' + field + '\\s*=(?!=)', 'g'); + let m; + while ((m = re.exec(source)) !== null) { + // Innermost wins: a nested function's range is contained by its parent's, and only the one the + // assignment textually sits inside is the one that writes it. + let owner = null; + for (const r of ranges) { + if (m.index >= r.start && m.index <= r.end && (owner === null || r.end - r.start < owner.end - owner.start)) { + owner = r; + } + } + names.push(owner ? owner.name : ''); + } + return names; +} + // ---------------------------------------------------------------- declaration 1: window-bar // It is presented inside the ledger panel. Moving it out is a move to another layer, which is @@ -127,13 +195,13 @@ function stateWrites(src) { /
src !== null && /state\.overviewSelection\s*=[^=]/.test(src); - assert('dropOverviewSelection exists and is what writes the field to null', - drop !== null && /state\.overviewSelection = null;/.test(drop)); + assert('dropOverviewSelection exists and clears the field through the one writer', + drop !== null && /setOverviewSelection\(null\);/.test(drop)); assert('the strip control clears through it rather than through its own assignment', clear !== null && /dropOverviewSelection\(\);/.test(clear) && !assignsItself(clear)); assert('and so does the base-filter reset, which is the one the empty ledger reaches', resetBase !== null && /dropOverviewSelection\(\);/.test(resetBase) && !assignsItself(resetBase)); } +// ---------------------------------------------------------------- the one writer, itself +// +// Enumerated rather than counted, because the count was the thing that could not be asserted: four +// is unassertable before the fix and unassertable after it. The set of NAMES is assertable at both +// ends, and it also names the offender in the failure detail, which a count cannot. +{ + const writers = writersOf('overviewSelection'); + assert('R5 holds: state.overviewSelection has exactly one writer and it is setOverviewSelection', + writers.length > 0 && writers.every((n) => n === 'setOverviewSelection'), + JSON.stringify(writers)); +} + +// The invariant the single writer exists to carry, which is also the part the four writers disagreed +// about: clearing the range has to disarm the gesture, because a brush in flight repaints the range +// on every pointer move. Two of the four did that and two did not; the setter has no way to forget. +{ + const setter = functionSource('setOverviewSelection'); + assert('setOverviewSelection exists', setter !== null); + assert('clearing the range through the writer also disarms the brush in flight', + setter !== null + && /if \(value === null\) abortOverviewDrag\(\);/.test(setter) + && /state\.overviewSelection = value;/.test(setter)); +} + +// A writer that the mode switch no longer goes through would still satisfy the two assertions above +// — the set would be {setOverviewSelection} and the field would have one owner. The convergence is +// only worth anything if every path that used to write still reaches it, so each one is named. +for (const name of ['dropOverviewSelection', 'setActualDuration', 'onOverviewPointerMove', 'onOverviewPointerUp']) { + const src = functionSource(name); + assert(name + ' reaches the field through the one writer', + src !== null + && /setOverviewSelection\(/.test(src) + && !/state\.overviewSelection\s*=[^=]/.test(src), + src === null ? 'function missing' : 'assigns the field itself or never reaches the writer'); +} + // ---------------------------------------------------------------- reporting for (const name of failures) console.log('FAIL ' + name); From eef89e4865dcbef1bd55a3afd1f4df3a6f8da091 Mon Sep 17 00:00:00 2001 From: avatasia Date: Wed, 14 Oct 2026 07:46:56 +0800 Subject: [PATCH 83/88] Point the ledger jump down until the reader has pressed it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arrow on the ledger''s jump control points wherever a press would take you, which meant that opening the page already resting at the end of the ledger drew 「跳到轨迹的最上」 before the reader had touched anything. That reading is not yet true of anything they did, so the first screen now always points down, and the first press always goes to the bottom: ledgerJumpUsed starts false, so ledgerJumpGoesUp() is false and 「在底部」 does not get a vote on the first screen the press arms it, and from then on the arrow reports position Both the glyph and the press read one function, ledgerJumpGoesUp(). They used to call ledgerEndInView() each and agree because they were the same call; they agree now for a stronger reason, since a single flag is all that separates the two states. The press reads the predicate BEFORE arming it. Arming first would let the very first press consult the position, and on a ledger that already fits on screen that sends the reader UP on the one press that is supposed to go down. It also relabels the arrow itself rather than leaving it to the observer: scrolling to the end you are already at fires no intersection event, which is exactly when the arrow has to move. The flag is never cleared. 「首屏」 is this page, not each session -- a reader who switches sessions has already moved on this page, so the arrow goes straight back to reporting position. Clearing it per session is a different decision and wants its own round of questions. Measured on the live page: first screen down, standing at the bottom without having pressed still down, first press to scrollY 7488 with the arrow up, second press back to 732 with the arrow down, and all seven sampled scroll positions agree afterwards. --- .../mmc-trajectory/miniapp/client/index.html | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index 62d5cb6..ef063e7 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -6450,6 +6450,32 @@

检视

var atLedgerEnd = false; var ledgerEndObserver = null; + /** + * Has this ledger been jumped yet. False until the reader presses it once. + * + * D46: the first screen always points down, whatever the page is sitting at, because the + * reading 「the end is on screen」 is not yet true of anything the reader did — and drawing + * 「读者还没动」 as 「读者在最上」 invents a state that does not exist. So the position only + * gets a vote after the first press. + * + * One writer, the click handler, and it is never cleared: 「首屏」 is this page, not each + * session, so a reader who switches sessions has already moved on this page and the arrow + * goes straight back to reporting position. Resetting it per session is a different decision + * and wants its own round of questions. + */ + var ledgerJumpUsed = false; + + /** + * Where the next press goes — and the ONE answer both the arrow and the press read. + * + * These two used to call `ledgerEndInView()` each, which agreed because they were the same + * call. They agree here too, and for a stronger reason: the two states now differ by one + * flag, so reading the predicate twice instead of once would let the glyph and the scroll + * disagree for the first time. One function, two callers, no way for them to part. + */ + function ledgerJumpGoesUp() { + return ledgerJumpUsed && ledgerEndInView(); + } /** * A picture of the trip, not its name. @@ -6476,8 +6502,8 @@

检视

var empty = !state.payload || !state.flat || state.flat.length === 0; button.hidden = empty; if (empty) return; - var atEnd = ledgerEndInView(); - var name = atEnd ? 'arrowUp' : 'arrowDown'; + var up = ledgerJumpGoesUp(); + var name = up ? 'arrowUp' : 'arrowDown'; // Rebuilt rather than swapped in place: there is no attribute selector that exchanges two // `d` values, and a cross-fade on a 16px glyph reads as a flicker rather than as motion. // The `data-arrow` read is what keeps this from rebuilding on every one of the scroll @@ -6489,7 +6515,7 @@

检视

glyph.setAttribute('data-arrow', name); button.appendChild(glyph); } - button.setAttribute('aria-label', atEnd ? '跳到轨迹的最上(最早一条已加载的记录)' : '跳到轨迹的最低(最新一条已加载的记录)'); + button.setAttribute('aria-label', up ? '跳到轨迹的最上(最早一条已加载的记录)' : '跳到轨迹的最低(最新一条已加载的记录)'); } /** @@ -6556,7 +6582,16 @@

检视

ledgerEndObserver.observe(sentinel); button.addEventListener('click', function () { - window.scrollTo(0, ledgerEndInView() ? ledgerTopOffset() : ledgerBottomOffset()); + // Read the predicate BEFORE arming it. Setting the flag first would make the very first + // press consult the position, and on a ledger that already fits on screen that sends the + // reader UP on the press that is supposed to be the one that goes down. + var up = ledgerJumpGoesUp(); + window.scrollTo(0, up ? ledgerTopOffset() : ledgerBottomOffset()); + // Armed after the scroll is issued, and the label is rewritten here rather than left to + // the observer: scrolling to the end you are already at fires no intersection event, and + // that is exactly the case where the arrow has to change. + ledgerJumpUsed = true; + setLedgerJumpLabel(); }); setLedgerJumpLabel(); From 5461c8092e193edf4a43e4a17b7b2cef075d453a Mon Sep 17 00:00:00 2001 From: avatasia Date: Wed, 14 Oct 2026 07:47:08 +0800 Subject: [PATCH 84/88] Assert the two states differ by a flag, and that the flag can only be set once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two assertions were rewritten rather than deleted. The one that pinned `var name = atEnd ? …` now points at the function that holds the reading, and the one that pinned the click's inline ternary now requires both the glyph and the press to go through ledgerJumpGoesUp(). Neither requirement went anywhere: 「the click chooses from the same reading the arrow does」 is if anything more load-bearing now, because a single flag is all that separates the two states and two copies of the predicate could let them say different things for the first time. Five new checks, four new mutations: - the flag starts false in the shipped file, so the first screen points down whatever the page is at; - the press arms it, and only the press does; - the destination is chosen BEFORE it is armed. Writing those two lines the other way round rebuilds the original defect from the inside: the first press consults a position the reader never chose, and on a ledger that already fits on screen that sends them UP; - the press relabels the arrow itself, matched as an adjacent pair rather than by index, because buildLedgerJump() carries a setLedgerJumpLabel() of its own inside the observer callback and an index comparison finds that one and reports the opposite defect. Three anchors stopped resolving and were re-read against the shipped file rather than re-worded: the arrow no longer mentions `atEnd`, and the scroll target now goes through `up`. verify-ledger-jump.mjs 38 -> 42 checks. mutate-ledger-jump.py 35 -> 39 mutations, all caught. One process note, because the probe is the part that would have shipped the wrong conclusion: the browser check for 「at the bottom and not yet pressed」 was written last, by which time the button had been pressed twice and the flag was long armed. It measured neither the old defect nor the new behaviour -- it had consumed its own precondition. A flag that cannot be un-set means that precondition lives only at the start of the page, so the check belongs before the first press and not after it. --- .../tests/mutate-ledger-jump.py | 49 +++++++++++++++++-- .../tests/verify-ledger-jump.mjs | 38 ++++++++++++-- 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/tests/mutate-ledger-jump.py b/plugins/avatasia/mmc-trajectory/tests/mutate-ledger-jump.py index ec5d289..d706895 100644 --- a/plugins/avatasia/mmc-trajectory/tests/mutate-ledger-jump.py +++ b/plugins/avatasia/mmc-trajectory/tests/mutate-ledger-jump.py @@ -93,9 +93,50 @@ [(" var glyph = icon(name);\n", " var glyph = document.createTextNode(atEnd ? '\u5230\u6700\u4e0a' : '\u5230\u6700\u4f4e');\n")], "the control shows an arrow rather than a word"), + # Re-anchored 2026-10-14 (D46). The arrow no longer reads `ledgerEndInView()` directly: the two + # states now differ by the `ledgerJumpUsed` flag, so both the glyph and the click go through + # `ledgerJumpGoesUp()`. The job is unchanged — the arrow still follows a reading rather than a + # constant — so the mutation is the same one pointed at the line that now carries it. ("the arrow stops following the reading", - [("var name = atEnd ? 'arrowUp' : 'arrowDown';", "var name = 'arrowDown';")], - "which arrow it is comes from the reading, not from a counter"), + [("var name = up ? 'arrowUp' : 'arrowDown';", "var name = 'arrowDown';")], + "the reading that picks the arrow exists and is the one the label reads"), + + # ---- D46: the first screen points down whatever the page is at ---- + + # The flag starts armed, so the first screen reports position and the control is back to + # 「go to where you already are」 — the failure the arrow's own comment says it must never be. + ("the reading starts armed instead of on the first press", + [("var ledgerJumpUsed = false;", "var ledgerJumpUsed = true;")], + "the reading starts unarmed, so the first screen points down whatever the page is at"), + + # The flag is never armed, so 「在底部」 never gets a vote and the control can only ever go + # down. Reads like a working button: it scrolls, it relabels, it just never comes back up. + ("the first press never arms the reading", + [(" ledgerJumpUsed = true;\n", "")], + "the first press arms the reading, and only the press does"), + + # The two lines in the wrong order. This is the original bug re-created from the inside: arm + # first, then ask, so the first press consults a position the reader never chose. + ("the reading is armed before the destination is chosen", + [(" var up = ledgerJumpGoesUp();\n" + " window.scrollTo(0, up ? ledgerTopOffset() : ledgerBottomOffset());\n" + " // Armed after the scroll is issued, and the label is rewritten here rather than left to\n" + " // the observer: scrolling to the end you are already at fires no intersection event, and\n" + " // that is exactly the case where the arrow has to change.\n" + " ledgerJumpUsed = true;\n", + " ledgerJumpUsed = true;\n" + " var up = ledgerJumpGoesUp();\n" + " window.scrollTo(0, up ? ledgerTopOffset() : ledgerBottomOffset());\n")], + "the destination is chosen before the reading is armed"), + + # The glyph and the scroll each read the reading themselves. They agree today because they are + # the same call — and that is exactly why letting them drift is the one thing not to allow, now + # that a flag is the only thing between the two states. + ("the click stops reading what the arrow reads", + [(" var up = ledgerJumpGoesUp();\n" + " window.scrollTo(0, up ? ledgerTopOffset() : ledgerBottomOffset());", + " window.scrollTo(0, up ? ledgerTopOffset() : ledgerBottomOffset());")], + "the click chooses between the two ends from the same reading the arrow does"), # Scoped to the attribute: the function's own comment names both old labels, so a mutation that # only changed the string would still be satisfied by the prose. @@ -129,12 +170,12 @@ # ---------- the two targets ---------- ("up goes to the top of the page", - [("window.scrollTo(0, ledgerEndInView() ? ledgerTopOffset() : ledgerBottomOffset());", + [("window.scrollTo(0, up ? ledgerTopOffset() : ledgerBottomOffset());", "window.scrollTo(0, 0);")], "the click never scrolls to the top of the document"), ("the two ends are swapped", - [("window.scrollTo(0, ledgerEndInView() ? ledgerTopOffset() : ledgerBottomOffset());", + [("window.scrollTo(0, up ? ledgerTopOffset() : ledgerBottomOffset());", "window.scrollTo(0, ledgerEndInView() ? ledgerBottomOffset() : ledgerTopOffset());")], "the click chooses between the two ends from the same reading the arrow does"), diff --git a/plugins/avatasia/mmc-trajectory/tests/verify-ledger-jump.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-ledger-jump.mjs index 87e493a..71abd47 100644 --- a/plugins/avatasia/mmc-trajectory/tests/verify-ledger-jump.mjs +++ b/plugins/avatasia/mmc-trajectory/tests/verify-ledger-jump.mjs @@ -117,8 +117,21 @@ assert('the label setter is in the shipped script', !!labelSrc); // presentation and not of what the control announces to anyone not looking at it. assert('the control shows an arrow rather than a word', !!labelSrc && /icon\(name\)/.test(labelSrc) && !/textContent = atEnd/.test(labelSrc)); -assert('which arrow it is comes from the reading, not from a counter', - !!labelSrc && /var name = atEnd \? 'arrowUp' : 'arrowDown'/.test(labelSrc)); +// D46 rewrote the reading, not the requirement. It used to be `var name = atEnd ? …` straight off +// `ledgerEndInView()`; now the two states differ by one flag — the ledger has to have been jumped +// once before 「在底部」 is allowed a vote — so the arrow comes off `ledgerJumpGoesUp()`. Same rule +// (which arrow is showing follows the reading, never a counter), pointed at the function that now +// holds the reading. +const goesUpSrc = functionSource('ledgerJumpGoesUp'); +assert('the reading that picks the arrow exists and is the one the label reads', + !!goesUpSrc && /return ledgerJumpUsed && ledgerEndInView\(\);/.test(goesUpSrc) + && !!labelSrc && /var up = ledgerJumpGoesUp\(\);/.test(labelSrc) + && /var name = up \? 'arrowUp' : 'arrowDown'/.test(labelSrc)); +// …and the flag has to start out false, in the shipped file rather than in the markup, or the +// reading is permanently 「not yet jumped」 and the control can only ever go down. (The press that +// arms it is asserted in the click section below, where buildSrc is in scope.) +assert('the reading starts unarmed, so the first screen points down whatever the page is at', + /var ledgerJumpUsed = false;/.test(html)); // Scoped to the attribute itself, not to the function: this file's own comment explains what the // two labels used to be, so a search for the words anywhere in the source is satisfied by prose. const ariaLine = labelSrc ? /setAttribute\('aria-label',[^\n]*\)/.exec(labelSrc) : null; @@ -168,7 +181,26 @@ assert("down means the sentinel at the ledger's own bottom", && !/lastElementChild/.test(bottomSrc)); assert('the click chooses between the two ends from the same reading the arrow does', - !!buildSrc && /window\.scrollTo\(0, ledgerEndInView\(\) \? ledgerTopOffset\(\) : ledgerBottomOffset\(\)\)/.test(buildSrc)); + !!buildSrc && /var up = ledgerJumpGoesUp\(\);/.test(buildSrc) + && /window\.scrollTo\(0, up \? ledgerTopOffset\(\) : ledgerBottomOffset\(\)\)/.test(buildSrc)); +// …and the press is what arms the reading. One writer; without it the control can only ever go down. +assert('the first press arms the reading, and only the press does', + !!buildSrc && /ledgerJumpUsed = true;/.test(buildSrc)); +// …armed AFTER the destination is chosen. Arming it first would let the very first press consult +// the position, and on a ledger that already fits on screen that sends the reader UP on the one +// press that is supposed to go down — the bug this whole flag exists to remove, reintroduced by +// writing the two lines in the wrong order. +assert('the destination is chosen before the reading is armed', + !!buildSrc + && buildSrc.indexOf('var up = ledgerJumpGoesUp();') >= 0 + && buildSrc.indexOf('var up = ledgerJumpGoesUp();') < buildSrc.indexOf('ledgerJumpUsed = true;')); +// The label is rewritten by the press rather than left to the observer: scrolling to the end the +// reader is already at fires no intersection event, and that is exactly when the arrow has to move. +// Matched as an adjacent pair rather than by index, because buildLedgerJump() contains a +// setLedgerJumpLabel() of its own inside the observer callback — an index comparison would find +// THAT one and report the arrow never being relabelled, which is the opposite of the defect. +assert('the press relabels the arrow itself instead of waiting for the observer', + !!buildSrc && /ledgerJumpUsed = true;\s*setLedgerJumpLabel\(\);/.test(buildSrc)); assert('the click never scrolls to the top of the document', !!buildSrc && !/scrollTo\(0, 0\)/.test(buildSrc)); assert('the jump is built once, on the wire path', From 30f284dadc24edbb7c2b4ee67d6850cd55b5e561 Mon Sep 17 00:00:00 2001 From: avatasia Date: Wed, 14 Oct 2026 08:06:01 +0800 Subject: [PATCH 85/88] Let the shipped comment name the list the selection is actually judged against The doc comment on revealSelection() said the list it is handed is the *windowed* rows rather than the filtered ones, and that it used to be judged before the slice. Both halves were true when D13 wrote them and both are false of the function that ships: renderLedger() passes `matched`, and the call sits after the window is sliced. A third sentence in the same paragraph -- "the same one the rows are built from" -- also stopped being true, because the rows are built from `windowed`. That paragraph is the documentation a reader of the installed plugin has, and it named the judgement D44 spent a round measuring and overturning. It was telling readers to decide "is the reader still looking at this" the way that loses a selection after a single poll on a session that is still being written. What survives from D13 is stated instead: the window is never widened to reach a selection, and this writes `state.selectedId` and nothing else. The reason it is `matched` now points at the call site in renderLedger(), which is inside the package. The section it used to cite lives in the workspace, outside the tree that installs, so a reader following it found nothing. verify-selection.mjs 15 checks and mutate-selection.py 11 mutations, both unchanged -- the guards already pinned `revealSelection(matched)`, which is exactly why the comment could sit next to them and disagree for as long as it did. A guard that reads the call site does not read the prose around it. node tests/run.mjs 57/57. --- .../mmc-trajectory/miniapp/client/index.html | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index ef063e7..7b1ce17 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -5516,21 +5516,27 @@

检视

* It used to ask `state.flat` — the whole session, unfiltered — so a record stayed selected * after every filter had already removed it: the ledger read 已显示 2 条, the inspector still * showed the row the reader had picked, and no row anywhere carried the selection mark. The - * list handed in here is the same one the rows are built from, so the two agree by - * construction instead of by convention. + * list handed in here is the filter result the table is narrowed from, not the session, so a + * record this accepts is by construction one the filters kept. * * It runs from renderLedger(), which is the one exit every path that changes what the table * shows goes through: the filters that call render() and the ones that call renderLedger() * directly. * - * The list is the *windowed* rows, not the filtered ones, and this is where the order - * finally settled. It used to be judged before the slice and used to raise `state.limit` to - * reach a selection sitting below the window — see docs/LAYERS.md D13 for the two things - * that were wrong with that. It writes one field, `state.selectedId`, and nothing else: the - * window belongs to the window controls, and a judgement that also moves it is a second - * writer of `state.limit`. + * The list is the *filtered* rows, not the windowed ones, and this paragraph used to say the + * opposite. The order settled first: the judgement runs after the window is sliced, so it + * looks at the table as it is about to be drawn rather than at a pre-slice list. The + * argument was corrected after that, and the reason is measured rather than argued — the + * window is a projection that slides on its own, every new record pushing the tail forward + * by a row, so a record leaving it measures the projection's motion and not the reader's + * attention. The numbers behind that are written out at the call site in renderLedger(). * - * @param {Array<{record: any}>} entries the rows about to be built from + * The order half has never stopped holding, and neither has the prohibition it came with: + * the window is not widened to reach a selection sitting below it. This writes one field, + * `state.selectedId`, and nothing else — the window belongs to the window controls, and a + * judgement that also moved it would be a second writer of `state.limit`. + * + * @param {Array<{record: any}>} entries the filtered rows — `matched`, not `windowed` * @returns {boolean} true when it dropped the selection — the caller has to redraw the panel, * because nine of the paths that reach here never redraw it themselves */ From b1251cf6489c33e1da0e8aa9c7d28903a34aef74 Mon Sep 17 00:00:00 2001 From: avatasia Date: Wed, 14 Oct 2026 08:06:02 +0800 Subject: [PATCH 86/88] Keep the shipped-file list to files that are in the package `docs/LAYERS.md` sat in run.mjs's SHIPPED list after the document moved out of the plugin tree. It then hashed to 'absent' on every run, which is inert -- absent matches absent, so it never reported drift -- but it meant one of the entries the hard-kill guard advertises protecting was not a shipped file at all. Seven real files, not eight. The membership rule is now written next to the list: these are the shipped files a mutate-* control rewrites in place. That is a rule about files rather than about importance, so it survives the next move. No guard is owed for the removal itself. An entry naming a file that is not there hashes to 'absent' on both sides and can never report drift, so there was nothing to break and nothing to keep. What the list does not have is a completeness check -- nothing asks whether every file a mutate-* control rewrites is in it, so renaming one out from under the list would quietly stop the hard-kill guard covering that file. That is a missing invariant rather than a wrong one, and it wants a round of its own. node tests/run.mjs 57/57. --- plugins/avatasia/mmc-trajectory/tests/run.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/avatasia/mmc-trajectory/tests/run.mjs b/plugins/avatasia/mmc-trajectory/tests/run.mjs index 1511a9e..d85238a 100644 --- a/plugins/avatasia/mmc-trajectory/tests/run.mjs +++ b/plugins/avatasia/mmc-trajectory/tests/run.mjs @@ -30,6 +30,11 @@ const filter = args.find((a) => !a.startsWith('--')) ?? ''; // rewrite are hashed before the first control runs and checked after the last one. A mismatch is // reported as a failed guard of its own, with the restore command, instead of being inferred from // whatever the next person sees. +// The shipped files a mutate-* control rewrites in place — that is the whole membership rule, and +// it is a rule about files rather than about importance. docs/LAYERS.md sat in this list until it +// moved out of the package to the workspace root: it then hashed to 'absent' on every run, which +// is inert (absent matches absent, so it never reported drift) but means one of the entries this +// guard advertises protecting was not a shipped file at all. const SHIPPED = [ 'miniapp/client/index.html', 'miniapp/node/server.mjs', @@ -37,7 +42,6 @@ const SHIPPED = [ 'tests/client-check.mjs', 'README.md', 'README.zh-CN.md', - 'docs/LAYERS.md', '.minimax-plugin/plugin.json', ]; const ROOT = join(HERE, '..'); From 952af70f32036f54a402cbe4e6a43b5520db6236 Mon Sep 17 00:00:00 2001 From: avatasia Date: Wed, 14 Oct 2026 08:30:25 +0800 Subject: [PATCH 87/88] Take a NUL byte out of server.mjs, and repoint its dangling references The byte came first because it is the one that hid the rest. server.mjs carried a literal NUL inside a string literal that was meant to be empty -- ids.join('\0') rather than ids.join('') -- so the whole file read as binary. grep stopped halfway through it, which is how an audit of that file reported seven references where there were seven and a half's worth of silence: the count came out wrong in the direction that looks like "nothing else to do here". It was correct by luck, which is the part worth keeping. Session ids are all the same length, so a fixed-length alphabet cannot collide and the empty separator is safe -- but nobody wrote that, and a file that reads as binary cannot be reviewed, and a reviewer has no way to learn that from a diff. The other nineteen are a different thing. Those comments told the reader to go somewhere they do not have: they named docs/LAYERS.md, moved out of the plugin directory so it would stop installing onto every reader's machine, and one of them named a bare section number with no file name at all, so grepping for the filename could not find it. A pointer is worse than a missing number -- a reader follows it, does not find it, and concludes that either the document is incomplete or the conclusion is wrong. Each one now points at the guard that actually pins the rule, which is inside the package and can be opened: verify-intrinsic-size, verify-cross-layer, verify-anomaly-model, verify-list-cache, verify-anchor-carry, verify-parsed-rows, verify-raw-lazy, verify-incremental. Two stop being references altogether. The content-visibility alternative is now described where 25 groups per frame is chosen, because the next person to ask is asking about exactly that and the answer is two sentences. node tests/run.mjs 57/57. --- .../mmc-trajectory/miniapp/client/index.html | 39 +++++++++++-------- .../mmc-trajectory/miniapp/node/server.mjs | 16 ++++---- 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index 7b1ce17..ca5825e 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -961,8 +961,7 @@ moment it is wanted, and scroll direction is not a statement about which filters the reader wants. It also meant visibility inside this band was decided from outside it, by a signal that has nothing to do with filtering. What decides visibility here now is the - band's own state — a pinned 上下文代 and the 异常 axis — and nothing else. - See docs/LAYERS.md D19. */ + band's own state — a pinned 上下文代 and the 异常 axis — and nothing else. */ line-height: 14px; } @@ -1509,9 +1508,9 @@ /* Off-screen turn groups stop being laid out. Gated on a class the ledger owns rather than being unconditional, because the first batch is what the height calibration is measured from: those groups have to be laid out for real before anything can be told apart from a - guess. See docs/LAYERS.md D15. + guess. Pinned by tests/verify-intrinsic-size.mjs. - contain-intrinsic-size is NOT set here. One number for every group is the thing D12 + contain-intrinsic-size is NOT set here. One number for every group is the thing that was measured and rejected — real heights span 11× across a session — so each group carries its own on its inline style, computed from its own (rows, bands) once the three heights have been calibrated off the real DOM. A written-down constant would be a guess at someone @@ -2239,9 +2238,10 @@ whole line each. This select no longer lives there: it sits in a `flex: none` tail inside a row that wraps, so `width: 100%` would size it against that tail and the band would grow sideways rather than wrap. Written here rather than left to chance - because a rule that silently starts governing a second layer is exactly the drift - LAYERS.md calls 联动异常 — the override has to exist even though no browser here is - narrow enough to exercise it (未做清单第 6 项). */ + because a rule that silently starts governing a second layer is the drift this file + keeps refusing: the override has to exist even though no browser here is narrow enough + to exercise it. tests/verify-cross-layer.mjs pins that a rule must not acquire a second + layer to govern. */ .filter-row-tail .field { flex: none; } @@ -2873,7 +2873,7 @@

检视

// table too: 异常 replaces this axis outright while a rule is ticked, a range bounds the // session to a stretch of wall-clock time, and a brush bounds it to a stretch of the // overview. Two comments in this file used to claim exclusivity for this axis with no - // qualifier; each of them was describing an earlier page. See docs/LAYERS.md D2. + // qualifier; each of them was describing an earlier page. // // There is deliberately no second row of layer buttons. Three fixed presets looked // like a coarser version of the chips, but any preset the reader outgrows became a @@ -2965,7 +2965,7 @@

检视

// first time the runtime adds another host-only record type; the flag arrives flagged. // // It is here rather than hidden because being able to select exactly these rows is how - // you answer 「这行空白是什么」 without leaving the page. docs/LAYERS.md D17. + // you answer 「这行空白是什么」 without leaving the page. record: function (record) { return !!record && record.internalOnly === true; } @@ -3723,7 +3723,7 @@

检视

// 「no rule is on」 had to be re-derived from three places, and that turning one of them // off without the other two read as a different state rather than as a bug: the // coordination could only live in call order, because there was no shared model for it to - // live in. See docs/LAYERS.md D5. + // live in. tests/verify-anomaly-model.mjs pins that shape. anomaly: { // 'single' or 'multi'. Single by default: a plain click answers 「点什么就显示什么」, // and adding to what is already ticked breaks that. In 'multi' the buttons union. It @@ -4192,7 +4192,7 @@

检视

* sends no count when the prefix was not the whole file, so `— 条` here would be printing the * absence of a number in the shape of a number. Omitting the segment is the honest rendering * of 「这个数不存在」: the reader still has the label and the recency, which is what the picker - * is for. See docs/LAYERS.md §21. */ + * is for. tests/verify-list-cache.mjs pins the gate that produces the absence. */ function sessionOptionLabel(session, now) { var label = truncateText(session && session.label ? session.label : '', 42); var when = formatAgo(session ? session.lastActiveAt : null, now); @@ -5928,8 +5928,13 @@

检视

* * Measured, not chosen. On this session at 18,924 rows across 150 turns, 25 groups is * ~3,150 rows and ~145 ms of row construction — the largest slice of a frame this loop can - * take before the page stops answering the pointer. The full picture, including the - * content-visibility alternative that was measured and rejected, is in docs/LAYERS.md D12. + * take before the page stops answering the pointer. + * + * The alternative was measured and rejected rather than untried: skipping layout for the + * off-screen groups is about 4x faster over the whole build, but it needs one + * contain-intrinsic-size per group and real group heights span 11x across a session, so any + * single value makes the scroll height lie about how much document there is. + * tests/verify-intrinsic-size.mjs pins the per-group sizing used instead. */ var LEDGER_BATCH_GROUPS = 25; @@ -6007,7 +6012,7 @@

检视

to match (measured 715,080 → 616), so there is no scroll position at all between here and the build that puts it back. A pass that is superseded before it gets there has to hand the intent on rather than re-read it from the wreckage — that - is what dropped the reader on the first turn. See docs/LAYERS.md D14. */ + is what dropped the reader on the first turn. tests/verify-anchor-carry.mjs pins it. */ if (!pendingAnchor) { pendingAnchor = { row: firstVisibleRow(), y: window.scrollY || window.pageYOffset || 0 }; } @@ -6682,7 +6687,7 @@

检视

* finishes consumes it. Anything else and each pass reads an anchor out of the wreckage: * measured, a click on any row that landed while a poll build was in flight left the page * anchored on the first turn for good, because the superseded build stopped at the token - * check and never reached the restore. See docs/LAYERS.md D14. + * check and never reached the restore. Pinned by tests/verify-anchor-carry.mjs. * * It is dropped, not restored, by the paths that end a chain with no table to anchor to — * loading, error, no payload, no sessions, no matches. Carrying an intent across one of @@ -7831,7 +7836,7 @@

检视

* Resolves to `{ raw, omitted }`. Whether the message was too large to carry used to be * a field on the record, checked here to short-circuit the fetch; it now travels with * the answer instead, because deciding it meant serializing every message in the session - * on every poll to measure them against a cap — see docs/LAYERS.md D11. + * on every poll to measure them against a cap. Pinned by tests/verify-raw-lazy.mjs. * * `omitted` and `raw === null` are the same state stated two ways, and a caller may test * either. Everything else resolves to the message or rejects: silently substituting the @@ -7857,7 +7862,7 @@

检视

wrap.appendChild(pre); // The two states the reader sees are unchanged; they now arrive with the answer rather // than ahead of it, because deciding which one applied was what made the bulk response - // serialize every message in the session — see docs/LAYERS.md D11. + // serialize every message in the session — see tests/verify-raw-lazy.mjs. pre.textContent = '正在读取原文…'; rawSourceFor(record).then(function (fetched) { if (fetched.omitted === true) { diff --git a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs index 040c5c2..8d02142 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs +++ b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs @@ -76,7 +76,7 @@ const TRAJECTORY_CACHE_ENTRIES = 4; * A 95 MB lineage lands somewhere in 130-290 MB. * * The trade is deliberate: it converts roughly 350 MB of allocation every 2.5 s into one - * resident cost. See docs/LAYERS.md D10. + * resident cost. Pinned by tests/verify-parsed-rows.mjs. */ const PARSED_ROWS_CACHE_BYTES = 256 * 1024 * 1024; /** @@ -852,7 +852,7 @@ export function toolCallSummary(name, args) { * the size was decided: buildRawPayload() had to JSON.stringify the message to measure it, so * every rebuild serialized about 100 MB of message bodies to compare them against a 64 KB cap * and then threw the string away — for a body the page reads for one record at a time, if at - * all. See docs/LAYERS.md D11. + * all. Pinned by tests/verify-raw-lazy.mjs. * * What the row carries now is the position of the message it came from. One row can produce * several records (an assistant message yields its thinking, its text and each tool call), @@ -1876,7 +1876,7 @@ export async function start(context) { /** * Parsed generation rows, keyed by path+size+mtime+limit. Beside trajectoryCache, not * inside it: a whole-session hit still short-circuits before this is touched at all, so - * the two caches never answer for each other. See docs/LAYERS.md D10. + * the two caches never answer for each other. Pinned by tests/verify-parsed-rows.mjs. */ const parsedRows = createParsedRowsCache(PARSED_ROWS_CACHE_BYTES); @@ -1907,7 +1907,7 @@ export async function start(context) { let titleCache = { at: 0, key: '', map: new Map() }; /** @param {readonly string[]} ids */ const sessionTitles = async (ids) => { - const key = ids.join(''); + const key = ids.join(''); const now = Date.now(); if (key === titleCache.key && now - titleCache.at < LIST_CACHE_TTL_MS) return titleCache.map; const map = await readSessionTitles(location.root, ids); @@ -1948,7 +1948,7 @@ export async function start(context) { startedAt, lastActiveAt: entry.lastActiveAt, // The active file's size, which is exactly what it is: `sizeBytes` is not a lineage total - // here, and /api/trajectory's is. Same name, different scope — see docs/LAYERS.md §21. + // here, and /api/trajectory's is. Same name, different scope. Pinned by tests/verify-list-cache.mjs. sizeBytes: entry.sizeBytes, turnCount: peekIsWhole ? peek.turns : null, cacheId, @@ -2263,7 +2263,7 @@ export async function start(context) { const keep = filter === null || filter === link.generation; // The rows are shared with the cache and with every other request that read this file. // Stamping the generation is the only write to them, and one filePath always maps to one - // generation, so it is idempotent — see docs/LAYERS.md D10. + // generation, so it is idempotent. Pinned by tests/verify-parsed-rows.mjs. for (const row of parsedFile.rows) { if (keep) { row[ROW_GENERATION] = link.generation; @@ -2329,7 +2329,7 @@ export async function start(context) { // `?since=` asks for the tail. The whole body is serialized either way — the cache // entry and /raw both need it — so this saves the transfer and the page's JSON.parse, // and not the server's work. Any reading of "41 MB → a few KB, so polling got faster" - // has to carry that with it. See docs/LAYERS.md Stage 4. + // has to carry that with it. Pinned by tests/verify-incremental.mjs. const since = parseSince(url.searchParams.get('since')); const delta = since === null ? null : buildDeltaPayload(payload, since); sendJsonBody(response, 200, delta === null ? body : JSON.stringify(delta), { etag, revalidate: true }); @@ -2367,7 +2367,7 @@ export async function start(context) { } // The one place the untouched message is materialised, for one record, on request. The // size verdict travels with it because the page has to be able to say "omitted" rather - // than silently show something else — see docs/LAYERS.md D11. + // than silently show something else — see tests/verify-raw-lazy.mjs. const built = buildRawPayload(row.message, redact); sendJson(response, 200, { index: wanted, raw: built.value, omitted: built.omitted }); }; From 6407f0133aafba61bf8b5c25e1c3e8748dbb3464 Mon Sep 17 00:00:00 2001 From: avatasia Date: Wed, 14 Oct 2026 08:30:26 +0800 Subject: [PATCH 88/88] Make the guard that says "nothing points at a missing file" able to fail verify-review16.mjs has asserted since it was written that nothing in the package points at a docs/ file that does not ship. It read two READMEs, because those are what a reader opens first. The two shipped source files were never added to it -- and they carried nineteen of those pointers between them. That is the shape this project keeps finding: the check and the thing it is about turned out to be two different objects, and the label claimed a coverage it did not have. Every mutation for the assertion was anchored in a README too, so nothing could have caught it. Both files are now read, through one SHIPPED_TEXT map that the second check below also uses -- two checks that each spelled out their own file list is how they came to disagree about what ships. client-check.mjs is in that map as well, and it turned out to hold a twentieth pointer, which is the first thing the widened assertion found. Three mutations, none deleted: - a pointer goes back into the client source; - a pointer goes back into the server source; - a NUL byte goes back into the server source. verify-review16.mjs 32 -> 33 assertions. mutate-review16.py 27 -> 30 mutations, all caught. --- .../mmc-trajectory/tests/client-check.mjs | 3 +- .../mmc-trajectory/tests/mutate-review16.py | 26 ++++++++++++++ .../mmc-trajectory/tests/verify-review16.mjs | 36 +++++++++++++++++-- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/tests/client-check.mjs b/plugins/avatasia/mmc-trajectory/tests/client-check.mjs index 595578c..9939cb6 100644 --- a/plugins/avatasia/mmc-trajectory/tests/client-check.mjs +++ b/plugins/avatasia/mmc-trajectory/tests/client-check.mjs @@ -197,7 +197,8 @@ check('truncateJson caps at 8000', raw.text.length, 8000); check('truncateJson flags', raw.truncated, true); check('truncateJson small', sandbox.truncateJson({ a: 1 }, 8000).truncated, false); // 「原文过大」 is not a field on the record any more: it arrives with the /raw answer, because -// deciding it meant serializing every message in the session on every poll (LAYERS.md D11). +// deciding it meant serializing every message in the session on every poll (see +// tests/verify-raw-lazy.mjs). // The markdown therefore takes the verdict as its own argument, and this asserts that a // caller who passes it still gets the line — and that one who does not, gets silence. const md = sandbox.buildHandoffMarkdown({ id: 'mvs_1', label: '测试会话' }, turn, failing, raw.text, raw.truncated, true); diff --git a/plugins/avatasia/mmc-trajectory/tests/mutate-review16.py b/plugins/avatasia/mmc-trajectory/tests/mutate-review16.py index da8d185..7fc3961 100644 --- a/plugins/avatasia/mmc-trajectory/tests/mutate-review16.py +++ b/plugins/avatasia/mmc-trajectory/tests/mutate-review16.py @@ -131,6 +131,32 @@ "Before changing the page, read [docs/LAYERS.md](docs/LAYERS.md).", "nothing still points at a docs/ file that does not ship"), + # ---- the same defect one layer over, in the two shipped source files ---- + # + # The assertion those two README mutations exercise was written over the two READMEs and the two + # source files were never added to it. Between them they carried 19 of those pointers, so the + # check was passing over an object it did not read. A mutation that puts one back therefore has + # to land in a source file: without these two, the assertion could have gone on green while the + # defect was fully back, which is the hole restated as a test. + ("the client source points at the document that left the package", + "guess. Pinned by tests/verify-intrinsic-size.mjs.", + "guess. See docs/LAYERS.md D15.", + "nothing still points at a docs/ file that does not ship"), + + ("the server source points at the document that left the package", + "// generation, so it is idempotent. Pinned by", + "// generation, so it is idempotent — see docs/LAYERS.md D10.", + "nothing still points at a docs/ file that does not ship"), + + # And the reason an audit of server.mjs under-reported it by two thirds: one NUL byte, in a string + # literal that was meant to be empty. It was correct by luck and it made the whole file read as + # binary to grep, git and diff, so nothing that scans shipped source could see past it. chr(0) + # rather than an escape so the byte is written literally instead of being re-read as text. + ("a NUL byte ships inside the server source", + "const key = ids.join('');", + "const key = ids.join('" + chr(0) + "');", + "no shipped source file carries a NUL byte"), + # ---- 5. the cache stops being released ---- # Anchored without any line ending on purpose. The first version of these two used \r\n because # server.mjs is CRLF in the working tree — and that is exactly the trap: it only matched because diff --git a/plugins/avatasia/mmc-trajectory/tests/verify-review16.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-review16.mjs index efeafdc..137829f 100644 --- a/plugins/avatasia/mmc-trajectory/tests/verify-review16.mjs +++ b/plugins/avatasia/mmc-trajectory/tests/verify-review16.mjs @@ -39,13 +39,43 @@ const layers = existsSync(join(PKG, 'docs')) ? (readdirSync(join(PKG, 'docs')).j assert('the design document is not in the plugin directory', !existsSync(join(PKG, LAYERS_REL)) && !existsSync(join(PKG, 'docs', 'STAGE1-SPEC.md')), layers); -assert('nothing still points at a docs/ file that does not ship', - !/LAYERS\.md/.test(readme) && !/LAYERS\.md/.test(readmeZh) - && !/STAGE1-SPEC/.test(readme) && !/STAGE1-SPEC/.test(readmeZh)); const serverJs = read('miniapp/node/server.mjs'); const clientHtml = read('miniapp/client/index.html'); const plugin = JSON.parse(read('.minimax-plugin/plugin.json')); +// Every shipped text file, once. Two checks below read this same map, which is the point: the first +// version of the dangling check hard-coded two paths, and the second one invented its own list, so +// the two could not disagree about what ships. If a file is added to the package it belongs here. +const SHIPPED_TEXT = { + 'README.md': readme, + 'README.zh-CN.md': readmeZh, + 'miniapp/client/index.html': clientHtml, + 'miniapp/node/server.mjs': serverJs, + 'tests/make-fixtures.mjs': read('tests/make-fixtures.mjs'), + 'tests/client-check.mjs': read('tests/client-check.mjs'), + '.minimax-plugin/plugin.json': read('.minimax-plugin/plugin.json'), +}; + +// This assertion was written over the two READMEs, because those are what a reader opens first, and +// the two shipped source files were never added to it. They carried 19 of these pointers between +// them — 13 in the client, 7 in the server, and one of those a bare section number with no file name +// at all, so grepping for the filename could not find it. The label claimed a coverage it did not +// have, which is the shape this project keeps finding: the check and the thing it is about turned +// out to be two different objects. +const DANGLING = /\b(?:LAYERS\.md|STAGE1-SPEC)\b/; +const danglingIn = Object.entries(SHIPPED_TEXT).filter(([, t]) => DANGLING.test(t)).map(([rel]) => rel); +assert('nothing still points at a docs/ file that does not ship', + danglingIn.length === 0, danglingIn.join(', ')); + +// A NUL byte shipped inside server.mjs, in a string literal that was meant to be empty — it was +// correct by luck and invisible on purpose. What makes it worth a guard is not the behaviour: it is +// that one byte made the whole file read as binary, so an audit of server.mjs under-reported it by +// two thirds and grep stopped halfway through it. A file that reads as binary cannot be reviewed, +// and a reviewer has no way to learn that from a diff. +const nulIn = Object.keys(SHIPPED_TEXT).filter((rel) => SHIPPED_TEXT[rel].includes('\u0000')); +assert('no shipped source file carries a NUL byte', + nulIn.length === 0, nulIn.join(', ')); + /* ---------- 1. the harness must be able to find Python, and must say so when it cannot ---------- */ // A hardcoded `python` is not portable: it does not exist on a stock Linux or macOS image. The