diff --git a/plugins/avatasia/mmc-trajectory/.gitattributes b/plugins/avatasia/mmc-trajectory/.gitattributes new file mode 100644 index 0000000..6fa2cf0 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/.gitattributes @@ -0,0 +1,25 @@ +# The working tree of this plugin must be byte-identical to what is committed. +# +# Without this file, core.autocrlf=true rewrites every LF blob to CRLF on checkout — and git then +# reports the result as *clean*, because it normalises line endings when it compares. The rewrite is +# invisible to `git status` while it is happening, and it cost this plugin specifically: +# +# 1. Two working copies of the same file compared unequal, so a byte-for-byte check between the +# ship source and the local package reported a difference that was not one. +# 2. `git checkout` of any path silently rewrote it. That is how `tests/fixtures/` landed CRLF on +# disk while its blobs are LF, and why verifying the fixture meant knowing which copy was which. +# 3. Every mutation harness that anchors on a line ending has to guess which ending the checkout +# produced. `mutate-review16.py` did guess, and that was a bug waiting to fire the moment the +# ending moved. The anchors there no longer contain newlines for the same reason. +# +# `text=auto` leaves git free to keep deciding for itself which files are binary; `eol=lf` is the +# part that pins the result. Every blob here is already LF, so adding this changes no committed +# content — it only stops the working tree from disagreeing with it. +# +# This file lives in the plugin directory, not the repository root. At the root it would rewrite +# line endings for every other plugin in the repo, and this is a single-plugin change. +* text=auto eol=lf + +# The one file in this plugin that is not text. `text=auto` would sniff it correctly on its own, but +# naming it keeps the intent visible next to the rule that would otherwise be the only one. +*.png binary \ No newline at end of file diff --git a/plugins/avatasia/mmc-trajectory/.gitignore b/plugins/avatasia/mmc-trajectory/.gitignore new file mode 100644 index 0000000..1e95f57 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/.gitignore @@ -0,0 +1,14 @@ +# Python bytecode caches from running the mutation harnesses. +# +# tests/ has 25+ .py files and they run on every `node tests/run.mjs`, so CPython drops a +# __pycache__/ into the package directory every time. It is not tracked and must never be: it is +# machine-specific, it changes shape between Python versions, and the plugin directory is the +# package that ships. The repository root .gitignore does not cover it. +__pycache__/ +*.pyc + +# tests/run.mjs hashes the shipped files before the mutation controls rewrite them, so a run killed +# mid-mutation is caught on the next one instead of being inferred from whatever the source looks +# like afterwards. The snapshot is machine-specific bookkeeping and this directory is the package +# that ships to readers, so it must never be tracked. +tests/.shipped-snapshot.json \ No newline at end of file diff --git a/plugins/avatasia/mmc-trajectory/.minimax-plugin/plugin.json b/plugins/avatasia/mmc-trajectory/.minimax-plugin/plugin.json index 18f9edd..c6e6fb0 100644 --- a/plugins/avatasia/mmc-trajectory/.minimax-plugin/plugin.json +++ b/plugins/avatasia/mmc-trajectory/.minimax-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "mmc-trajectory", "displayName": "会话轨迹", - "version": "1.0.0", + "version": "1.1.0", "description": "按轮次浏览当前会话的模型轨迹:消息流、思考过程、工具调用与结果、token 与耗时统计。", "author": "avatasia", "icon": "icon.png", diff --git a/plugins/avatasia/mmc-trajectory/README.md b/plugins/avatasia/mmc-trajectory/README.md index 81a7ce7..46765fe 100644 --- a/plugins/avatasia/mmc-trajectory/README.md +++ b/plugins/avatasia/mmc-trajectory/README.md @@ -4,7 +4,20 @@ 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 + +New features on this page grew one at a time, and most of the defects that survived review came from the same place: a control added without asking which layer it belongs to. So answer four questions in writing before writing any code. + +1. **Layer** — which functional layer does this belong to, and which layer's state does it write? +2. **Relation** — beside, mutually exclusive with, or subordinate to which existing controls of that same layer? +3. **Layout** — which container holds it? Does it need a new sticky region, panel, or layer? +4. **Contract** — does it change a number another part of the page already promises? If yes, list every place that reads that number before you change it. + +The fourth is where the coordination bugs come from: one feature changed a number and every other surface kept speaking the old one. Mutual exclusion belongs in one place — one writer per state field — never as a convention spread across functions. + +`tests/` carries the regression guards for all of this; `npm test` runs them. ## Install and use @@ -18,11 +31,15 @@ The author directory is only used to group contributions in the community reposi The page follows the most recent conversation by default and re-reads it every 2.5 seconds, so it stays live while you work; it pauses while the page is hidden. Each read is validated with an HTTP `ETag`: when the session file has not grown, the service answers `304 Not Modified` with no body and the page keeps what it already rendered. An idle tab therefore costs a few bytes per poll instead of the whole trajectory. Use the session picker to switch to any other conversation. No API key and no dependency installation are required — the package is dependency-free and needs no build step. +When the session *has* grown, the poll carries `?since=` alongside its validator and the service answers with the tail instead of the whole session: a payload of the same shape plus a `delta` marker, with `turns` holding only the turns that gained records. On a session of 21,000 records that is roughly 20 KB instead of 43 MB. The cursor is the highest `record.index` the page already holds — the same key `raw?index=` answers on — and it travels only with the validator it was taken against, so a session switch or a manual refresh sends the whole payload instead. `stats` and `session` travel whole in every tail: `stats` is an accumulator over all rows, so a tail cannot be added to it. A page that is ahead of the service (which is what a context-generation switch looks like from here) is answered with the whole payload, since a tail from there is empty and an empty tail is indistinguishable from "nothing to add". This saves transfer and the page's parsing, not the service's work — it still reads, parses and serializes the whole session to build the cache entry and to answer `raw`. + ## What it shows The ledger groups records by turn, following the `turn_id` recorded in the session file. Each record is classified as user, answer, narration, thinking, tool call, tool result, system, or compaction, and each turn header carries that turn's own Token counts and elapsed time. Selecting a record opens a detail panel with summary, raw JSON, tool arguments, tool result, and reasoning, plus a button that hands the record to the Agent's chat input. -**The type chips are the only filter over kinds.** One thing is being filtered — which record kinds are visible — and it is one array in the client, written from exactly three controls: the eight kind buttons, 全部, and 清除筛选. An earlier version also had a row of layer buttons (turn / thinking / call) sitting above the list as presets over those same chips. They were removed, because any preset a reader outgrows becomes a button that contradicts the chips it was supposed to summarise: untick 问答 while leaving 用户 ticked, and the turn button goes dark and claims the conversation is hidden when in fact it was narrowed. One control that cannot drift is worth more than three convenient ones that can. Two further controls cut across kinds instead of over them: a time range, which bounds the ledger to a wall-clock window, and an anomaly filter, whose rules pick out records that are wrong — a failed tool call, a compaction, a turn that never got an answer, an answer that came back empty — rather than records of a particular kind. Neither rewrites the kind array, so every kind chip still answers exactly one question. +**The type chips are the only filter over kinds.** One thing is being filtered — which record kinds are visible — and it is one array in the client, written from exactly four controls: the eight kind buttons, 全部, 清除筛选, and the anomaly filter. An earlier version also had a row of layer buttons (turn / thinking / call) sitting above the list as presets over those same chips. They were removed, because any preset a reader outgrows becomes a button that contradicts the chips it was supposed to summarise: untick 问答 while leaving 用户 ticked, and the turn button goes dark and claims the conversation is hidden when in fact it was narrowed. One control that cannot drift is worth more than three convenient ones that can. Two further controls cut across kinds instead of over them: a time range, which bounds the ledger to a wall-clock window, and an anomaly filter, whose rules pick out records that are wrong — a failed tool call, a compaction, a turn that never got an answer, an answer that came back empty — rather than records of a particular kind. + +**The anomaly filter is the one cut-across control that also rewrites the kind array**, and deliberately so: ticking an anomaly rule calls the same `resetBaseFilters()` the 清除筛选 button calls, which returns kinds to all, clears the search box, drops any brush on the overview strip, and clears the time range. Without that, a reader could tick 工具失败 with 问答 unticked, see nothing, and have no way back except hunting for which of five controls to undo. The cost is stated here because it is a real cost: the chips stop being a filter you keep while you narrow by error — so 「只回答一个问题」 holds for the chips only outside anomaly mode, which is also when they are the only thing still in play. A time range and a search term still never rewrite the kind array. Naming kinds directly costs one extra click and reaches everything a preset did — tick 用户 *and* 答复 for the conversation, untick both for its absence, and any scattered subset in between, such as only the user's questions or only system records. diff --git a/plugins/avatasia/mmc-trajectory/README.zh-CN.md b/plugins/avatasia/mmc-trajectory/README.zh-CN.md index bb26af5..cfe8634 100644 --- a/plugins/avatasia/mmc-trajectory/README.zh-CN.md +++ b/plugins/avatasia/mmc-trajectory/README.zh-CN.md @@ -4,7 +4,20 @@ 按轮次浏览一段 MiniMax Code 对话的模型轨迹:消息流、思考过程、工具调用与结果、token 消耗与每轮耗时。 -作者:[avatasia](https://github.com/avatasia) · 版本:`1.0.0` +作者:[avatasia](https://github.com/avatasia) · 版本:`1.1.0` + +### 动手改这个页面前 + +这个页面的功能是一次加一个长出来的,而活到 review 之后的缺陷大多来自同一个地方:加控件时没先问它属于哪一层。所以实现之前,先用文字答完四个问题。 + +1. **归层** — 它属于哪一层,改的是哪一层的状态? +2. **关联** — 和同层已有控件是并列、互斥,还是从属? +3. **布局** — 放进哪个已有容器?需不需要新的 sticky 区域、面板或层? +4. **口径** — 它是否改变了页面上其他部分已经承诺的数字?若是,先列出**所有**读这个数字的地方,再动手。 + +联动异常出在第四问:一个新功能改了一个数字,而别处仍在说旧的那个。互斥要写在**一处** —— 一个状态字段一个写者 —— 而不是变成散落在各函数间的约定。 + +`tests/` 里是这些规则的回归守卫,`npm test` 跑它们。 ## 安装与使用 @@ -22,7 +35,9 @@ 台账按 `turn_id` 把记录分组,轮次划分完全依据会话文件中记录的 `turn_id`。每条记录被归类为用户、答复、过程、思考、工具调用、工具结果、系统或压缩;每个轮次表头带该轮自己的 token 计数与耗时。选中任意记录会打开详情面板,含摘要、原始 JSON、工具入参、工具结果与思考内容,并提供一个把该记录内容送回 Agent 对话输入框的按钮。 -**类型选项是唯一针对「类型」的筛选。** 被过滤的始终只有一件事——哪些记录类型可见——客户端里它是同一个数组,只由三个控件写入:八个类型按钮、「全部」和「清除筛选」。早期版本在列表上方还排了一行图层按钮(轮次 / 思考 / 调用),作为同一批类型选项的快捷预设,已经删除。任何读者能走偏的预设,最终都会变成一个与它本该概括的选项自相矛盾的按钮:只取消「过程」而保留「用户」,轮次按钮就熄灭并声称整个对话已隐藏,而实际上你只是收窄了它。一个不会跑偏的控件,比三个方便的控件更值钱。 另外两个控件并不在类型之上做筛选,而是横跨类型:时间区间把台账限制在一个墙上时钟窗口内;异常筛选挑出的是「出了问题的记录」—— 工具失败、上下文压缩、整轮没有答复、答复是空的 —— 而不是某一类记录。两者都不改写类型数组,所以每个类型选项始终只回答一个问题。 +**类型选项是唯一针对「类型」的筛选。** 被过滤的始终只有一件事——哪些记录类型可见——客户端里它是同一个数组,只由四个控件写入:八个类型按钮、「全部」、「清除筛选」,以及异常筛选。早期版本在列表上方还排了一行图层按钮(轮次 / 思考 / 调用),作为同一批类型选项的快捷预设,已经删除。任何读者能走偏的预设,最终都会变成一个与它本该概括的选项自相矛盾的按钮:只取消「过程」而保留「用户」,轮次按钮就熄灭并声称整个对话已隐藏,而实际上你只是收窄了它。一个不会跑偏的控件,比三个方便的控件更值钱。 另外两个控件并不在类型之上做筛选,而是横跨类型:时间区间把台账限制在一个墙上时钟窗口内;异常筛选挑出的是「出了问题的记录」—— 工具失败、上下文压缩、整轮没有答复、答复是空的 —— 而不是某一类记录。 + +**异常筛选是唯一一个会改写类型数组的横跨控件,而且是故意的**:勾选任一异常规则时,它调用的是「清除筛选」按钮调的那个 `resetBaseFilters()`,把类型恢复为全选、清空搜索框、丢掉总览条上的刷选、并清掉时间区间。否则读者会勾上「工具失败」而「问答」还取消着,看到一片空白,却不知道该回去改哪一个——一共五个控件可以撤销。代价必须写出来,因为它是真代价:异常模式下类型选项就不再是「一边保留一边按错误收窄」的筛选器 —— 所以「每个类型选项只回答一个问题」这句话只在异常模式之外成立,而异常模式之外也正是类型选项唯一还在起作用的时候。时间区间和搜索词仍然不改写类型数组。 直接点名类型只多花一次点击,却能到达预设原本覆盖的全部范围:勾选「用户」**和**「答复」就是对话,两个都取消就是没有对话,中间任意散落的子集(只看用户提问、只看系统记录)同样可以。 diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index c977097..ca5825e 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -504,6 +504,67 @@ color: inherit; } + /* A rule with nothing to find stays listed — "none of these happened" is a different fact + from "this is not available" — but it is not dressed as a button that will return rows. + The count still carries it, so this is a dim, never a hide. */ + .anomaly-btn.is-empty .anomaly-count { + opacity: 0.55; + } + + /* 异常 sits directly under the type axis because both answer the same question — which + records the ledger shows — so the two read as one strip of choices. Same 16px gutter as + .traj-controls, so its left edge lines up with the 全部 button above it. */ + .anomaly-bar { + display: flex; + flex: none; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-bottom: 1px solid var(--mcode-border); + } + + .anomaly-bar-label { + font-size: 12px; + color: var(--mcode-text-subtle); + } + + /* The 多选 switch. It is a real checkbox so it is announced and operable by keyboard with + no extra work, and it is small so it reads as a switch rather than a third kind of + filter. Its own text, not colour, is what says which mode is in force. */ + .anomaly-multi { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--mcode-text-muted); + cursor: pointer; + } + + .anomaly-multi input { + margin: 0; + } + + .anomaly-multi:has(input:checked) { + color: var(--mcode-text); + } + + .anomaly-bar-note { + font-size: 12px; + color: var(--mcode-text-subtle); + } + + /* In 异常 mode the type axis, the search box and the time range do not apply. They are + dimmed rather than left looking live: a control that cannot change what is on screen + and still takes clicks is the exact defect this axis was moved to fix. The JS also sets + `inert` on them, so keyboard focus cannot land on a control that does nothing. + 会话 / 上下文代 / 刷新 are deliberately unmarked — they choose which session is on + screen, not which records, and have to keep working. */ + body[data-anomaly-mode='on'] [data-filter-scope] { + opacity: 0.4; + filter: grayscale(1); + } + .range-sep { font-size: 12px; color: var(--mcode-text-subtle); @@ -770,22 +831,117 @@ on. The reference styles the glyph rather than the button, so the button stays flat when pressed and only the mark changes. */ - .traj-controls { + /* One sticky band for both filter axes. + + The type row and the 异常 row decide the same question — which records the ledger shows — + so they are pinned as a single block rather than one of them following the other out of + view. Only the type row claimed `position: sticky` before, and it did not survive: with + the ledger below it and both rows scrolled away, a session filtered down to a few hundred + rows gave no sign that anything was filtered at all. The band carries its own background + because it now sits over the ledger, which is opaque underneath it. + + Everything pinned below this measures its offset from it rather than assuming it — see + syncStickyOffsets(). The anomaly row wraps at narrow widths and the note under it comes + and goes, so its height is not a number anyone can write down once. */ + .filter-sticky { position: sticky; top: 0; - z-index: 5; + z-index: 6; + background: var(--mcode-bg); + border-bottom: 1px solid var(--mcode-border); + } + + .traj-controls { display: flex; flex: none; + /* The type row's intrinsic width is 574px — ten Chinese labels, measured, + and it does not move with the viewport. A `flex: none` row is sized by its + content, so the content is also free to widen the page: below 640px the + document measured 622px wide at every viewport and the window scrolled + sideways by up to 262px. + + Capping the row at the viewport is what lets the chips below step down. + `flex-wrap` alone does nothing here, and the reason is worth recording + because it reads like a working fix: a shrink-to-fit flex container is + always exactly as wide as its widest line, so it can never wrap. Declared + wrap, measured with wrap removed — identical, every width. The cap is what + gives the row a width other than its own content, and only then does the + wrap below have something to do. At 640px and up this cap never binds: + measured band heights are unchanged at every width in that range. */ + max-width: 100%; align-items: center; padding: 6px 16px; background: var(--mcode-bg); - border-bottom: 1px solid var(--mcode-border); } - .traj-controls-actions { + /* The 上下文代 picker is L1 — it chooses which slice of the conversation is on screen, + the same class of control as 会话 — so it belongs in the band that does not scroll + away, not in the toolbar that does. It shares the type row when the width allows and + drops to a line of its own when it does not. + + `flex-wrap` is the entire graceful-degradation story, and it is here because + .traj-controls is `flex: none`: without wrapping, a tail that does not fit would push + the row wider than the page instead of stepping down. Wrapping keeps the band honest + at every width — no clipping, no sideways overflow, and no assumption about which + widths are "normal". Nothing in this arrangement is measured by hand: syncStickyOffsets() + reads the band's real offsetHeight on the one exit that can change it (D9). */ + .filter-row { + display: flex; + flex-wrap: wrap; + align-items: center; + column-gap: 12px; + } + + /* Only the right padding: `margin-left: auto` is what separates this from the type row, and a + left padding here is width the row has to find. It is 32px of the 9px shortfall measured on + 2026-10-12 — the arithmetic that predicted the picker would fit had forgotten that this box + carries its own padding at all, so the browser is the only authority on whether they share + a line and this is the second attempt. The right padding keeps the picker's right edge on + the same 790px line the toolbar's fields end on. */ + .filter-row-tail { display: flex; flex: none; align-items: center; + margin-left: auto; + padding: 6px 16px 6px 0; + } + + /* 14px/36px is the toolbar's control size. Every other control in this band is a 20px + chip, so carrying the toolbar's height would grow the band by 16px on every page — and + the band is pinned over the ledger, so its height is reading area the reader does not + get. Only the size changes; the options, their labels and their counts are untouched. + No width is set here: the browser sizes a - -
+
-
+
时间区间
-

留空 = 不限。选一个时间区间,或用快捷按钮。

- -
-
- 异常 - -
-
-
+

留空 = 不限。选一个时间区间,或用快捷按钮。

@@ -2008,7 +2624,9 @@

关键指标

- -
+ + + + -
-
-

检视

- +
+ +
+
+

检视

+ + +
+ + +
+
+

尚未选择记录

+
-

尚未选择记录

-
@@ -2158,9 +2865,15 @@

检视

// would invite reading the two as one category. var KIND_ORDER = ['user', 'steer', 'reply', 'narration', 'thinking', 'toolCall', 'toolResult', 'system', 'compaction']; - // The type chips are the only filter. Which record kinds are visible is one array — - // state.kinds — and every control that changes it writes to that array and nowhere - // else, so a question about "what am I looking at" has exactly one answer. + // Which record kinds are visible is one array — `state.kinds` — and every control that + // changes it writes to that array and nowhere else, so a question about "what am I + // looking at" has exactly one answer. + // + // That is a claim about kinds, and only about kinds. Three other conditions narrow the + // 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. // // 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 @@ -2239,6 +2952,23 @@

检视

record: function (record) { return !!record && record.kind === 'reply' && isNil(record.text); } + }, + { + id: 'hostOnly', + label: '内部记账', + // A record the runtime writes for itself: it goes into the session, and the runtime + // filters it out of the request it sends to the model, but it carries no content — so + // on this page it is a blank row sitting after the reply it belongs to. + // + // Judged on the flag the server derived from the same `hostMetadata.providerVisibility` + // the runtime itself filters on, not on a customType name. A name list goes stale the + // 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. + record: function (record) { + return !!record && record.internalOnly === true; + } } ]; @@ -2246,11 +2976,23 @@

检视

return rule.id; }); - function anomalyRuleLabel(id) { + // Not a rule, so it has no id and no entry in the table: it is the state where no rule + // applies and the row above is in charge again. 重置, not 反选 — nothing is being inverted + // here, the row is being put back the way it was on arrival. Named here rather than in the + // markup so the bar cannot end up offering a way back that the handler and the note + // disagree with. + var ANOMALY_NONE_LABEL = '重置'; + + function anomalyRuleById(id) { for (var i = 0; i < ANOMALY_RULES.length; i += 1) { - if (ANOMALY_RULES[i].id === id) return ANOMALY_RULES[i].label; + if (ANOMALY_RULES[i].id === id) return ANOMALY_RULES[i]; } - return id || DASH; + return null; + } + + function anomalyRuleLabel(id) { + var rule = anomalyRuleById(id); + return rule ? rule.label : (id || DASH); } /** @@ -2721,7 +3463,7 @@

检视

return { text: text.slice(0, cap), truncated: true }; } - function buildHandoffMarkdown(session, turn, record, rawText, rawTruncated) { + function buildHandoffMarkdown(session, turn, record, rawText, rawTruncated, rawOmitted) { var lines = []; lines.push('请分析这条会话轨迹记录,说明它在做什么、有没有问题,以及是否需要改进。'); lines.push(''); @@ -2754,7 +3496,7 @@

检视

if (record.api) lines.push('- 接口:' + record.api); if (record.stopReason) lines.push('- 结束原因:' + record.stopReason); if (record.textTruncated) lines.push('- 提示:正文已被上游截断。'); - if (record.rawOmitted) lines.push('- 提示:原文过大已被省略。'); + if (rawOmitted) lines.push('- 提示:原文过大已被省略。'); if (record.isError || record.error) { lines.push('- 状态:失败'); lines.push('- 错误:' + safeText(record.error)); @@ -2849,7 +3591,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) { @@ -2940,6 +3686,22 @@

检视

// up room. Folding them hands the height back to the ledger. filtersCollapsed: false, kpiCollapsed: false, + // Where the inspector is, on a narrow screen only. 'flow' is its ordinary place in the + // document, below the ledger; 'docked' is pinned to the bottom at half height; 'full' is + // pinned and uncapped. Wide screens never leave 'flow' — in two columns the panel is + // beside the ledger, so there is nothing to dock away from. + inspectorDock: 'flow', + // Which pinned height the panel was at on the way INTO folded, so the minimize button can put + // it back where the reader left it rather than always at half. Read only while folded, and + // written only on the way into folded — see setInspectorDock(). That one property is why it + // can never be stale: outside the folded state nothing looks at it, so there is no window in + // which the dock has moved on and the memory has not. + inspectorDockBeforeFold: 'docked', + // The docked height in px, once the reader has dragged it. null means "they have not", and + // the quarter-window default applies. A separate field rather than an extra mode on + // inspectorDock because it survives the state leaving the dock: coming back to the panel + // later should not silently forget the height the reader just chose. + inspectorDockH: null, // Duration button. Off = every record gets an equal slice on the overview and // recorded timing is ignored; on = recorded start/duration with idle gaps // compressed away. Persisted, because it is a reading preference, not data. @@ -2948,8 +3710,7 @@

检视

overviewSelection: null, overviewViewport: null, overviewHoverId: null, - // The type chips are the only filter, so this array is the only definition of what is on - // screen. Unchecking a kind means its records are simply not part of the render + // Unchecking a kind means its records are simply not part of the render // plan — no placeholder row, no "已折叠 N 行 · 点击展开", nothing standing in for // what is gone. The reference implementation folds with a summary line instead; // this deliberately does not, because a line that claims to be a summary is one @@ -2958,18 +3719,34 @@

检视

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: [], + // 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. 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 + // 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, // and both null is the default: no range, whole session. // - // This is the one filter the KPI follows. The type chips and the search box narrow - // what is *drawn*; a range answers a different question — "what happened in this - // stretch" — and a headline count for the whole session beside a table of the range - // is the mismatch this state exists to remove. + // Of everything on this layer, the KPI follows the range and nothing else. The type + // axis, the search box and the 异常 axis narrow what is *drawn* — and while 异常 is on, + // recordVisible() consults none of them — while a range answers a different question, + // "what happened in this stretch", and a headline count for the whole session beside + // a table of the range is the mismatch this state exists to remove. rangeFrom: null, rangeTo: null, // The highest record index in force when the range was set. An open-ended range is @@ -3004,6 +3781,13 @@

检视

// unchanged session costs a 304 with no body instead of a multi-megabyte reply. etag: '', etagFor: '', + // Cursor for `?since=`: the highest record index already on screen, so a poll that + // finds new records transfers the tail instead of the session. Bound to a cache key + // for the same reason the validator is — a cursor means nothing against a different + // session or a different slice of a rotated one, so a mismatch has to drop it rather + // than ask for a tail from a payload this copy was never part of. + since: 0, + sinceFor: '', // null = follow whatever generation the runtime considers active. A number pins the // ledger to one slice of a rotated history so pre-compaction turns stay reachable. generation: null @@ -3028,6 +3812,7 @@

检视

dom.staleBar = document.getElementById('stale-bar'); dom.toolbar = document.getElementById('toolbar'); dom.kindControls = document.getElementById('kind-controls'); + dom.filterSticky = document.getElementById('filter-sticky'); dom.sessionSelect = document.getElementById('session-select'); dom.generationField = document.getElementById('generation-field'); dom.generationSelect = document.getElementById('generation-select'); @@ -3040,7 +3825,10 @@

检视

dom.rangeQuick = document.querySelector('.range-quick'); dom.rangeHint = document.getElementById('range-hint'); dom.anomalyControls = document.getElementById('anomaly-controls'); + dom.anomalyNote = document.getElementById('anomaly-mode-note'); + dom.anomalyMulti = document.getElementById('anomaly-multi'); dom.windowBar = document.getElementById('window-bar'); + dom.overview = document.getElementById('overview'); dom.headerToggle = document.getElementById('header-toggle'); dom.kpiToggle = document.getElementById('kpi-toggle'); dom.kpiGrid = document.getElementById('kpi-grid'); @@ -3054,6 +3842,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'); @@ -3062,6 +3852,12 @@

检视

dom.copyBtn = document.getElementById('copy-btn'); dom.handoffBtn = document.getElementById('handoff-btn'); dom.inspectorStatus = document.getElementById('inspector-status'); + dom.inspectorPanel = document.getElementById('inspector-panel'); + dom.inspectorMinimize = document.getElementById('inspector-minimize'); + dom.inspectorExpand = document.getElementById('inspector-expand'); + dom.inspectorExpandIcon = document.getElementById('inspector-expand-icon'); + dom.inspectorMinimizeIcon = document.getElementById('inspector-minimize-icon'); + dom.inspectorResize = document.getElementById('inspector-resize'); dom.footerTruncated = document.getElementById('footer-truncated'); dom.footerBinding = document.getElementById('footer-binding'); } @@ -3139,15 +3935,24 @@

检视

// two generations is two different bodies, and reusing a validator across them would // let one silently satisfy the other with a 304. var genKey = state.generation === null ? 'auto' : String(state.generation); - var url = '/api/trajectory?session=' + session - + (state.generation === null ? '' : '&generation=' + encodeURIComponent(String(state.generation))); var cacheKey = session + '#' + genKey; + var url = '/api/trajectory?session=' + session + + (state.generation === null ? '' : '&generation=' + encodeURIComponent(String(state.generation))) + // Only ever asked alongside the validator below. A page that has a cursor for this + // key sends it and its If-None-Match together, so the unchanged case is still a 304 + // and the delta only ever arrives when the session really did grow. A manual refresh + // drops the cursor as well as the validator: it is asking for the truth from the + // server, and a tail is not that. + + (!manual && state.sinceFor === cacheKey && state.since > 0 + ? '&since=' + encodeURIComponent(String(state.since)) : ''); var headers = { Accept: 'application/json' }; // A manual refresh must always reach the server: drop the validator so the // response cannot be revalidated into a 304 the user did not ask for. if (manual) { state.etag = ''; state.etagFor = ''; + state.since = 0; + state.sinceFor = ''; } else if (state.etag && state.etagFor === cacheKey) { headers['If-None-Match'] = state.etag; } @@ -3205,7 +4010,126 @@

检视

}); } - function applyPayload(payload, manual) { + /** + * The highest record index in a payload, which is the cursor the next poll sends. + * + * `record.index` rather than anything else: it is dense from 1, it only ever grows (a + * generation switch starts a new payload rather than renumbering this one), and it is + * already the public key `/raw?index=` answers on. + */ + function payloadCursor(payload) { + var turns = payload && Array.isArray(payload.turns) ? payload.turns : []; + var max = 0; + for (var i = 0; i < turns.length; i += 1) { + var records = Array.isArray(turns[i].records) ? turns[i].records : []; + for (var j = 0; j < records.length; j += 1) { + if (typeof records[j].index === 'number' && records[j].index > max) max = records[j].index; + } + } + return max; + } + + /** + * 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 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 = []; + var at = {}; + var i; + 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) { + 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 = {}; + for (var key in incoming) { + if (Object.prototype.hasOwnProperty.call(incoming, key)) merged[key] = incoming[key]; + } + merged.records = records; + return merged; + } + + /** + * A delta laid onto the payload already in hand, producing a whole payload again. + * + * The tail can start in the middle of a turn, so its turns are merged into the turns + * already here rather than appended after them — appending would show one turn twice + * and split 无答复, which counts turns, across two of them. + */ + function mergeDelta(previous, delta) { + var turns = previous && Array.isArray(previous.turns) ? previous.turns.slice() : []; + var at = {}; + var i; + for (i = 0; i < turns.length; i += 1) { + if (turns[i] && turns[i].id !== undefined) at[turns[i].id] = i; + } + var tail = Array.isArray(delta.turns) ? delta.turns : []; + for (i = 0; i < tail.length; i += 1) { + var incoming = tail[i]; + if (!incoming || incoming.id === undefined) continue; + if (at[incoming.id] === undefined) { + at[incoming.id] = turns.length; + turns.push(incoming); + } else { + turns[at[incoming.id]] = mergeTurnRecords(turns[at[incoming.id]], incoming); + } + } + // `session` and `stats` arrive whole in every delta, so they replace rather than merge. + return { session: delta.session, stats: delta.stats, turns: turns }; + } + + function applyPayload(body, manual) { + // A delta is only ever laid onto a payload this page actually has. Without one — + // first load, a session switch, a manual refresh — the whole payload is the answer. + var mergeable = body && body.delta && state.payload && Array.isArray(state.payload.turns); + var payload = mergeable ? mergeDelta(state.payload, body) : body; var next = signatureOf(payload); var changed = next !== state.signature; state.payload = payload; @@ -3214,6 +4138,10 @@

检视

state.hardError = null; state.stale = false; state.flat = flattenTurns(payload.turns); + // The cursor is the server's own word for how far it got, so a delta is O(1) here and + // only a whole payload has to walk the records to find the end of them. + state.since = mergeable ? body.delta.upTo : payloadCursor(payload); + state.sinceFor = state.etagFor; // The overview reaches the filter holding a bare record, but 无答复 is a property of // the turn the record sits in, so the owning turn has to be reachable from the // record alone. Built here rather than searched per call: this runs on every poll. @@ -3223,10 +4151,11 @@

检视

if (payloadTurns[t] && payloadTurns[t].id) turnById.set(payloadTurns[t].id, payloadTurns[t]); } - if (changed) { - rePinWindow(); - revealSelection(); - } + // Only the window is re-decided here. Whether the selected record is still in the table is + // settled inside renderLedger(), which is the one exit every path that changes what the + // table shows goes through; answering the question here as well would give the field two + // writers, and the poll path is the one that used to leave it stale. + if (changed) rePinWindow(); render(); if (manual) announce('已刷新,正在显示最新数据。'); } @@ -3254,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. 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); - 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; } @@ -3929,7 +4869,7 @@

检视

// it kept the excluded work visible enough that turning off 工具结果 left the // tool lane reading as a solid band, which is indistinguishable from no filter // at all. Positions still come from the full model, so the axis does not move. - if (filtering && !spanPassesFilter(span.record, hasQuery)) continue; + if (filtering && !spanPassesFilter(span.record)) continue; node.style.setProperty('--ov-lane', String(span.lane)); node.style.setProperty('--ov-left', (left * 100).toFixed(4) + '%'); node.style.setProperty('--ov-width', (width * 100).toFixed(4) + '%'); @@ -3982,10 +4922,50 @@

检视

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. + * + * Split out of clearOverviewSelection() because that one is a *control*: it clears the zoom + * too, because a reader pressing it wants the strip back the way it was. A filter reset needs + * only the selection, and needs it for two reasons: a reader who zoomed the strip to find a + * turn should not lose that zoom because they widened the filters, and writing the field + * directly never aborted the brush — so a drag left in flight kept repainting a range the + * filters had just discarded. + */ + function dropOverviewSelection() { + setOverviewSelection(null); + } + function clearOverviewSelection() { if (!state.overviewSelection && !state.overviewViewport) return; - abortOverviewDrag(); - state.overviewSelection = null; + dropOverviewSelection(); state.overviewViewport = null; resetWindowMode(); renderOverview(); @@ -3998,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(); @@ -4196,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; @@ -4301,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(); @@ -4387,14 +5367,33 @@

检视

} /** - * Everything except 异常, in one place. + * Whether one record is in the current view — the one function that answers it (R2). * - * The ledger and the 异常 rule counts both go through here. They each had their own - * copy for a while, and the result was a button promising 246 rows next to a list - * showing 12 — the same 「筛选了但什么都没变」 this file keeps guarding against, moved - * up one level. + * The ledger walks `state.flat` and is handed `{turn, record}`; the overview strip is + * handed a bare record and looks the turn back up. Both ask here now. They each had + * their own copy for a while, and the result was a strip drawing spans for records the + * list refused to render — which reads as 「筛选了但什么都没变」 rather than as a bug, and + * is the shape this file has already paid for once on the kind axis. + * + * 异常 mode replaces the filter outright, strip included, so it is branched on first. + * That is what makes the exclusivity hold by construction instead of by two copies + * happening to agree: with no rule ticked the anomaly branch cannot run at all, because + * passesAnomaly() reads an empty rule list as "off" and lets everything through. Running + * the kind, query and range tests first and the anomaly test last — the order this was + * in before — drew spans for records the list had already stopped filtering on the + * moment a rule was ticked. + * + * `selection` is the overview brush, and it is an argument rather than a read of state + * because one caller passes null on purpose: the brush is drawn on this strip, and a + * strip that hid the records you were dragging a window over could not be used to drag + * one. Every other condition is read from state and has to apply to both callers, so the + * brush is the only thing that legitimately differs between them — and saying so in the + * signature is what stops the next reader from "fixing" the strip to apply it too. */ - function passesBaseFilter(record, selection) { + function recordVisible(record, turn, selection) { + if (state.anomaly.rules.length > 0) { + return passesAnomaly(record, { turn: turn || null, liveTurn: liveTurn() }, state.anomaly.rules); + } if (!hasKind(record, state.kinds)) return false; if (state.query && !matchesQuery(record, state.query)) return false; // The overview brush is a further filter, applied after kind and text so that @@ -4404,31 +5403,77 @@

检视

return true; } - function anomalyContext(entry) { - return { turn: entry.turn, liveTurn: liveTurn() }; - } - /** - * How many records each rule would keep, given every other filter exactly as it stands. - * Counted through passesBaseFilter so a button can never advertise a number the ledger - * will not deliver. `any` counts records, not rule hits: one row can trip two rules, - * and 「全部异常」 promising more rows than exist is the same lie as promising fewer. + * What each 异常 button would show if it were the only one ticked. + * + * Counted over every record with no other filter applied, because that is exactly what + * 异常 mode does: the type axis, the search box and the time range all stop applying the + * moment a rule is on. Counting through recordVisible() — which is what this used to do, + * while the ledger was also applying those filters — produced a number no click could + * ever deliver, and that is the whole of the original defect: a button promising 283 + * rows above a list showing 1,494. The only fix is to count the way the ledger filters, + * so the invariant is stated as a property of this function: the number on a button is + * what you get when you tick that button and nothing else. + * + * Two units, because the rules are two kinds of thing. A record rule counts 条. A turn + * rule counts 轮, and counts the *turns*, not the records inside them: 无答复 fires on + * every record of a turn that never answered, so counted in records it advertised 1,244 + * "unanswered" rows that carry no sign of being unanswered — while the session was in + * fact broken a dozen times. A unit is a claim about what the number counts, and the two + * cannot share one. + * + * `any` counts records, not rule hits: one row can trip two rules, so it never equals the + * sum of the parts. That is why it is labelled as the union and not as a total. + * + * `selected` is the union of the rules that are actually ticked, which is a different + * number from `any` whenever the selection is not the whole table. It is counted here rather + * than recomputed where it is used because this loop already holds `hits` for every record: + * asking again would be a second full walk of a session that can hold twenty thousand rows + * and re-renders on every poll. And `visibleEntries()` filters on exactly this predicate, so + * `selected` is the length of the list under the buttons — the same promise every other + * number on this row makes. + * + * @returns {{byRule: Object, unit: Object, any: number, selected: number, total: number}} */ function anomalyCounts() { var byRule = {}; - for (var i = 0; i < ANOMALY_RULES.length; i += 1) byRule[ANOMALY_RULES[i].id] = 0; + var unit = {}; + for (var i = 0; i < ANOMALY_RULES.length; i += 1) { + byRule[ANOMALY_RULES[i].id] = 0; + unit[ANOMALY_RULES[i].id] = ANOMALY_RULES[i].turn ? '轮' : '条'; + } var any = 0; - var selection = state.overviewSelection; + var selected = 0; + var sel = state.anomaly.rules; var live = liveTurn(); + var counted = {}; for (var j = 0; j < state.flat.length; j += 1) { var entry = state.flat[j]; - if (!passesBaseFilter(entry.record, selection)) continue; var hits = recordAnomalyRules(entry.record, { turn: entry.turn, liveTurn: live }); if (!hits.length) continue; any += 1; - for (var k = 0; k < hits.length; k += 1) byRule[hits[k]] += 1; + for (var s = 0; s < hits.length; s += 1) { + if (sel.indexOf(hits[s]) >= 0) { + selected += 1; + break; + } + } + for (var k = 0; k < hits.length; k += 1) { + var id = hits[k]; + var rule = anomalyRuleById(id); + if (!rule || !rule.turn) { + byRule[id] += 1; + continue; + } + // A turn rule reaches every record of its turn, so the turn is what gets counted, + // and only the first record of that turn to arrive counts it. + var key = id + '#' + entry.turn.index; + if (counted[key]) continue; + counted[key] = true; + byRule[id] += 1; + } } - return { byRule: byRule, any: any }; + return { byRule: byRule, unit: unit, any: any, selected: selected, total: state.flat.length }; } /** @@ -4445,30 +5490,77 @@

检视

return out; } + /** + * The rows of the table, in order. + * + * Which of the two filter systems is in force is decided inside recordVisible(), not + * here: 异常 mode replaces the type axis, the search box, the time range and the + * overview brush rather than stacking with them, and that either/or belongs to one + * place. This loop supplies only what it alone has — the turn off each flat entry, and + * the brush, which the ledger does apply (the strip deliberately does not; the reason + * is in recordVisible()). + */ function visibleEntries() { var out = []; var selection = state.overviewSelection; for (var i = 0; i < state.flat.length; i += 1) { var entry = state.flat[i]; - if (!passesBaseFilter(entry.record, selection)) continue; - if (!passesAnomaly(entry.record, anomalyContext(entry), state.anomalyRules)) continue; + if (!recordVisible(entry.record, entry.turn, selection)) continue; out.push(entry); } return out; } - - function revealSelection() { - if (!state.selectedId) return; - for (var i = 0; i < state.flat.length; i += 1) { - if (state.flat[i].record.id === state.selectedId) { - var needed = state.flat.length - i; - if (needed > state.limit) state.limit = Math.min(needed, state.flat.length); - return; - } - } - state.selectedId = null; - } - + /** + * Whether the record the inspector is showing still belongs to the table. + * + * 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 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 *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(). + * + * 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 + */ + function revealSelection(entries) { + if (!state.selectedId) return false; + for (var i = 0; i < entries.length; i += 1) { + if (entries[i].record.id === state.selectedId) return false; + } + state.selectedId = null; + return true; + } + + /** + * The selected record, or null. + * + * This looks the id up in `state.flat` on purpose, and it is not where the selection is + * judged: `flat` is the unfiltered session, and treating it as "what the reader can see" is + * the bug this file's revealSelection() exists to prevent. By the time anything calls this, + * `state.selectedId` has already survived the filter that produced the rows on screen, so a + * plain lookup is correct — and staying a lookup keeps the five readers of it (the inspector, + * copy, hand-off, the hand-off button) from each re-deciding the question for themselves. + * + * @returns {{record: any}|null} + */ function selectedRecord() { if (!state.selectedId) return null; for (var i = 0; i < state.flat.length; i += 1) { @@ -4665,32 +5757,27 @@

检视

return state.query !== '' || state.kinds.length !== KIND_ORDER.length || state.overviewSelection !== null || hasTimeRange() - || state.anomalyRules.length !== 0 + || state.anomaly.rules.length !== 0 ; } /** - * Whether one record belongs in the current filter. + * The strip's way into recordVisible(), and nothing else. * - * This must agree with visibleEntries() step for step. When it did not, the overview - * strip drew spans for records the list refused to render, which is how "filtered - * but nothing changed" happened. + * It looks the owning turn back up because the strip is handed a bare record while the + * ledger gets `{turn, record}` off the flat entry — the same question, the same table, + * one lookup apart. It passes no brush, for the reason recordVisible() gives. + * + * What it must not do is hold a predicate of its own. It used to hold a copy of the + * whole decision, and the two copies drifted: the strip drew spans for records the + * list refused to render, which reads to the reader as "filtered but nothing changed" + * rather than as a bug. Adapting the caller's argument shape is not a second opinion; + * answering the question again would be. */ - function spanPassesFilter(record, hasQuery) { - if (!hasKind(record, state.kinds)) return false; - if (hasQuery && !matchesQuery(record, state.query)) return false; - // The range is a filter here too. Leaving it out made the overview draw spans for - // records the list refused to render — the same "filtered but nothing changed" this - // function exists to prevent. - if (!inTimeRange(record)) return false; - // 异常 belongs here for the same reason. The strip is handed a bare record, so the - // owning turn comes back out of turnById instead of off the flat entry the ledger - // gets — the same question, the same table, one lookup apart. + function spanPassesFilter(record) { var owner = record ? turnById.get(record.turnId) : null; - if (!passesAnomaly(record, { turn: owner || null, liveTurn: liveTurn() }, state.anomalyRules)) return false; - return true; + return recordVisible(record, owner || null, null); } - function buildTurnHead(turn, entries) { // Plain text, not a button. There is no per-turn fold left to toggle, and a // control that does nothing is worse than no control. @@ -4834,6 +5921,83 @@

检视

return state.limit; } + /* ---------------- batched row build ---------------- */ + + /** + * Turn groups built per frame once a window is too large for one. + * + * 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 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; + + /** + * Bumped by every renderLedger() before it touches the ledger. + * + * A batch in flight carries the token it started with and stops the moment that no longer + * matches, so a new exit never has to hunt down and abort a callback: there is exactly one + * thing to check and it is this number. Without it, the ten writers of "what the table + * shows" would have to interleave into one ledger and the reader would see two tables + * interleaved in the order their row loops happened to finish. + */ + var ledgerBuildToken = 0; + + /** {rows, total} while a batched build runs, null the rest of the time. */ + var ledgerProgress = null; + + /** + * The last (shown, hiddenAbove, total) updateWindowBar() was given. + * + * Kept so that progress can be re-reported by re-running that function rather than by + * writing the count node directly — the window bar has one writer and a second one is the + * two-writers defect this exit exists to prevent (D8). + */ + var ledgerWindow = null; + + /** A poll tick that arrived while the ledger was still being built. */ + var pollDeferred = false; + + /** Whether a batched build is running. One tick every 2.5 s asks this before it redraws. */ + function ledgerBuilding() { + return ledgerProgress !== null; + } + + /** + * Report how far a batched build has got, or that it is over. + * + * @param {number|null} rows rows built so far, or null when the build has finished + * @param {number} [total] the rows the window will hold when it finishes + */ + function setLedgerProgress(rows, total) { + ledgerProgress = rows === null ? null : { rows: rows, total: total }; + if (ledgerWindow) { + updateWindowBar(ledgerWindow.shown, ledgerWindow.hiddenAbove, ledgerWindow.total); + } + } + + /** + * The one poll tick that was held back while the ledger was being built. + * + * An 全部 window over 18,924 rows takes about 4 s to lay out — longer than the poll interval + * — so admitting every tick would restart the build before it ever reached the end and the + * reader would watch the count reset forever. Instead the tick is remembered and this runs + * once the build finishes, which brings the session up to date without making the reader + * pay for a second build they did not ask for. + */ + function flushDeferredPoll() { + if (!pollDeferred) return; + pollDeferred = false; + fetchSessions(); + fetchTrajectory(false); + } + function renderLedger() { var ledger = dom.ledger; /* Restoring the raw pixel offset is what made the list creep out from under the @@ -4841,14 +6005,51 @@

检视

whole slice forward: the oldest row falls off the top and everything below it rises by one row. Restoring scrollY keeps the pixel but not the content, so each poll quietly moved the page. Anchor on the topmost row that is actually - on screen and restore *its* position instead. */ - var anchor = firstVisibleRow(); - var scrollY = window.scrollY || window.pageYOffset || 0; + on screen and restore *its* position instead. + + The intent is captured once per chain, not once per pass. clear(ledger) below + collapses a 716,000px document to about 1,500px and the browser clamps scrollY + 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. tests/verify-anchor-carry.mjs pins it. */ + if (!pendingAnchor) { + pendingAnchor = { row: firstVisibleRow(), y: window.scrollY || window.pageYOffset || 0 }; + } + var anchor = pendingAnchor.row; + var scrollY = pendingAnchor.y; + + /* Any build still in flight belongs to the table this call is about to replace. + Bumping the token here retires it — there is no abort call to get wrong and no + window in which two loops append to the same list. + + It sits *above* clear(ledger) rather than further down, because the four early + returns between them clear the ledger as well. With the bump below them a pass + that returned early retired nothing, leaving the previous build alive to append + turn groups into a skeleton — and, since it would then reach restoreRowAnchor() + with an anchor from a table that no longer exists, to move the page for it. */ + ledgerBuildToken += 1; + /* Skipping has to be off for the whole build, not just for the first measurement. + + `is-skipping-offscreen` lives on the ledger element, and clear(ledger) empties its + children without touching its own class — so the class set by one build is still there + for the next one. Every group appended from the second build onwards therefore lands + with `content-visibility: auto` already applied, and a skipped element is laid out by + its contain-intrinsic-size: the group measures 0, sizeBuiltGroup writes that 0 onto + it, and a group sized 0px occupies no space, so it can never re-enter the viewport and + is never re-rendered. The ledger stays collapsed — 140 KB of rows inside a 2,000 px + page — and because it collapses, scrolling up a little lands the reader at the top + instead of one row higher. + + Ordering the class-on after the measurement loop is necessary but not sufficient: it + only fixes the first build after a page load. This line is what fixes the rest. */ + ledger.classList.remove('is-skipping-offscreen'); clear(ledger); if (state.loading) { ledger.appendChild(buildSkeleton()); dom.ledgerMeta.textContent = '加载中'; + forgetScrollAnchor(); return; } @@ -4857,12 +6058,14 @@

检视

stateBlock(null, '轨迹暂时不可用', '服务恢复后会自动重试,页面结构保持不变。') ); dom.ledgerMeta.textContent = '不可用'; + forgetScrollAnchor(); return; } if (!state.payload) { ledger.appendChild(buildSkeleton()); dom.ledgerMeta.textContent = '加载中'; + forgetScrollAnchor(); return; } @@ -4876,6 +6079,7 @@

检视

); dom.ledgerMeta.textContent = '共 0 条'; applyFilterScope(); + forgetScrollAnchor(); return; } @@ -4883,23 +6087,83 @@

检视

var total = state.flat.length; applyFilterScope(); + var windowed = matched.length > state.limit ? matched.slice(matched.length - state.limit) : matched; + var hiddenAbove = matched.length - windowed.length; + + // 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 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. + // + // 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(matched)) renderInspector(); + if (matched.length === 0) { var reset = el('button', 'btn', '清除筛选'); reset.type = 'button'; reset.addEventListener('click', function () { clearFilters(); }); - ledger.appendChild( - stateBlock(null, '当前筛选条件下没有命中记录', '共 ' + total + ' 条记录中没有匹配项,可以放宽类型或搜索词。', reset) - ); + // The hint has to name a control the reader can actually use. Under an anomaly rule the + // type chips and the search box are inert — syncAnomalyMode() marks them so, precisely + // because ticking a rule already reset them — so 「放宽类型或搜索词」 points at two things + // that do nothing when pressed, and the reader is left hunting for which of five controls + // to undo. 清除筛选 is the one control that always works, and it is already on the button. + var anomalyOn = state.anomaly.rules.length > 0; + var hint = anomalyOn + ? '共 ' + total + ' 条记录里没有符合所选异常规则的记录。类型与搜索在异常模式下不参与筛选,要回到全部记录请点「清除筛选」。' + : '共 ' + total + ' 条记录中没有匹配项,可以放宽类型或搜索词。'; + ledger.appendChild(stateBlock(null, '当前筛选条件下没有命中记录', hint, reset)); + forgetScrollAnchor(); return; } - var windowed = matched.length > state.limit ? matched.slice(matched.length - state.limit) : matched; - var hiddenAbove = matched.length - windowed.length; - 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 — @@ -4911,30 +6175,118 @@

检视

var lastTurn = groups.length ? groups[groups.length - 1].turn.index : null; dom.ledgerMeta.textContent = turnRangeLabel(firstTurn, lastTurn); - for (var i = 0; i < groups.length; i += 1) { - var group = groups[i]; - var section = el('section', 'turn-group'); - section.setAttribute('aria-label', '第 ' + (isCount(group.turn.index) ? group.turn.index : DASH) + ' 轮'); - section.appendChild(buildTurnHead(group.turn, group.entries)); - var list = el('ul', 'row-list'); - // Every entry that survived the filters becomes a row. There is no second pass - // that hides anything, so what is on screen is exactly what was selected. - var rails = railStates(group.entries); - for (var j = 0; j < group.entries.length; j += 1) { - var item = el('li'); - if (group.entries[j].record.kind === 'compaction') { - item.className = 'compaction-cell'; - item.appendChild(buildCompactionBand(group.entries[j])); - } else { - item.appendChild(buildRow(group.entries[j], rails[j])); + /** + * One batch: build up to LEDGER_BATCH_GROUPS turns, then hand the frame back. + * + * Declared here rather than beside the other helpers so that the row loop — including the + * compaction routing, which client-check.mjs reads out of renderLedger() for exactly this + * reason — stays the text it has always been. What the rows *are* has not changed; only + * how many are built before the browser gets the thread back. + * + * Nothing here changes what the window promises to show. 「显示全部」 still means every + * record; it just stops meaning it inside a single unbroken second and a half. + */ + var build = { token: ledgerBuildToken, at: 0, rows: 0, bands: 0, heights: null }; + var buildTotal = 0; + for (var b = 0; b < groups.length; b += 1) buildTotal += groups[b].entries.length; + + function buildStep() { + // A newer renderLedger() owns the ledger now. Stop without touching it: the rows half + // built here belong to a table that no longer exists, and the exit that replaced it has + // already cleared the list. + if (build.token !== ledgerBuildToken) return; + var built = 0; + while (build.at < groups.length && built < LEDGER_BATCH_GROUPS) { + var group = groups[build.at]; + build.at += 1; + var section = el('section', 'turn-group'); + section.setAttribute('aria-label', '第 ' + (isCount(group.turn.index) ? group.turn.index : DASH) + ' 轮'); + section.appendChild(buildTurnHead(group.turn, group.entries)); + var list = el('ul', 'row-list'); + // Every entry that survived the filters becomes a row. There is no second pass + // that hides anything, so what is on screen is exactly what was selected. + var rails = railStates(group.entries); + var bands = 0; + for (var j = 0; j < group.entries.length; j += 1) { + var item = el('li'); + if (group.entries[j].record.kind === 'compaction') { + item.className = 'compaction-cell'; + item.appendChild(buildCompactionBand(group.entries[j])); + bands += 1; + } else { + item.appendChild(buildRow(group.entries[j], rails[j])); + } + list.appendChild(item); } - list.appendChild(item); + section.appendChild(list); + build.rows += group.entries.length; + build.bands += bands; + // Every group is sized, including the ones built before the calibration existed — + // a group left unsized collapses to nothing the moment the class goes on, which is + // how a scrollbar ends up shorter than the table it is supposed to describe. On the + // first batch build.heights is still null, so this is a no-op and sizeBuiltGroup() + // below is what covers those groups. + applyIntrinsicSize(section, group.entries.length, bands, build.heights); + ledger.appendChild(section); + built += 1; } - section.appendChild(list); - ledger.appendChild(section); - } - restoreRowAnchor(anchor, scrollY); + // Calibrate after the first batch, never before: these groups are the only ones that + // have been laid out for real, and reading a height off a skipped subtree would be + // reading the prediction back. Read once — after this the class is on, and the same + // measurement would be quoting itself. + // + // The class therefore goes on LAST, after both measurements, and that ordering is the + // fix rather than a style preference. `content-visibility: auto` sizes a skipped element + // by its `contain-intrinsic-size`, and an element with none is laid out as if it were + // empty — so turning skipping on first makes every group in this batch measure 0, and + // sizeBuiltGroup then writes that 0 onto it. A group sized 0px occupies no space, so it + // can never come back into the viewport, so it is never re-rendered: the ledger stays + // collapsed for good and the document is two thousand pixels tall above a hundred and + // forty kilobytes of rows. It only showed up in the default window, where 200 records + // span fewer turns than LEDGER_BATCH_GROUPS and therefore the *whole* ledger is this + // batch; D15's own A/B ran on 显示全部, where the batch is long enough to sit in the + // viewport and get measured for real. + if (!build.heights) { + build.heights = calibrateRowHeights(ledger); + var firstBatch = ledger.querySelectorAll('.turn-group'); + for (var f = 0; f < firstBatch.length && f < LEDGER_BATCH_GROUPS; f += 1) { + sizeBuiltGroup(firstBatch[f]); + } + ledger.classList.add('is-skipping-offscreen'); + } + + if (build.at < groups.length) { + setLedgerProgress(build.rows, buildTotal); + window.requestAnimationFrame(buildStep); + return; + } + + setLedgerProgress(null); + restoreRowAnchor(anchor, scrollY); + // Before the measurement, not after it. flushDeferredPoll() only starts fetches, so + // nothing can re-enter the ledger from inside it today — but the exit that does this + // measuring has to stay the last word, and a trailing statement is the one place where + // a future synchronous call would quietly measure an empty page. + flushDeferredPoll(); + // Measured here rather than at the end of render(): this is the one exit every path that + // changes what the table shows goes through — the filters that call render() and the nine + // that call renderLedger() directly. A filter that grows the band therefore cannot leave + // the offsets above describing the page as it was before the reader touched anything. + // syncAnomalyMode(), which writes the 异常 note that gives the band its third line, runs + // before this point inside render(), so the height is already final here. + // The early returns above deliberately do not measure: none of them can change the band, + // and emptying the ledger mid-flight is not a reason to move a pinned strip. + syncStickyOffsets(); + } + + // Windows of a few groups never reach the yield: the while loop inside buildStep consumes + // all of them and falls straight through, so the default 200-row table is built exactly as + // it always was, in one pass, with no progress written and no frame spent. Seeding the + // progress only when there is more than one batch is what keeps that true — otherwise the + // common path paid for a 「正在显示 0 / 200 条」 that no reader ever saw. + if (groups.length > LEDGER_BATCH_GROUPS) setLedgerProgress(0, buildTotal); + buildStep(); } /** @@ -4964,15 +6316,49 @@

检视

clear(wrap); wrap.appendChild(parts.actions); } + // Kept so a mid-build progress tick can be re-reported by re-running this function rather + // than by writing the count node from somewhere else. See ledgerWindow's comment. + ledgerWindow = { shown: shown, hiddenAbove: hiddenAbove, total: total }; applyAxisButton(parts.axis); applyWindowMode(parts, hiddenAbove); // The session's total turn count lives here, beside the record counts it explains — // not in the panel title, which describes only the rows on screen. var sessionTurns = state.payload && state.payload.stats ? state.payload.stats.turns : null; + if (ledgerProgress) { + // Mid-build the count reports what is actually on screen, not what the window will hold. + // 「已显示 18,924 条」 above three thousand rows that exist is the page promising a number + // it has not reached yet, which is the same defect as the one this whole exit exists to + // prevent — one surface describing a table that is not there. + // + // No 命中 here, deliberately. `ledgerProgress.total` is already the window being built, so + // 「正在显示 R / W」 is not something a reader can add to 共 N — the line has no ambiguity + // to fix. And `shown` / `hiddenAbove` below are the *previous* render's, held in + // ledgerWindow, so a 命中 derived from them here would print a stale filter result beside a + // live progress count. + parts.count.textContent = + (isCount(sessionTurns) ? '全场 ' + formatInt(sessionTurns) + ' 轮 · ' : '') + + '共 ' + formatInt(total) + ' 条 · 正在显示 ' + formatInt(ledgerProgress.rows) + + ' / ' + formatInt(ledgerProgress.total) + ' 条'; + return; + } + // 「共 N 条」 is the whole session, unfiltered. The numbers beside it are not: 已显示 is the + // window and 更早的 is what sits above it, and those two add up to what the *filter* matched, + // not to the session. A reader who adds them lands on a third answer with nothing on the line + // saying which pair the arithmetic was supposed to use. Print the filter result and the line + // closes on itself. + // + // Derived rather than passed: `windowed` plus what is above it *is* the matched count, so this + // costs no third argument and cannot drift from the two numbers printed beside it. A + // caller-supplied figure would be a fourth copy of one fact, which is how two copies of a + // count start disagreeing. + var matchedCount = shown.length + hiddenAbove; + // Only when it is a different number: an unfiltered session would otherwise be told 共 24,516 + // and 命中 24,516 on one line, which is the same fact twice and reads as two. + var matchedLabel = matchedCount === total ? '' : ' · 命中 ' + formatInt(matchedCount) + ' 条'; parts.count.textContent = (isCount(sessionTurns) ? '全场 ' + formatInt(sessionTurns) + ' 轮 · ' : '') + - '共 ' + formatInt(total) + ' 条 · 已显示 ' + formatInt(shown.length) + ' 条' + + '共 ' + formatInt(total) + ' 条' + matchedLabel + ' · 已显示 ' + formatInt(shown.length) + ' 条' + (hiddenAbove > 0 ? ' · 更早的 ' + formatInt(hiddenAbove) + ' 条未加载' : ''); } @@ -5040,6 +6426,188 @@

检视

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; + /** + * 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. + * + * 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 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 + // 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', up ? '跳到轨迹的最上(最早一条已加载的记录)' : '跳到轨迹的最低(最新一条已加载的记录)'); + } + + /** + * 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 () { + // 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(); + } + /** * The mode button names the action; the count beside it names the state. * @@ -5108,6 +6676,94 @@

检视

return null; } + /** + * The reader's scroll intent, carried across builds. + * + * It has to be carried, because there is no moment where the position survives on its + * own. renderLedger() clears the ledger — which takes the document from 716,000px to + * about 1,500px, and the browser clamps scrollY to match — and the position only comes + * back at the end of the build, from restoreRowAnchor(). So the first pass of a chain + * writes down where the reader was, every pass after it re-uses that, and the pass that + * 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. 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 + * those would apply it to a different session or a different filter than the one it was + * measured against. + * + * @type {{row: {id: string, top: number} | null, y: number} | null} + */ + var pendingAnchor = null; + + /** End a scroll-intent chain without restoring it. One writer, so one place. */ + function forgetScrollAnchor() { + pendingAnchor = null; + } + + /** + * The three row heights, read off groups that are really laid out. + * + * Never a constant. These are CSS pixels in whatever font, zoom and viewport the reader has, + * and the numbers move when the window is resized — so they are measured here, from the DOM + * the first batch actually built, and thrown away with the build. + * + * A median rather than a mean: one group with a stray long row must not shift every + * prediction on the page. + * + * @returns {{head: number, row: number, band: number}|null} null if the batch is not there + */ + function calibrateRowHeights(ledgerEl) { + var heads = ledgerEl.querySelectorAll('.turn-head'); + var rows = ledgerEl.querySelectorAll('.row-list > li:not(.compaction-cell)'); + var bands = ledgerEl.querySelectorAll('li.compaction-cell'); + if (!heads.length || !rows.length) return null; + return { + head: medianHeight(heads), + row: medianHeight(rows), + band: bands.length ? medianHeight(bands) : medianHeight(rows) + }; + } + + /** The middle of a set of element heights, in CSS pixels. 0 for an empty set. */ + function medianHeight(nodes) { + var out = []; + for (var i = 0; i < nodes.length; i += 1) { + var h = nodes[i].getBoundingClientRect().height; + if (h > 0) out.push(h); + } + if (!out.length) return 0; + out.sort(function (a, b) { return a - b; }); + return out[Math.floor((out.length - 1) / 2)]; + } + + /** + * Put one built group's predicted height on it, from the heights it was actually built with. + * + * Used for the first batch, which exists before there is anything to predict from. It reads + * the group's own DOM rather than its (rows, bands), because that is the same number and + * measuring it is the honest way to get it. + */ + function sizeBuiltGroup(section) { + if (!section) return; + section.style.containIntrinsicSize = + Math.round(section.getBoundingClientRect().height) + 'px'; + } + + /** + * A group's predicted height from its own shape: one head, then rows and bands. + * + * `auto` on the left is what lets the browser prefer the size it has already rendered once + * the reader has scrolled past — so scrolling down and back does not accumulate drift. + */ + function applyIntrinsicSize(section, rows, bands, heights) { + if (!heights) return; + var h = heights.head + rows * heights.row + bands * heights.band; + section.style.containIntrinsicSize = 'auto ' + Math.round(h) + 'px'; + } + /** * Put the anchored row back where it was. * @@ -5115,8 +6771,13 @@

检视

* can arrive to push it out of the window entirely. Then there is nothing to hold on * to and the old pixel offset is the best answer available, so fall back to it * rather than jumping to the top. + * + * Consuming the intent belongs here and not at the call site: this is the one place a + * chain ends, and a chain that ended without being consumed would hand its anchor to + * whatever came next. */ function restoreRowAnchor(anchor, fallbackY) { + forgetScrollAnchor(); if (anchor) { var after = anchorOffset(anchor.id); if (after !== null) { @@ -5175,6 +6836,14 @@

检视

*/ function applyFilterScope() { if (!dom.kpiScope) return; + 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. + dom.kpiScope.textContent = '整场会话 · 当前只看异常记录'; + dom.kpiScope.hidden = false; + return; + } if (hasTimeRange()) { var stats = kpiStats(); var scope = '已按区间 ' + timeRangeLabel() + ' 统计'; @@ -5274,15 +6943,31 @@

检视

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 }; + } + + /** + * Every axis back to its default, from the empty ledger's 「清除筛选」 button. + * + * The base axes are reset by resetBaseFilters() rather than assigned here, because two + * functions that both mean 「put it back」 and carry different fields is how the empty-state + * button came to leave a brush standing: it read as clearing the filters, and it cleared all + * but the one that visibleEntries() also reads. + */ function clearFilters() { - state.kinds = KIND_ORDER.slice(); - state.query = ''; - state.anomalyRules = []; - clearTimeRange(); + resetBaseFilters(); + state.anomaly = defaultAnomaly(); resetWindowMode(); - if (dom.searchInput) dom.searchInput.value = ''; - syncKindButtons(); - syncAnomalyButtons(); + if (dom.anomalyMulti) dom.anomalyMulti.checked = false; + syncAnomalyButtons(anomalyCounts()); render(); } @@ -5424,6 +7109,411 @@

检视

/* ---------------- inspector ---------------- */ + var INSPECTOR_DOCK_QUERY = '(max-width: 1080px)'; + // The height the panel gets when the reader has not asked for another one. 2/5 of the + // window, on the reviewer's instruction (it was 1/4 through D22–D26). + // + // One constant, because this number used to be written twice — applyInspectorDock() and + // inspectorDockHeightNow() each had their own `innerHeight / 4`. Two writers of one number is + // how the two halves start disagreeing, and the failure is invisible: the default height the + // panel is given and the height a drag starts from are the same promise, so if only one of + // them is changed the drag starts from a height the reader never saw. + var INSPECTOR_DOCK_SHARE = 0.4; + /** The default docked height. The single place this number is computed. */ + function inspectorDefaultDockHeight() { + return Math.round(window.innerHeight * INSPECTOR_DOCK_SHARE); + } + // Below this the panel head and its handle stop fitting, so a drag that would go further is + // refused rather than honoured. 160px is the smallest height that still shows the head, the + // locator and a couple of lines of the summary. + var INSPECTOR_MIN_DOCK_H = 160; + // The ledger keeps at least this share of the window, whatever the inspector is doing. It is + // one rule with two uses — the expanded cap and the drag clamp — because the reported complaint + // was that expanding the panel covered the rows outright, and a rule that only bounds one of + // the two ways the panel can grow is not that rule. Two copies of it would be two rules. + var INSPECTOR_LEDGER_SHARE = 0.25; + // D40: the hair of viewport the wide-screen inspector leaves below itself. Its bottom edge + // 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; + + /** + * The narrow-screen docking, and the one place that decides where the inspector is. + * + * Four values, of which three are heights the reader picks and one is a position: + * + * flow — the panel is in the document below the ledger. Where it starts, and where + * 「滚到最底部」 brings it back. This one is a position, not a height. + * folded — pinned, down to its own header and nothing else. + * docked — pinned to the bottom of the viewport at two fifths. + * full — pinned, uncapped, because the reader asked with the handle. + * + * Three of those four are pinned and one is not, and D36 is the story of how the fourth one + * did not exist: the panel carried `position: sticky; bottom: 0` in every state, so `flow` + * was a class name rather than a position — sticky dragged the panel 7471px up the document + * and in front of the ledger. The measurement is in the stylesheet; which of the two it is + * comes from updateInspectorDock() below. + * + * Nothing here reads a scroll *direction* and nothing writes a class on . That is the + * shape D19 was withdrawn for — a scroll signal from outside the layer deciding what + * something inside it does. This decides the presentation of one panel, from that panel's + * own geometry plus one rectangle read out of the band above it, and touches nothing else on + * the page. + */ + /** + * How much of the window is left under the pinned strips — the number both inspector layouts + * build their ceiling on. + * + * One reader for both values, deliberately. `syncStickyOffsets()` measures the band and the + * overview and writes them out; `applyInspectorDock()` used to read them back and re-derive the + * same sum on its own, and the wide ceiling was a third derivation from those same two numbers. + * Two derivations of one fact drift, and nothing noticed — each half had its own guards, and no + * guard ever said the two halves have to agree with each other. + * + * Read from the CSS variables rather than from `offsetHeight`, because those variables are what + * the panel's own `top` is built from. One source, so the top of the region and the height of + * the region cannot disagree about where the pinned stack ends. + */ + function inspectorUnderPins() { + var root = getComputedStyle(document.documentElement); + var band = parseFloat(root.getPropertyValue('--sticky-band-h')) || 0; + var overview = parseFloat(root.getPropertyValue('--sticky-overview-h')) || 0; + return Math.round(window.innerHeight - band - overview); + } + + function applyInspectorDock() { + var docked = state.inspectorDock !== 'flow'; + var narrow = window.matchMedia(INSPECTOR_DOCK_QUERY).matches; + + // Nothing selected, nothing to look at: the panel goes away rather than showing an empty + // shell the reader has to scroll past to reach the footer. The test is the selection field + // itself rather than a second `panelVisible` flag — a flag would be able to disagree with + // the selection, and nothing here would notice. + // + // state.selectedId rather than selectedRecord(): the latter walks state.flat, and this runs + // on every render. The "set but no longer in the payload" case is already handled by + // revealSelection(), which clears the id — so the id being set is the selection. + var hasSelection = state.selectedId !== null && state.selectedId !== undefined; + if (dom.inspectorPanel) dom.inspectorPanel.hidden = !hasSelection; + if (!hasSelection && state.inspectorDock !== 'flow') { + state.inspectorDock = 'flow'; + docked = false; + } + + // Measured, not derived from vh. This page lives in a host iframe where 100vh is not the + // reader's viewport, which is the reason client-check bans it — so the height the panel may + // take is read off the window and the offsets syncStickyOffsets() just measured, and + // written here. + // + // panelMax is what is left after the pinned strips *and* the ledger's guaranteed quarter. + // It is the ceiling for all three states, so the expanded panel stops short of covering + // the rows and a drag cannot grow past it either. + // + // Flow used to be exempt, on the grounds that at the end of the document it is an ordinary + // block and 「完全显示」 means uncapped. That is what shipped, and it was wrong: the panel + // carries `position: sticky; bottom: 0` in every state, and a sticky-bottom element taller + // than the scrollport has nowhere legal to sit. See the rule's own comment for the + // measurement. 「完全显示」 now means the content is all reachable and scrolls inside the + // panel, not that the panel grows without bound. + // Both ceilings are `underPins` minus a reserve, and only the reserve depends on the layout: + // in one column the ledger sits above the panel and cannot be squeezed out of the window, in + // two the ledger is in the other column and the two do not interact at all. So the reserve is + // the only thing this function decides, and the sum it subtracts from comes from the one + // helper — the same number the panel's `top` is built out of. + // + // Narrow only, and on purpose. Both variables written here are read by rules inside + // `max-width: 1080px` and by nothing else, so on a wide screen they were numbers that looked + // authoritative and governed nothing: measured at 1600x900, `--inspector-full-h` stood at + // 540px while the panel was being capped at 753px by a different variable entirely. + if (dom.inspectorPanel && narrow) { + var ledgerReserve = Math.max( + INSPECTOR_MIN_LEDGER_H, + Math.round(window.innerHeight * INSPECTOR_LEDGER_SHARE) + ); + var panelMax = Math.max(INSPECTOR_MIN_DOCK_H, inspectorUnderPins() - ledgerReserve); + dom.inspectorPanel.style.setProperty('--inspector-full-h', panelMax + 'px'); + // A quarter of the measured window, or the height the reader dragged to — clamped to the + // same ceiling the expanded state gets, so the two ways of making the panel taller cannot + // disagree about where the top one is. + var dockH = state.inspectorDockH; + if (dockH === null || dockH === undefined || !isFinite(dockH)) { + dockH = inspectorDefaultDockHeight(); + } + dockH = Math.min(Math.max(Math.round(dockH), INSPECTOR_MIN_DOCK_H), panelMax); + dom.inspectorPanel.style.setProperty('--inspector-dock-h', dockH + 'px'); + } + + var folded = state.inspectorDock === 'folded'; + + if (dom.inspectorPanel) { + dom.inspectorPanel.classList.toggle('is-inspector-docked', state.inspectorDock === 'docked'); + dom.inspectorPanel.classList.toggle('is-inspector-full', state.inspectorDock === 'full'); + 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. + if (dom.inspectorResize) dom.inspectorResize.hidden = !(narrow && docked && !folded); + + var minimize = dom.inspectorMinimize; + var expand = dom.inspectorExpand; + if (!minimize || !expand) return; + var expanded = state.inspectorDock === 'full'; + + // Both buttons exist only while the panel is pinned — in flow there is no height to + // change and a grip that does nothing is worse than no grip. + // + // They do not agree about the folded state, and that is the point. The minimize button + // stays: folded is the only state with no other way out, so hiding it there would turn + // 「fold it away」 into 「dismiss it」, which is not what the reader asked for. The expand + // button goes, because its contract is 「展开 与 半高两种状态切换」 and folded is neither + // of the two — leaving it on screen would mean inventing a third behaviour for it. + minimize.hidden = !narrow || !docked; + expand.hidden = !narrow || !docked || folded; + + // Each label names what this click does, which is the whole reason there are two of + // them: before D38 one label had to cover three states and the reader had to count + // clicks to use it. + // + // The folded label is the one that has to be data-dependent. It used to be the constant + // 「恢复到半高」, so a panel folded from fully expanded came back to half height — an exit + // announcing something it was not going to do. It reads the same function the click reads. + var minLabel = folded + ? (inspectorFoldReturnDock() === 'full' ? '恢复到完全展开' : '恢复到半高') + : '折叠到最小'; + minimize.setAttribute('aria-label', minLabel); + minimize.setAttribute('title', minLabel); + + expand.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + var expandLabel = expanded ? '收起为半高' : '完全展开检视'; + expand.setAttribute('aria-label', expandLabel); + expand.setAttribute('title', expandLabel); + + // The glyph points the way the click goes: up while it makes the panel taller, down + // while it takes the height back. Written here rather than carried as two icons toggled + // by `hidden`, because `hidden` is not reliably honoured on an SVG element and this is + // the one place that already knows which state it is in. + if (dom.inspectorExpandIcon) { + dom.inspectorExpandIcon.setAttribute( + 'd', + expanded ? 'M4 9.5 8 5.5 12 9.5' : 'M4 6.5 8 10.5 12 6.5' + ); + } + + // The minimize control's glyph follows its label: a dash while it means 「fold this away」, + // the standard restore mark while it means 「put it back where it was」. Same reason as the + // chevron above, and the same single writer — two glyphs toggled by `hidden` would not work + // anyway, since `hidden` is not reliably honoured on an SVG element. + if (dom.inspectorMinimizeIcon) { + dom.inspectorMinimizeIcon.setAttribute( + 'd', + folded ? 'M3.5 8.5 h5.5 v5 h-5.5 z M6.5 8.5 v-5 h6 v5 h-2.5' : 'M3.5 8 H12.5' + ); + } + } + + /* ---------------- resizing the docked panel ---------------- + * + * Three states, not two: the reader picks a docked height by dragging the panel's top edge. + * The drag is read off pointer movement against the height the panel had when the pointer + * went down, so it does not accumulate rounding over a long drag the way a running total + * would. + * + * Dragging is a statement about the *docked* height, so it drops the panel out of 'full': the + * fully expanded state is defined as "no cap", and a height the reader just chose is a cap. + * Leaving that implicit would mean the grip silently did nothing in the one state where it is + * most visible. */ + var inspectorResizeDrag = null; + + /** The height the panel is actually showing right now, for a drag to start from. */ + function inspectorDockHeightNow() { + var h = dom.inspectorPanel ? dom.inspectorPanel.getBoundingClientRect().height : 0; + return h > 0 ? h : inspectorDefaultDockHeight(); + } + + function onInspectorResizeDown(event) { + if (event.button !== 0) return; + if (state.inspectorDock === 'flow') return; + if (!dom.inspectorResize) return; + dom.inspectorResize.setPointerCapture(event.pointerId); + inspectorResizeDrag = { + pointerId: event.pointerId, + anchorClientY: event.clientY, + anchorH: inspectorDockHeightNow() + }; + // The page must not scroll under a resize drag; the grip's touch-action says so for touch, + // and this says it for the mouse. + event.preventDefault(); + } + + function onInspectorResizeMove(event) { + if (!inspectorResizeDrag) return; + // Only the pointer that started it may drive it, the same rule the overview brush uses. + if (inspectorResizeDrag.pointerId !== event.pointerId) return; + // The panel is pinned to the bottom, so its top edge is the one that moves: dragging up + // grows it. `clientY` not pageY — the panel is pinned, so page coordinates would add the + // scroll offset into the height. + var next = inspectorResizeDrag.anchorH + (inspectorResizeDrag.anchorClientY - event.clientY); + state.inspectorDockH = next; + setInspectorDock('docked'); + event.preventDefault(); + } + + function onInspectorResizeUp(event) { + if (!inspectorResizeDrag) return; + if (inspectorResizeDrag.pointerId !== event.pointerId) return; + try { dom.inspectorResize.releasePointerCapture(event.pointerId); } catch (e) { /* already gone */ } + inspectorResizeDrag = null; + } + + /** + * The one writer of `state.inspectorDock`, and the only reason it is a function. + * + * D38 turned one control into two, and two controls each assigning the field is two + * writers of one value — the thing R1 exists to prevent, and the thing that makes two + * controls quietly grow a third state nobody can name. So the buttons do not assign; they + * name where the click goes and this does the writing. + * + * No early return on an unchanged value, and that is load-bearing twice over. A drag calls + * this once per pointermove with the same `'docked'` it already had; skipping the repaint + * would make the panel stop growing halfway through a resize. `dockInspector()` and + * `resetInspectorDock()` keep their own guards, so a scroll frame that asks for the state + * the panel is already in still costs nothing. + */ + function setInspectorDock(next) { + // Written here and nowhere else, on the way IN. 'flow' can only be folded away from if the + // control is reachable in flow, which it is not, so the panel is always at one of the two + // pinned heights by the time this runs — and `full` is the only one worth remembering. + if (next === 'folded') state.inspectorDockBeforeFold = state.inspectorDock; + state.inspectorDock = next; + applyInspectorDock(); + } + + /** + * The height the minimize control puts the panel back at when it is folded. + * + * One function for both readers — the button's click target and the button's own label. Two + * readers each spelling the rule is how they end up disagreeing, which is the exact defect this + * exists to close: a button that says 「恢复到半高」 and takes you to full height. + */ + function inspectorFoldReturnDock() { + return state.inspectorDockBeforeFold === 'full' ? 'full' : 'docked'; + } + + function dockInspector() { + if (state.inspectorDock !== 'flow') return; + setInspectorDock('docked'); + } + + function resetInspectorDock() { + setInspectorDock('flow'); + } + + function updateInspectorDock() { + if (!dom.inspectorPanel) return; + if (!window.matchMedia(INSPECTOR_DOCK_QUERY).matches) { + if (state.inspectorDock !== 'flow') resetInspectorDock(); + return; + } + // The question is the band's, not the panel's. D36: the reported rule is 「只有全部、异常、 + // 轨迹这个区域处于最上方的时候, 检视才会强制下部显示」 — pinned while that region is at the top + // of the viewport, back in the document the moment it comes back down into view. + // + // `#filter-sticky` IS that region — 全部 / 类型 / 异常 / the generation note — and it is + // `position: sticky; top: 0`. Whether it is pinned at the top is therefore not a thing to + // compute: `rect.top` sits at 0 while the reader is past it and turns positive again the + // moment they scroll back up. No threshold to tune, no direction to read. Measured + // 2026-10-13 at 897x858: pinned at scrollY 692, released at 691, and tracking scrollY + // exactly (691.1 - scrollY) in between. + // + // What this replaced asked a different question and answered it early. + // `panel.rect.bottom === innerHeight` answers 「has the panel been pinned yet」, which on the + // same page happens around scrollY 550 — a hundred pixels before the band has reached the + // top, so the panel sat stuck to the bottom while the reader was still down in the filters. + // + // Two earlier attempts are recorded because they will look reasonable again: + // + // - `rect.bottom >= innerHeight`, the loose form of the same idea, reads true for both + // 「scrolled past it」 and 「not reached yet」, and docks a panel the reader has not come + // down to. Caught by probe as 「它缩在眼前」. + // - `panel.offsetTop + panel.offsetHeight < scrollY`, and main.columns' bottom as a + // second reference. The ledger is virtualised, so the height above the panel is an + // estimate — measured offsetTop 926,767 against a real layout position near 934,100 — + // and the panel is a child of main.columns, so pinning the panel pins the container's + // bottom edge too. Both fire on scroll position rather than on the region leaving. + // + // Nothing is written to the band: no class, no attribute, no listener. The scroll handler + // is on `window`, and its only statement is scheduleInspectorDock() below. + var band = dom.filterSticky; + if (!band) return; + var pinned = band.getBoundingClientRect().top <= 1; + if (pinned) dockInspector(); + else if (state.inspectorDock !== 'flow') resetInspectorDock(); + } + + // rAF-throttled: the ledger re-batches as it scrolls, so this can fire many times a frame + // and none of the intermediate answers change what the last one says. + var inspectorDockFrame = 0; + function scheduleInspectorDock() { + if (inspectorDockFrame) return; + inspectorDockFrame = window.requestAnimationFrame(function () { + inspectorDockFrame = 0; + updateInspectorDock(); + }); + } + var TABS = [ { id: 'summary', label: '摘要', available: function () { return true; } }, { id: 'raw', label: '原文', available: function () { return true; } }, @@ -5457,6 +7547,13 @@

检视

clear(dom.tablist); clear(dom.panels); + // Whether the panel is on the page at all is decided here rather than by the click handler + // that changed the selection: renderInspector() is the single exit every selection change + // goes through — selectRecord(), revealSelection(), the generation picker and every poll + // all land here — so deciding it anywhere else would be a second place to keep in sync. + // Both branches below need it, hence before the early return. + applyInspectorDock(); + if (!entry) { dom.inspectorLocator.textContent = '尚未选择记录'; dom.inspectorMeta.textContent = ''; @@ -5529,6 +7626,21 @@

检视

dom.copyBtn.disabled = false; updateHandoffButton(); + + // The panel just changed height, and its height is what decides whether sticky has it + // pinned to the viewport bottom. Nothing that got here fires scroll or resize, so the + // decision those two listeners normally make had not been made: the first appearance of + // the inspector inherited whatever state came before it. D32 made that visible — a panel + // that was already pinned reported `flow`, so it came up at panelMax with no handle. + // + // renderInspector() is the exit every selection change goes through (see above), so this is + // the one place that owns re-deciding it. The scheduler rather than updateInspectorDock() + // directly, because more DOM writes follow this call and a rAF measures what they settled + // into rather than what they are halfway through. + // + // The empty-selection branch above deliberately does not call it: applyInspectorDock() has + // already forced `hidden` and `flow` for that case, and there is no panel left to measure. + scheduleInspectorDock(); } function activateTab(id) { @@ -5699,8 +7811,9 @@

检视

var attempt = requestRaw(index).then(function (result) { delete rawPending[key]; if (result.ok && result.body && Object.prototype.hasOwnProperty.call(result.body, 'raw')) { - rawCache[key] = result.body.raw; - return result.body.raw; + var entry = { raw: result.body.raw, omitted: result.body.omitted === true }; + rawCache[key] = entry; + return entry; } var code = result.body && typeof result.body.error === 'string' ? result.body.error : ''; var err = new Error(code || 'raw_unavailable'); @@ -5720,15 +7833,19 @@

检视

/** * What a reader should show for a record's message. * - * A record whose message was too large to carry resolves to null, which callers read - * as "there is no message body" and say so. Everything else resolves to the message or - * rejects: silently substituting the normalized row would put different content under - * a label that promises 原文, with nothing on the page to say it had happened. + * 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. 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 + * normalized row would put different content under a label that promises 原文, with + * nothing on the page to say it had happened. * * @param {Record} record */ function rawSourceFor(record) { - if (record && record.rawOmitted) return Promise.resolve(null); return fetchRaw(record); } @@ -5737,26 +7854,30 @@

检视

wrap.style.display = 'flex'; wrap.style.flexDirection = 'column'; wrap.style.gap = '8px'; - if (record.rawOmitted) { - wrap.appendChild(el('p', 'note note-danger', '原文过大,已省略。可以从参数与结果页查看已保留的部分。')); - } if (record.textTruncated) { wrap.appendChild(el('p', 'note', '正文已截断:仅保留前段内容。')); } var pre = el('pre', 'pre'); pre.setAttribute('tabindex', '0'); wrap.appendChild(pre); - if (record.rawOmitted) { - pre.textContent = DASH; - } else { - pre.textContent = '正在读取原文…'; - rawSourceFor(record).then(function (source) { - if (source === null || source === undefined) pre.textContent = DASH; - else pre.innerHTML = jsonColorize(source); - }, function () { - pre.textContent = '原文读取失败,请重试。'; - }); - } + // 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 tests/verify-raw-lazy.mjs. + pre.textContent = '正在读取原文…'; + rawSourceFor(record).then(function (fetched) { + if (fetched.omitted === true) { + wrap.insertBefore( + el('p', 'note note-danger', '原文过大,已省略。可以从参数与结果页查看已保留的部分。'), + pre + ); + pre.textContent = DASH; + return; + } + if (fetched.raw === null || fetched.raw === undefined) pre.textContent = DASH; + else pre.innerHTML = jsonColorize(fetched.raw); + }, function () { + pre.textContent = '原文读取失败,请重试。'; + }); return wrap; } @@ -5797,8 +7918,9 @@

检视

var done = function (ok) { inspectorSay(ok ? '已复制该记录的 JSON。' : '复制失败,请手动选择文本后复制。'); }; - rawSourceFor(record).then(function (source) { - var text = source === null || source === undefined ? stringify(record) : stringify(source); + rawSourceFor(record).then(function (fetched) { + var noRaw = fetched.omitted === true || fetched.raw === null || fetched.raw === undefined; + var text = noRaw ? stringify(record) : stringify(fetched.raw); if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { navigator.clipboard.writeText(text).then( function () { @@ -5874,14 +7996,18 @@

检视

rawSourceFor(record).then(function (fetched) { // A message too large to carry has no raw; the normalized record is then the most // there is, which is what this sent before raw became a separate fetch. - var rawSource = fetched === null || fetched === undefined ? stripVolatile(record) : fetched; + var omitted = fetched.omitted === true; + var rawSource = omitted || fetched.raw === null || fetched.raw === undefined + ? stripVolatile(record) + : fetched.raw; var raw = truncateJson(rawSource, HANDOFF_RAW_LIMIT); var text = buildHandoffMarkdown( state.payload ? state.payload.session : null, entry.turn, record, raw.text, - raw.truncated + raw.truncated, + omitted ); inspectorSay('已发出请求,等待你在弹出的确认框中确认…'); var result = message.append(text); @@ -5909,6 +8035,57 @@

检视

/* ---------------- top-level render ---------------- */ + /** + * Publishes the heights of the pinned bands so everything below them can be positioned from + * a measurement instead of a number written down once. + * + * The alternative was three constants — 32 for the type row, 51 for the overview strip, 45 + * for the window bar — and they were already wrong the moment the 异常 row joined the band: + * that row wraps at narrow widths and grows a line when its note appears, so no constant can + * stay right. This runs at the end of `render()`, after the note has been written and the + * buttons re-counted, which is the only moment the band's height is final. `offsetHeight` + * rather than `getBoundingClientRect().height`, so a fractional device-pixel ratio cannot + * make the bands overlap by a third of a pixel per poll. + */ + function syncStickyOffsets() { + if (!dom.filterSticky || !dom.overview || !dom.windowBar) return; + var root = document.documentElement.style; + var band = dom.filterSticky.offsetHeight; + var overview = dom.overview.offsetHeight; + root.setProperty('--sticky-band-h', band + 'px'); + root.setProperty('--sticky-overview-h', overview + 'px'); + root.setProperty('--sticky-window-h', dom.windowBar.offsetHeight + 'px'); + // D40: the wide-screen inspector's ceiling, from the same two numbers the panel's `top` is + // built out of — so the top of the region and the height of the region cannot disagree about + // where the pinned stack ends. Measured, never vh; see the note in client-check. + // + // Wide only, for the reason given on the other writer: on a narrow screen this variable is + // read by nothing at all. It was written on both, which meant that at every width exactly + // one of the two ceilings was live and the other was a number nobody was reading. + if (!window.matchMedia(INSPECTOR_DOCK_QUERY).matches) { + root.setProperty('--inspector-wide-h', Math.max( + 0, + inspectorUnderPins() - INSPECTOR_WIDE_BOTTOM_GAP + ) + 'px'); + } + } + + var stickyFrame = null; + + /** + * One measurement per frame, however many resize events arrive. + * + * Every call reads three offsetHeights, which forces layout; a window drag fires `resize` + * continuously, and measuring on each one turns a drag into a layout thrash. + */ + function scheduleStickyOffsets() { + if (stickyFrame !== null) return; + stickyFrame = window.requestAnimationFrame(function () { + stickyFrame = null; + syncStickyOffsets(); + }); + } + function render() { renderBars(); renderGeneration(); @@ -5928,7 +8105,14 @@

检视

syncRangeBounds(); // The 异常 buttons carry a count that has to follow the session and every other filter, // exactly like the refresh button's decision to appear. - syncAnomalyButtons(); + // Which of the two filter systems is in force is decided here, before anything reads a + // count or draws a span, so the strip, the buttons and the ledger cannot each answer a + // different question. One walk feeds both: `render()` hands the same object to the note + // and to the buttons, so the number in the prose and the number on a button are two + // readings of one snapshot rather than two walks of a session that is still growing. + var anomaly = anomalyCounts(); + syncAnomalyMode(anomaly); + syncAnomalyButtons(anomaly); renderOverview(); renderLedger(); renderInspector(); @@ -6062,6 +8246,18 @@

检视

function buildAnomalyButtons() { if (!dom.anomalyControls) return; dom.anomalyControls.textContent = ''; + + // First, and not as one choice among the others: this is the state the page opens in + // and the only way back out of them. Built here rather than written into the markup so + // it cannot drift out of step with the buttons it has to reverse. + var none = el('button', 'btn btn-quiet anomaly-btn anomaly-btn-none'); + none.type = 'button'; + none.id = 'anomaly-none'; + none.setAttribute('data-anomaly-none', ''); + none.setAttribute('aria-pressed', 'true'); + none.appendChild(el('span', 'anomaly-label', ANOMALY_NONE_LABEL)); + dom.anomalyControls.appendChild(none); + for (var i = 0; i < ANOMALY_RULES.length; i += 1) { var rule = ANOMALY_RULES[i]; var button = el('button', 'btn btn-quiet anomaly-btn'); @@ -6073,6 +8269,8 @@

检视

button.appendChild(el('span', 'anomaly-count', '0')); dom.anomalyControls.appendChild(button); } + + // Last, so the row reads left to right as: everything, then each kind, then any kind. var all = el('button', 'btn btn-quiet anomaly-btn'); all.type = 'button'; all.id = 'anomaly-all'; @@ -6080,54 +8278,219 @@

检视

all.setAttribute('aria-pressed', 'false'); all.appendChild(el('span', 'anomaly-label', '全部异常')); all.appendChild(el('span', 'anomaly-count', '0')); - dom.anomalyControls.insertBefore(all, dom.anomalyControls.firstChild); + dom.anomalyControls.appendChild(all); } /** * Counts and pressed state for the 异常 buttons. Both are read from the same pass the * ledger filters through, so a number on a button and the rows below it are the same * statement about the same records. + * + * The counts are passed in rather than walked again here: `render()` takes one snapshot and + * hands the same object to this and to `syncAnomalyMode()`. That is not only cheaper — two + * walks over a live session can disagree, because the session grows between them, and a + * button count that did not come from the same read as the note beside it is the exact + * mismatch this row was built to end. */ - function syncAnomalyButtons() { + function syncAnomalyButtons(counts) { if (!dom.anomalyControls) return; - var counts = anomalyCounts(); var buttons = dom.anomalyControls.querySelectorAll('.anomaly-btn'); for (var i = 0; i < buttons.length; i += 1) { var button = buttons[i]; + var isNone = button.hasAttribute('data-anomaly-none'); var isAll = button.hasAttribute('data-anomaly-all'); var id = button.getAttribute('data-anomaly-rule'); - var n = isAll ? counts.any : (counts.byRule[id] || 0); - var on = isAll - ? state.anomalyRules.length === ANOMALY_RULES.length - : state.anomalyRules.indexOf(id) >= 0; - var noun = isAll ? '全部异常' : anomalyRuleLabel(id); - var label = noun + ' ' + formatInt(n) + ' 条 · ' + (on ? '当前只看这些 · 点击取消' : '当前未启用 · 点击只看这些'); - button.querySelector('.anomaly-count').textContent = formatInt(n); + var on; + var n; + var noun; + var unit; + var label; + if (isNone) { + // 重置 carries no number. The other buttons answer "how much would this keep", and + // that question is meaningless for a control that keeps whatever the row above says + // — the row above's own count is already on the window bar. + // + // 多选 counts towards "is this row at its default" because it is: the axis opens in + // 单选, and 多选 changes what every other button on this row does when pressed. A row + // 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.anomaly.rules.length === 0 && state.anomaly.mode !== 'multi'; + noun = ANOMALY_NONE_LABEL; + label = noun + ' · ' + (on ? '已是默认状态 · 表格按上一行的条件筛选' + : '点击回到默认状态 · 表格按上一行的条件筛选'); + n = null; + } else if (isAll) { + on = state.anomaly.rules.length === ANOMALY_RULES.length; + n = counts.any; + noun = '全部异常'; + unit = '条'; + label = ruleLabelFor(noun, n, unit, on); + } else { + on = state.anomaly.rules.indexOf(id) >= 0; + n = counts.byRule[id] || 0; + noun = anomalyRuleLabel(id); + unit = counts.unit[id] || '条'; + label = ruleLabelFor(noun, n, unit, on); + } + var cell = button.querySelector('.anomaly-count'); + if (cell) cell.textContent = formatInt(n) + ' ' + unit; button.setAttribute('aria-pressed', on ? 'true' : 'false'); button.setAttribute('aria-label', label); button.setAttribute('title', label); - button.classList.toggle('is-empty', n === 0); + button.classList.toggle('is-empty', n !== null && n === 0); } } + /** + * One rule's button name, built from the same two facts the number is. + * + * The unit is printed next to the number rather than assumed: 条 for a rule about a + * record, 轮 for a rule about a turn. A bare number cannot carry that difference, and a + * reader who cannot tell them apart is exactly who reads 1,244 条 as 1,244 unanswered + * replies when the session was broken thirteen times. + */ + function ruleLabelFor(noun, n, unit, on) { + return noun + ' ' + formatInt(n) + ' ' + unit + ' · ' + + (on ? '当前只看这些 · 点击取消' : '当前未启用 · 点击只看这些'); + } + function onAnomalyToggle(ruleId) { var next; if (ruleId === null) { - next = state.anomalyRules.length === ANOMALY_RULES.length ? [] : ANOMALY_RULE_IDS.slice(); - } else { - next = state.anomalyRules.filter(function (id) { + // 全部异常: every rule, or none of them. + 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.anomaly.rules.filter(function (id) { return id !== ruleId; }); - if (next.length === state.anomalyRules.length) next = next.concat([ruleId]); - next.sort(function (a, b) { - return ANOMALY_RULE_IDS.indexOf(a) - ANOMALY_RULE_IDS.indexOf(b); - }); + 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.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.anomaly.last = ruleId; } - state.anomalyRules = next; + next.sort(function (a, b) { + return ANOMALY_RULE_IDS.indexOf(a) - ANOMALY_RULE_IDS.indexOf(b); + }); + state.anomaly.rules = next; + if (next.length > 0) resetBaseFilters(); + resetWindowMode(); + render(); + } + + /** + * 多选 on or off. + * + * Turning it off has to leave exactly one rule standing, because the buttons are about to + * stop being able to say more than one at a time and a selection of three that the buttons + * now claim to be one is the mismatch this whole axis was moved to fix. The survivor is + * the rule the reader ticked last — the one they were looking at a moment ago — and not + * the first in table order, which would be an answer to a question they did not ask. + */ + function onAnomalyMultiChange(checked) { + 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.anomaly.rules[0]]; + } + if (state.anomaly.rules.length === 0) state.anomaly.last = null; + render(); + } + + /** + * Back to the default: no anomaly rule, and the table filtered by the row above. + * + * Deliberately not a toggle. 重置 is the state the page opens in, so a toggle would mean + * the second click on an already-active button silently filtered the list — the reader + * pressed "put it back" and got less of it. And deliberately it does not restore what the + * row above used to hold either: it is a reset, so the row above is already at its default + * and the table follows from there. + */ + function onAnomalyReset() { + 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(); } + /** + * The row above, put back to its default: every kind, no query, no strip selection, no range. + * + * Written once and called from both directions into anomaly mode, because the controls and + * the state have to move together — setting `state.kinds` without `syncKindButtons()` is + * how the axis ends up showing 「用户」 while the table is not filtered by it. + * + * This is also the one writer of the base axes' defaults, which is why clearFilters() below + * calls it rather than repeating the assignments. The two used to be parallel functions with + * the same name in spirit and different contents: clearFilters() left state.overviewSelection + * alone, and that field is read by visibleEntries() — so the 「清除筛选」 button in the empty + * ledger cleared the kinds, the query, the anomaly rules and the range, and the ledger stayed + * filtered by a brush on the strip that the reader had just pressed a button to get rid of. + */ + function resetBaseFilters() { + state.kinds = KIND_ORDER.slice(); + state.query = ''; + dropOverviewSelection(); + clearTimeRange(); + if (dom.searchInput) dom.searchInput.value = ''; + syncKindButtons(); + } + + /** + * Keeps the page honest about which filter system is in force. + * + * The controls for the inactive one are made inert as well as dimmed, because a control + * that accepts a click and then changes nothing on screen is the defect that put this + * axis where it was — reintroducing it one line away would be worse than the original. + * 会话 / 上下文代 / 刷新 carry no marker: they choose which session is on screen, not which + * records of it, so they have to keep working while an anomaly rule is ticked. + */ + function syncAnomalyMode(counts) { + 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.anomaly.mode === 'multi'; + if (!dom.anomalyNote) return; + dom.anomalyNote.hidden = !on; + if (on) { + // Two rules on means the per-rule numbers do not add up to the list — one record can + // trip both — so the reader needs the total for *their* selection. It used to be + // answered with 合计见「全部异常」, which is the union of all four rules: for a stack of + // two it pointed at a number 1,300 rows larger than what was on screen. The total is + // counted instead, and it is the length of the ledger under these buttons. + // + // 叠加 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.anomaly.rules.length; + var stacked = n > 1 + ? (state.anomaly.mode === 'multi' + ? ' · 已叠加 ' + formatInt(n) + ' 条规则 · 合计 ' + formatInt(counts.selected) + ' 条' + : ' · 全部 ' + formatInt(n) + ' 条规则同时生效 · 合计 ' + formatInt(counts.selected) + ' 条') + : ''; + dom.anomalyNote.textContent = '异常筛选已开启 · 类型、搜索、时间区间与总览已回到默认且不参与 · 点「' + + ANOMALY_NONE_LABEL + '」回到默认筛选' + stacked; + } + } + function syncKindButtons() { var buttons = dom.kindControls.querySelectorAll('.tbtn-action'); for (var i = 0; i < buttons.length; i += 1) { @@ -6180,6 +8543,15 @@

检视

stopPolling(); pollTimer = window.setInterval(function () { if (document.hidden) return; + // A table that is still being built is not a table this tick may replace. An 全部 window + // over 18,924 rows takes about 4 s to lay out — longer than this interval — so letting + // every tick through would restart the build before it reached the end, and the reader + // would watch the count reset from 3,000 back to 0 forever. The tick is remembered + // instead, and flushDeferredPoll() runs it once the build finishes. + if (ledgerBuilding()) { + pollDeferred = true; + return; + } fetchSessions(); fetchTrajectory(false); }, POLL_INTERVAL); @@ -6193,6 +8565,44 @@

检视

} function wire() { + // Nothing re-measured the band when the window changed shape. On a session whose payload has + // stopped growing every poll is a 304 and render() never runs, so a resize that wrapped the + // type row or the 异常 row left the pinned strips sitting at the old width — until new + // records happened to arrive, which on a finished session is never. + window.addEventListener('resize', scheduleStickyOffsets); + + // The inspector's own scroll and resize listeners, separate from the sticky offsets above + // because they answer a different question: not how tall the pinned strips are, but + // whether this panel has left the viewport. Passive because nothing here scrolls the page + // from inside the handler — the one scrollBy lives in resetInspectorDock(), which runs + // from this same callback and is the only place a read of scroll becomes a write of it. + window.addEventListener('scroll', scheduleInspectorDock, { passive: true }); + window.addEventListener('resize', scheduleInspectorDock); + + // Two buttons, one job each, and neither writes the field itself — each names the state its + // click goes to and hands it to the one writer. The single button this replaced cycled + // folded -> half -> expanded -> folded, which was correct and still wrong to use: reaching + // 「半高」 from 「完全展开」 took two clicks and a reader had to know which click they were + // on. What each button does is now in its own aria-label and nowhere else. + dom.inspectorMinimize.addEventListener('click', function () { + setInspectorDock(state.inspectorDock === 'folded' ? inspectorFoldReturnDock() : 'folded'); + }); + dom.inspectorExpand.addEventListener('click', function () { + setInspectorDock(state.inspectorDock === 'full' ? 'docked' : 'full'); + }); + + // The grip, beside the handle rather than instead of it: the handle chooses which of the + // two pinned heights the panel has, the grip chooses how tall that one is. pointercancel is + // registered alongside pointerup because a drag that ends any other way — the browser + // taking the capture, a second touch — would otherwise leave the drag object alive and + // every later pointermove would resize a panel nobody is touching. + dom.inspectorResize.addEventListener('pointerdown', onInspectorResizeDown); + dom.inspectorResize.addEventListener('pointermove', onInspectorResizeMove); + dom.inspectorResize.addEventListener('pointerup', onInspectorResizeUp); + dom.inspectorResize.addEventListener('pointercancel', onInspectorResizeUp); + + applyInspectorDock(); + dom.followBtn.addEventListener('click', function () { setFollow(!state.followLatest); }); @@ -6282,20 +8692,37 @@

检视

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(); var anomalyButtons = dom.anomalyControls.querySelectorAll('.anomaly-btn'); for (var a = 0; a < anomalyButtons.length; a += 1) { anomalyButtons[a].addEventListener('click', function () { - // Read the pressed state at click time rather than closing over it: the buttons + // Read which button this is at click time rather than closing over it: the buttons // are re-synced on every poll, and a captured flag goes stale between the render // and the click. + if (this.hasAttribute('data-anomaly-none')) { + onAnomalyReset(); + return; + } var id = this.getAttribute('data-anomaly-rule'); onAnomalyToggle(id || null); }); } + // `change`, not `click`: a checkbox operated by keyboard fires change and nothing else, + // and this control has to work the same way for both. + if (dom.anomalyMulti) { + dom.anomalyMulti.addEventListener('change', function () { + onAnomalyMultiChange(this.checked); + }); + } + var kindButtons = dom.kindControls.querySelectorAll('.tbtn-action'); for (var j = 0; j < kindButtons.length; j += 1) { if (kindButtons[j].id === 'kind-all') continue; diff --git a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs index 78d7bd1..8d02142 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs +++ b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs @@ -28,16 +28,71 @@ 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. + * + * 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; 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; +/** + * Upper bound on the parsed generation rows kept resident, in bytes. + * + * The whole-session cache above stores serialized bodies; this one stores parsed object + * graphs, which cost several times their source text. Measured on this machine: JSON.parse + * retains 1.36x the text of the emitted payload, and that is a floor — the payload has + * already had its message bodies stripped by splitRawRecords, so the rows graph is larger. + * 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. Pinned by tests/verify-parsed-rows.mjs. + */ +const PARSED_ROWS_CACHE_BYTES = 256 * 1024 * 1024; +/** + * Heap charged to one cache entry per source byte read. V8 cannot report an object graph's + * size, so a byte budget needs a proxy. + * + * Measured on this machine's own lineage: 95.3 MB of messages.jsonl retained 116 MB of heap + * after two forced collections, i.e. 1.22x the source. 2x leaves headroom over that while + * still erring on the side of over-counting, because a proxy that under-counts would let the + * cache grow past its own budget without noticing. + * + * It was 3x before, and that was wrong in the direction that matters: 95.3 MB x 3 charged + * 286 MB against a 256 MB budget, so the LRU evicted entries it had room for in reality and + * re-parsed them on the next poll. The budget is only as good as this number. + */ +const PARSED_ROWS_HEAP_PER_SOURCE_BYTE = 2; const SESSION_STORE_TOKEN = ''; const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }; const ROLE_PATTERN = /"role"\s*:\s*"(user|assistant|toolResult|custom)"/g; @@ -788,39 +843,67 @@ export function toolCallSummary(name, args) { } /** - * Moves `raw` off every record into an index-keyed map. + * Moves the raw-message locator off every record into an index-keyed map. + * + * Two things used to happen here and only one of them was load-bearing. * - * On a real session the untouched message is about two thirds of the payload, and the page - * reads it for exactly one record at a time: the inspector's raw tab, one clipboard copy, - * one handoff. Carrying it on every row pays that weight on every poll and buys nothing - * until a row is actually opened, so it leaves the bulk response and is fetched per record - * instead. + * The message itself was carried on every row and taken off again by this function. That is + * what kept the bulk response small, and it stays true. What did not survive scrutiny is how + * 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. Pinned by tests/verify-raw-lazy.mjs. * - * `rawOmitted` deliberately stays on the row. Whether a message was too large to carry at - * all is a fact the list itself has to be able to state — it is one boolean per row, and - * it is what lets the inspector say "omitted" instead of silently showing something else. + * 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), + * and they all answer with the same message, so the locator is per row, not per segment. * * Records are carried per turn, not as one flat list, so this walks the turns. * * @param {Array>} turns - * @returns {Map} + * @returns {Map} */ export function splitRawRecords(turns) { - /** @type {Map} */ + /** @type {Map} */ const raws = new Map(); if (!Array.isArray(turns)) return raws; for (const turn of turns) { if (!turn || typeof turn !== 'object' || !Array.isArray(turn.records)) continue; for (const record of turn.records) { - if (!record || typeof record !== 'object' || !('raw' in record)) continue; + if (!record || typeof record !== 'object' || !('rawRow' in record)) continue; const index = Number(record.index); - if (Number.isInteger(index) && index > 0) raws.set(index, record.raw); - delete record.raw; + // Not Number(record.rawRow): Number(null) is 0, so a record whose locator is absent + // would resolve to the first row and answer with the wrong message. + const rowIndex = record.rawRow; + if (Number.isInteger(index) && index > 0 && typeof rowIndex === 'number' && Number.isInteger(rowIndex) && rowIndex >= 0) { + raws.set(index, rowIndex); + } + delete record.rawRow; } } return raws; } +/** + * A record the runtime keeps for itself: written to the session, never shown, never sent. + * + * Mirrors the runtime's own `isHostOnlyMessage()` — a `custom`-role record whose + * `hostMetadata.providerVisibility` is `'omit'`. That predicate is what + * `projectAgentMessagesForModel()` filters on before the outbound request is built, so it is + * the field to read: matching a list of `customType` strings instead would go stale the first + * time the runtime adds another host-only record type. + * + * @param {unknown} message + * @returns {boolean} + */ +export function isHostOnlyMessage(message) { + if (!message || typeof message !== 'object' || Array.isArray(message)) return false; + const record = /** @type {Record} */ (message); + if (record.role !== 'custom') return false; + const metadata = record.hostMetadata; + return !!metadata && typeof metadata === 'object' && !Array.isArray(metadata) + && metadata.providerVisibility === 'omit'; +} /** * Builds the frozen `/api/trajectory` payload from already-parsed rows. * @param {{ session: { id: string, createdAtMs?: number | null }, rows: Array>, sizeBytes?: number, truncated?: boolean, redact?: (value: string) => string, generations?: Array>, generation?: number | null, orphans?: Array<{ generation: number, fileName: string }> }} input @@ -883,7 +966,8 @@ export function buildTrajectoryPayload(input) { return turn; }; - for (const row of rows) { + for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { + const row = rows[rowIndex]; const message = row && typeof row.message === 'object' && row.message !== null ? row.message : {}; const source = /** @type {Record} */ (message); const role = typeof source.role === 'string' ? source.role : 'unknown'; @@ -910,7 +994,6 @@ export function buildTrajectoryPayload(input) { if (turn.endedAt === null || timestamp > turn.endedAt) turn.endedAt = timestamp; } - const raw = buildRawPayload(source, redact); const usage = normalizeUsage(source.usage); if (usage) { stats.tokens.input += usage.input; @@ -955,8 +1038,14 @@ export function buildTrajectoryPayload(input) { api: typeof source.api === 'string' ? source.api : null, stopReason: typeof source.stopReason === 'string' ? source.stopReason : null, isError: source.isError === true ? true : source.isError === false ? false : null, - raw: raw.value, - rawOmitted: raw.omitted, + // Whether the runtime keeps this record for itself. Carried as one boolean so the page + // can select on it (the 异常 axis has a rule for it) without the page having to know the + // runtime's customType names, and without the record leaving the payload — dropping these + // rows would renumber every record after them, because `index` counts emitted rows. + internalOnly: isHostOnlyMessage(source), + // Where the untouched message lives, not the message itself. /raw follows this back + // when a reader actually opens the record; nothing else reads it. + rawRow: rowIndex, textTruncated: false, }; @@ -1385,7 +1474,7 @@ function redactStructure(value, redact, depth = 0) { * @param {Record} message * @param {(value: string) => string} redact */ -function buildRawPayload(message, redact) { +export function buildRawPayload(message, redact) { if (!message || typeof message !== 'object') return { value: null, omitted: true }; let serialized = ''; try { @@ -1497,6 +1586,226 @@ export function trajectoryCacheKey(entry, identity, binding, filter) { const gen = filter === null || filter === undefined ? 'all' : String(filter); return `${entry.dir}|gen=${gen}|${lineage}|${entry.sizeBytes}|${entry.lastActiveAt}|${identity.catalogBytes ?? ''}|${bind}`; } +/** + * The `?since=` cursor, or null when there is not a usable one. + * + * An absent, blank, non-integer or negative value is not an error: it is a page that has + * nothing yet, and a page with nothing yet needs the whole payload. Turning that into a + * 400 would put the first poll of every session on an error path for a question with a + * perfectly good default answer. + * @param {string | null | undefined} raw + * @returns {number | null} + */ +export function parseSince(raw) { + if (raw === null || raw === undefined || String(raw).trim() === '') return null; + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) return null; + return value; +} + +/** + * The part of a payload the page does not have: every record after the cursor. + * + * `stats` and `session` travel whole in every delta. `stats` because it is an accumulator, + * every counter in it the result of walking all the rows, so a tail could not be added to + * it without the page re-deriving the server's own arithmetic. `session` because it is a few + * 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. 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 + * the cursor is past the end. The second is the page being *ahead* of the server, which is + * what a generation switch looks like from here — and a tail from there is empty, so + * serving it would leave the page believing the server has nothing to add. + * + * @param {any} payload + * @param {number} since + * @returns {any | null} + */ +export function buildDeltaPayload(payload, since) { + if (!Number.isInteger(since) || since < 0) return null; + const turns = payload && Array.isArray(payload.turns) ? payload.turns : []; + if (turns.length === 0) return null; + let upTo = 0; + for (const turn of turns) { + const records = turn && Array.isArray(turn.records) ? turn.records : []; + for (const record of records) { + if (typeof record.index === 'number' && record.index > upTo) upTo = record.index; + } + } + 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 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, + stats: payload.stats, + turns: tail, + delta: { since, upTo, records }, + }; +} + +/** + * @typedef {{ rows: Array>, skipped: number, size: number, read: number, empty: boolean, truncated: boolean }} ParsedRows + */ + +/** + * Cache key for one generation file's parsed rows. + * + * Built from what the filesystem says right now, never from the catalog. The catalog's + * byteLength is a snapshot — this file documents that as going stale, and the active file + * grows past it between polls — so a key built from it would eventually serve a stale parse. + * + * The key names the full path, not the basename: every session has a `messages.jsonl`, so a + * basename key lets two sessions' identical files collide. + * + * `limit` participates because readHead truncates at it, and whether the file was truncated is + * part of what this entry holds. + * + * @param {string} filePath + * @param {number} size + * @param {number} mtimeMs + * @param {number} limit + * @returns {string} + */ +export function parsedRowsKey(filePath, size, mtimeMs, limit) { + return `${filePath}|${size}|${mtimeMs}|${limit}`; +} + +/** + * LRU over parsed generation rows, bounded by bytes. + * + * Bounded by bytes rather than entries because entries say nothing about volume: one + * generation file is 40 KB and the next is 10 MB. A single entry larger than the whole budget + * is not stored at all — it would evict everything else and still miss every time. + * + * @param {number} budget + */ +export function createParsedRowsCache(budget) { + /** @type {Map} */ + const entries = new Map(); + let bytes = 0; + return { + get bytes() { return bytes; }, + get size() { return entries.size; }, + /** + * @param {string} key + * @returns {ParsedRows | null} + */ + get(key) { + const hit = entries.get(key); + if (hit === undefined) return null; + // Re-reading is the only signal that a file is still hot, so it refreshes recency. + entries.delete(key); + entries.set(key, hit); + return hit.value; + }, + /** + * @param {string} key + * @param {ParsedRows} value + * @param {number} size + * @returns {number} + */ + set(key, value, size) { + const previous = entries.get(key); + if (previous !== undefined) { + bytes -= previous.size; + entries.delete(key); + } + if (size > budget) return bytes; + entries.set(key, { value, size }); + bytes += size; + while (bytes > budget && entries.size > 1) { + const oldest = entries.keys().next(); + if (oldest.done) break; + const victim = entries.get(oldest.value); + entries.delete(oldest.value); + bytes -= victim ? victim.size : 0; + } + return bytes; + }, + clear() { + entries.clear(); + bytes = 0; + }, + }; +} + +/** + * One generation file's rows, from the cache when the filesystem still agrees with the key. + * + * Extracted from the route so a test can run the real step twice and compare: a cache hit + * that is even slightly different from a cold read is the one failure this cache may not + * have, and it is invisible to any test that does not run this function. + * + * Throws whatever readHead throws; the caller owns the error response, because the route + * needs to answer 500 and the test needs the throw. + * + * @param {ReturnType} cache + * @param {string} filePath + * @param {number} limit + * @returns {Promise} + */ +export async function loadParsedRows(cache, filePath, limit) { + // Stat for the key rather than trusting the catalog, for the same reason the read limit is + // not taken from it: the active file grows past its recorded size between polls. If the file + // changes between this stat and readHead the entry ends up keyed on a size it does not + // match, and the next poll — which stats again — misses and re-reads. The race can only cost + // a redundant read; it can never serve a stale parse, because a different size is a + // different key. + const current = await stat(filePath); + const key = parsedRowsKey(filePath, current.size, current.mtimeMs, limit); + const hit = cache.get(key); + if (hit !== null) return hit; + + const head = await readHead(filePath, limit); + const parsed = parseMessagesJsonl(head.text); + const value = { + rows: parsed.rows, + skipped: parsed.skipped, + size: head.size, + read: head.read, + empty: head.text === '', + // Truncated means our cap cut the read, nothing else. + truncated: truncatedByCap(head.size, limit), + }; + cache.set(key, value, Math.ceil(head.size * PARSED_ROWS_HEAP_PER_SOURCE_BYTE)); + return value; +} /** * Sends an already-serialized JSON body. Unlike {@link sendJson} it supports revalidation: @@ -1561,9 +1870,15 @@ export async function start(context) { * but is only ever read for one record at a time, so keeping it beside the body means * the poll transfers the small payload and the record that gets opened still costs * nothing extra. - * @type {Map }>} + * @type {Map, rows: Array> }>} */ const trajectoryCache = new Map(); + /** + * 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. Pinned by tests/verify-parsed-rows.mjs. + */ + const parsedRows = createParsedRowsCache(PARSED_ROWS_CACHE_BYTES); const loadClientEntry = async () => { if (clientEntry) return clientEntry; @@ -1580,6 +1895,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); @@ -1589,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. Pinned by tests/verify-list-cache.mjs. sizeBytes: entry.sizeBytes, - turnCount: peek.turns, + turnCount: peekIsWhole ? peek.turns : null, cacheId, }; descriptorCache.set(cacheKey, { at: Date.now(), value }); @@ -1691,8 +2036,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) { @@ -1815,6 +2159,12 @@ export async function start(context) { } // No validator from the page (first load, or a fresh tab): reuse the body we already // built instead of re-reading and re-parsing the files to rebuild the same bytes. + // No `?since=` handling here, and that is not an oversight. A page that carries a + // cursor carries the validator it was taken against, so a request that reaches this + // line with a cursor means the session changed — which is the branch below. Cutting + // a tail out of a cached payload would also mean holding that payload in memory, and + // this cache deliberately does not: it holds the serialized bytes and the rows /raw + // needs, not the object graph the bytes came from. sendJsonBody(response, 200, cached.body, { etag, revalidate: true }); return; } @@ -1893,24 +2243,28 @@ export async function start(context) { // a file that was merely newer than its catalog entry. `readHead` already clamps to the real // stat size, so MAX_MESSAGE_BYTES bounds memory on its own. const limit = MAX_MESSAGE_BYTES; - let head; + /** @type {ParsedRows} */ + let parsedFile; try { - head = await readHead(filePath, limit); + parsedFile = await loadParsedRows(parsedRows, filePath, limit); } catch (error) { context.logger.error('miniapp.trajectory.read_error', { reason: 'messages_unreadable', code: errorCode(error) }); sendError(response, 500, 'trajectory_unreadable', '会话数据暂时无法读取'); return; } - if (head.text === '') { + // Kept as a flag on the cached value rather than a log line at the read site: a file that + // parsed empty once will parse empty every time, and a hit must still say so. + if (parsedFile.empty) { context.logger.warn('miniapp.trajectory.read_error', { reason: 'empty_messages' }); } - // Truncated means our cap cut the read, nothing else. - if (truncatedByCap(head.size, limit)) truncated = true; - readBytes += head.read; - const parsed = parseMessagesJsonl(head.text); - skippedTotal += parsed.skipped; + if (parsedFile.truncated) truncated = true; + readBytes += parsedFile.read; + skippedTotal += parsedFile.skipped; const keep = filter === null || filter === link.generation; - for (const row of parsed.rows) { + // 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. Pinned by tests/verify-parsed-rows.mjs. + for (const row of parsedFile.rows) { if (keep) { row[ROW_GENERATION] = link.generation; rows.push(row); @@ -1953,6 +2307,10 @@ export async function start(context) { // Last thing before serializing, and the only place `raw` is ever touched: from here on // the body carries no message bodies at all. + // The locator map plus the rows it points into. Both are references: the rows are the + // same objects the parsed-rows cache holds, so this adds a map of numbers and an array of + // pointers rather than a second copy of the session. Keeping the array here also means + // /raw still answers after the parsed-rows cache has evicted the file. const raws = splitRawRecords(payload.turns); let body; @@ -1962,13 +2320,19 @@ export async function start(context) { sendJson(response, 500, { error: 'trajectory_unserializable', message: '会话数据暂时无法读取' }); return; } - trajectoryCache.set(cacheKey, { at: Date.now(), etag, body, raws }); + trajectoryCache.set(cacheKey, { at: Date.now(), etag, body, raws, rows }); while (trajectoryCache.size > TRAJECTORY_CACHE_ENTRIES) { const oldest = trajectoryCache.keys().next(); if (oldest.done) break; trajectoryCache.delete(oldest.value); } - sendJsonBody(response, 200, body, { etag, revalidate: true }); + // `?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. 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 }); }; /** @@ -1995,11 +2359,17 @@ export async function start(context) { sendError(response, 503, 'trajectory_not_cached', '会话数据已释放,请重新加载'); return; } - if (!cached.raws.has(wanted)) { + const rowIndex = cached.raws.get(wanted); + const row = rowIndex === undefined ? undefined : cached.rows[rowIndex]; + if (rowIndex === undefined || !row || typeof row !== 'object') { sendError(response, 404, 'record_not_found', '找不到该记录'); return; } - sendJson(response, 200, { index: wanted, raw: cached.raws.get(wanted) }); + // 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 tests/verify-raw-lazy.mjs. + const built = buildRawPayload(row.message, redact); + sendJson(response, 200, { index: wanted, raw: built.value, omitted: built.omitted }); }; const handleDashboard = async (response) => { @@ -2093,8 +2463,14 @@ 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 + // disposed runtime kept a whole session's parsed rows alive — measured at roughly 43MB of heap + // still held for a 39MB session after dispose. It has the same clear() as the others and was + // simply missed; the line is here so the next cache added cannot repeat the omission quietly. + parsedRows.clear(); clientEntry = null; disposal = close(server).then(() => undefined); return disposal; diff --git a/plugins/avatasia/mmc-trajectory/tests/METHODOLOGY.md b/plugins/avatasia/mmc-trajectory/tests/METHODOLOGY.md new file mode 100644 index 0000000..35422c5 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/tests/METHODOLOGY.md @@ -0,0 +1,677 @@ +# How to verify a change here + +`README.md` in this directory says what the guards are. This says how to arrive at them, and — more +usefully — what has gone wrong when this was not followed. Every rule below is here because it +cost a real defect or a real false alarm, not because it is good practice in the abstract. + +The short version: + +> Black-box the behaviour first. Freeze the data before you write any number. Derive the expected +> value by hand. Then make the guard fail on purpose, and keep the failing mutation or fix the +> guard — never delete it. + +--- + +## 1. Drive the page before you read the code + +Reading a 285 KB client file to find a defect in a filter row is slow, and it is how you end up +asserting the implementation you happened to read instead of the behaviour someone reported. + +Open the dashboard and walk the states. For the 异常 row the matrix that matters is: + +| | no rule | one rule | several | 重置 pressed | +| --- | --- | --- | --- | --- | +| **单选** | 重置 | replace on each press | unreachable | — | +| **多选** | 重置 | stack | union, untick one at a time | — | + +That walk found three defects that no amount of reading would have surfaced, because all three are +about what a control *says* rather than what it computes. + +### Reading the state of a row of controls + +Read the **parent container's** `outerHTML`, not a filtered set of children: + +``` +query(kind="dom", selector="#anomaly-bar") → every button with its aria-pressed +``` + +A selector like `#anomaly-controls button[aria-pressed="true"]` returns only the **first** match, +and a comma-separated list returns one of the matches in unspecified order. Both look exactly like +"the stacking did not work". During this work that reading produced a confident claim that a +working feature was broken, and the only reason it was caught is that the note text disagreed with +it. + +Two more that matter on this page: + +- **checkbox state does not appear in serialised DOM.** `syncAnomalyMode` writes + `dom.anomalyMulti.checked`, which is a property; the serialised `` is identical whether it + is ticked or not. `query(kind="semantic")` does not expose it either. To see it, screenshot. +- **the session grows while you measure.** Every number on this page moves every 2.5 seconds. A + reading of `1,705` against a ledger of `1,706` is not a defect. See §2. + +--- + +## 2. Never take a number from the page as the expectation + +This is the rule that would have prevented the original 异常 defects, and the one that is easiest to +break while writing new guards. + +The counts on the 异常 row are computed by walking the session. If a guard computes its expected +value with the same walk, it is a tautology: two implementations of the same mistake agree. + +Every expected number comes from **committed fixture data**, counted by code that shares nothing +with the client — the guard reads `payload.turns[]` itself and never calls `anomalyCounts()`: + +``` +node tests/verify-anomaly.mjs +fixture: 23 turns, 88 records, 21 turns that never answered +OK (234 assertions; 70 anomalous records of 88) +``` + +If you must compare against the live page to close a report, do it on a **stable** quantity — the +ledger window, which shows `已显示 N 条 · 更早的 M 条未加载`, so the total is `N + M` and readable in +one snapshot. Even then, treat a ±1 difference as the session growing, and re-read before calling +it a bug. + +`--real` runs the same guards against this machine's `~/.minimax`. It is a second opinion about +*shape*, never a source of expected values: that file grows too. + +--- + +## 3. Derive the expectation by hand, and write down the derivation + +Before writing an assertion, work out what it should be and check that it is not boring: + +``` +noReply → 21 turns are `closed === false` and are not the live turn → 21 轮 + those turns hold N records in total → N 条 shown +toolError → a `toolResult`/`toolCall` pair whose result says it failed → some 条 +``` + +Three things this catches that a green suite does not: + +- **Units.** `byRule` counts turns for a turn rule and records for a record rule. `21 轮` and `N 条` + are not comparable, and a session broken 21 times once advertised 1,244 "unanswered" rows. +- **Unions do not add up.** One record can trip two rules. `工具失败 335 条` and `上下文压缩 19 条` + can be `354 条` or `353 条` and both are correct. A guard that asserts `a + b == total` will be + flaky, and one that asserts `a + b == total` while the fixture happens to have no overlap will + pass forever without testing anything — hence the explicit `some pair of rules genuinely overlaps` + assertion, which fails loudly if the fixture stops containing an overlap. +- **Partial selections.** `全部异常` is the union of **all** rules. It is never the total of the two + rules currently ticked. An earlier version of the hint said `合计见「全部异常」`, which was true + only for the full set; for a two-rule stack it pointed 1,300 rows above the ledger. + +--- + +## 4. Give every invariant a mutation, and keep it alive + +A guard that cannot fail reads as coverage. For each new invariant, add a case to +`mutate-anomaly.py` that breaks exactly that rule, and require it to be caught **by the assertion +that owns it**: + +``` +python tests/mutate-anomaly.py +OK (70 mutations, each caught by the guard that owns the defect) +index.html restored byte-for-byte +``` + +The runner refuses three outcomes, and all three are things that have happened here: + +| runner reports | what it means | what to do | +| --- | --- | --- | +| `anchor matched N times` | the anchor is not unique, or the source moved | re-derive the anchor from the current file. **Never** loosen it to `>= 1` | +| `NOT CAUGHT` | the guard is not testing what it claims | fix the guard. Do not delete the mutation | +| `CRASHED` | the probe died before reporting | this is not a catch. Fix the crash | + +### A mutation that is a no-op is worse than no mutation + +The first attempt at "count the partial union" was: + +```python +# wrong — `sel.length === 0` is false whenever rules are ticked, so this changes nothing +- if (sel.indexOf(hits[s]) >= 0) { +- selected += 1; +- break; +- } ++ if (sel.length === 0 || sel.indexOf(hits[s]) >= 0) { ... } +``` + +It survived, and the survival was correct: the mutation was dead code. The replacement drops the +`break`, which turns the union into a count of rule-hits — wrong exactly when two rules overlap on +one record, and correct otherwise. **Prefer mutations that only differ under the condition the +invariant is about.** A mutation that is always different is usually breaking something the +assertion never covered. + +### The owner must have a stable name + +`assert()` names are the runner's interface. Two traps: + +- **No colons.** The probe used to recover names with `split(':')[0]`, so `单选: ticking a second…` + was truncated to `单选` and mutations that *had* been caught were reported as uncaught. Names are + now collected separately; keep them colon-free anyway, because it is still how failures are read. +- **A name that varies with the input cannot own a mutation.** Per-pair assertions named + `… — compaction + toolError` are excellent for a human reading a failure and unusable as a + mutation owner, because *which* pair fires depends on the fixture. Keep both: one family-level + assertion with a stable name, plus the per-instance ones for diagnostics. + +--- + +## 5. Assert prose by running it + +A sentence can be grammatical and wrong. `合计见「全部异常」` was, and no regex over the source +would have said so. + +`syncAnomalyMode` is loaded into the VM and its **rendered text** is read back: + +```js +page.dom.anomalyNote = { hidden: true, textContent: '' }; +vm.runInContext(fnSource('function syncAnomalyMode(counts)'), page); +page.syncAnomalyMode(page.anomalyCounts()); +const printed = page.dom.anomalyNote.textContent; // what the reader is actually shown +``` + +and the number is **parsed back out** of the string rather than matched as a phrase, so rewording +the sentence does not break the guard while repointing the number still does: + +```js +const countOf = (text) => Number((text.match(/合计 ([\d,]+) 条/) || [])[1]?.replace(/,/g, '')); +``` + +Use source-shape assertions for wiring — is it wired to `change` or `click`, is it marked +`data-filter-scope` — and use execution for anything a reader would be misled by. + +--- + +## 6. When a guard goes red after an unrelated change, re-derive it + +Changing a signature breaks every `fnSource('function syncAnomalyButtons()')` anchor at once. The +failure is noise, not evidence. Re-read what the function now promises and rewrite the assertion +onto the new contract. + +Two of the 14 red assertions in this session were **inverted on purpose** and say so in their own +comment: + +- `ticking a second keeps table order, not click order` required the toggle to stack + unconditionally. True of the code, false of the requirement. It now asserts 单选 replacement, + and the union it described is re-asserted in the 多选 block with the checkbox actually on. +- `重置 is pressed exactly when no rule is` was true of the *filter* and false of the *row*: 多选 is + part of the row's state and is the only control that turns it off. + +An assertion that has to be deleted has usually found a requirement that was not written down yet. +Write that down in the assertion's comment, or the next reader will delete it too. + +--- + +## 7. One render, one snapshot + +`render()` took the counts twice — once for the hint, once for the buttons. Two walks of a session +that is still growing can disagree, and the note would have published a number the button row did +not have. + +```js +var anomaly = anomalyCounts(); +syncAnomalyMode(anomaly); +syncAnomalyButtons(anomaly); +``` + +Two guards hold this: the exact call sequence, and that `render()` contains exactly **one** +`anomalyCounts()` call. Anything a reader can add up must come from one read. + +--- + +## 8. Measure the layout; never write it down + +A sticky offset is a measurement, not a constant. This file carried `top: 32px` and `top: 83px` +with a comment explaining them, and both were correct only while the layout had exactly one filter +row above the strip. Adding a second filter row — one that wraps at narrow widths and grows a line +when its note appears — invalidated them silently: nothing overlaps at the width the comment was +written at, and at 735px the window bar sits on top of the strip. + +```css +/* wrong: correct for one layout, and there is no way to tell from reading it */ +.overview { top: 32px; } +.window-bar { top: 83px; } +``` + +```js +function syncStickyOffsets() { + if (!dom.filterSticky || !dom.overview || !dom.windowBar) return; + var root = document.documentElement.style; + var band = dom.filterSticky.offsetHeight; + var overview = dom.overview.offsetHeight; + root.setProperty('--sticky-band-h', band + 'px'); + root.setProperty('--sticky-overview-h', overview + 'px'); + root.setProperty('--sticky-window-h', dom.windowBar.offsetHeight + 'px'); +} +``` + +`offsetHeight`, not `getBoundingClientRect().height`: this runs on every 2.5s poll, and a +fractional device-pixel ratio would otherwise accumulate a third of a pixel per poll into an +overlap nobody can see and everybody inherits. + +Three rules make that hold, and each of them needed a mutation before it was believed: + +- **Pin what answers one question as one block.** The two filter rows were pinned separately, which + allowed the state where the type row is still on screen and the 异常 row is not. Half a filter + visible is worse than none: the reader is told nothing is filtered. +- **The set of variables the stylesheet reads must equal the set the measurement writes.** One + direction lets a fallback govern a band that nothing measures; the other lets the measurement + keep writing a variable nobody reads, which is what "the offsets went back to being constants" + looks like from the outside. +- **Everything pinned over the ledger paints a background.** The rows underneath are + `background: transparent`, so a pinned band that stops covering them shows them through. + +And the general form of the third one: **an element named in markup and an element named in a +selector are two names, and nothing checks that they agree.** A band looked up by an id the markup +does not ship measures nothing, all three offsets fall back to their constants, and no error is +raised anywhere. A guard that hands the function a stub cannot catch this — the stub supplies +exactly what the function asked for — so the binding is checked against the markup instead. + +### 8.1 Assert the thing, not its name, and not its prefix + +Two assertions written for the folded inspector state survived their own mutations, and both were +the same mistake wearing different clothes. + +**A regex that stops early.** The rule was "the handle stays in the folded state": + +```js +/toggle\.hidden\s*=\s*!narrow\s*\|\|\s*!docked/ // passes on `… || docked || folded` +/toggle\.hidden\s*=\s*!narrow\s*\|\|\s*!docked\s*;/ // does not +``` + +The mutation appended `|| folded` — the exact defect — and the pattern matched the prefix and never +read what followed. **An unanchored pattern is satisfied by the correct code plus anything at all.** +When the assertion is about a whole statement, anchor it at the statement's end. + +**An assertion about a name instead of about the thing.** The rule was "folded carries no cap of its +own": + +```js +valueIn(sel, 'display') === 'none' && !jsHtml.includes('--inspector-folded-h') // insufficient +``` + +Nothing stops a `max-height: 51px` being added to the same rule. The cap was back, and the guard was +still green, because the guard had been about a *variable name* rather than about *a cap*. Assert +the shape instead: the rule declares **exactly one** property and it is the one. + +The common form: **「there is no X」 must be asserted as "the thing that would be X is absent", never +as "the name we used to call X is absent".** The first is a property of the code; the second is a +property of one spelling of it. + +### 8.2 The same trap, catching the other one + +§8.1 was written about guards that are too narrow. D36 lost a mutation to the mirror image: a guard +naming a handle, where the mutation reaches the same handle through a local. + +```js +!/filterSticky\s*\.\s*(classList|setAttribute|dataset|hidden|style)/.test(updateFn) // passes +``` + +The rule it was meant to enforce is 「the dock decision writes nothing anywhere」. The mutation +aliases the band (`var band = dom.filterSticky;`) and writes `band.classList.toggle(...)`, which +writes to the filter layer exactly as much and passes the name check. Naming the handle only ever +caught writes spelled that one way. + +Both failures are the same sentence: **the assertion was about a spelling, and the mutation was free +to use another one.** The narrow pattern that reads "not `filterSticky.something`" has to become +the wide one that reads "not `.classList` / `.setAttribute(` / `.dataset` / `.style` / `.hidden =` +anywhere in this function" — a claim about the function rather than about one identifier in it. The +wider pattern is less specific about *what* was written and more specific about *that nothing* was, +which is what the rule actually claims. + +The tell in both cases: the assertion's subject could be renamed, aliased or reformatted without the +code changing what it does. If that is possible, the assertion is measuring the spelling. + +### 8.3 "Adjacent" is not "first", and "in the file" is not "in the thing" + +D38 lost four mutations to two related mistakes, and the first one is the more general of the two. + +**Adjacency only excludes what sits *between*.** The rule was "the dock writer must not early-return on +a same-value call", because the drag calls it with `'docked'` on every `pointermove` and an early +return freezes the panel halfway through a resize. The first assertion asked for the assignment and the +repaint to be adjacent: + +```js +/state\.inspectorDock\s*=\s*next\s*;\s*applyInspectorDock\(\)\s*;\s*\}\s*$/ // passes +``` + +The mutation inserts `if (state.inspectorDock === next) return;` **above** both of them. They are still +adjacent, still in that order, still the last two statements — and the guard stays green through a +defect that silently breaks every drag. The rule is not "nothing between these two statements", it is +**"these two statements are the whole body"**, so the assertion has to say that: + +```js +setFn.replace(/^[^]*?\{/, '').replace(/\}\s*$/, '').replace(/\s+/g, ' ').trim() + === 'state.inspectorDock = next; applyInspectorDock();' +``` + +The general form: **an ordering or proximity assertion answers a weaker question than the rule it +stands for.** Proving `A` precedes `B` is not proving `A` is the first thing. When the rule is about +an *absence*, the assertion has to bound the whole region, not draw a line inside it. + +**A name looked up in the whole file is a name looked up in the wrong file.** The same rule read "both +controls ship inside the panel head", and the first version found `
` with +`html.indexOf`. The ledger has its own `.panel-head`, it appears earlier in the document, and the +result was a green assertion on a head that contained neither control. Containment is a relation +between two positions, so it has to be resolved relatively: + +```js +const head = elementRegion(html, 'div', '
', panelRegion.start); +``` + +The tell is the same as §8.2's, pointed the other way: not "the subject could be renamed" but **"the +subject could be the wrong one and the assertion would not know"**. A lookup with no `from` is a +claim that the thing is unique in the document. If it is not unique — or you have not checked — it is +a claim about the first occurrence. + +And the two together are why so much of this file reads as it does: **every assertion has to name the +region it is talking about, and every absence has to be bounded rather than spotted.** + +### 8.4 A contract can be wrong while the control is exactly right + +D38 split one three-state cycling button into two, one job each, and that was correct. D39 then found +that the folded button's exit said 「恢复到半高」 as a **literal** — so folding a fully expanded panel +brought it back to half height. The button existed, was wired, reported both directions, and did +exactly what it said. It was just saying the wrong thing half the time. + +The shape worth naming: **splitting a control decides which branch each control owns; it does not +decide what that branch points at.** The exit target is a function of something the reader did +earlier, so it needs a field and a function: + +```js +function setInspectorDock(next) { + if (next === 'folded') state.inspectorDockBeforeFold = state.inspectorDock; + state.inspectorDock = next; + applyInspectorDock(); +} +``` + +The property that makes it correct is narrow and worth copying: **the field is written only on the way +IN and read only while folded.** There is no window in which the dock has moved on and the memory has +not, so nothing has to clear it — no reset in `resetInspectorDock`, no reset when the selection is +lost. "Can this go stale?" is answered structurally rather than by adding the clearing code that would +have needed its own guard. + +Three sub-rules came out of writing the guards for it, and all three are about the same seam: + +- **Two readers of one rule must call the same function.** The label and the click both pick a branch; + spelled separately they are free to disagree, and disagreeing IS the defect. The guard counts the + calls rather than checking either spelling, so a third reader fails too. +- **A parser that only understands literals stops seeing the control at all.** The transition table's + `state.X === P ? A : B` matcher needed `A` to become "a literal *or* a call", and until it did, four + assertions silently went quiet rather than going red. A parser that cannot represent the code is + indistinguishable from code that is not there — so when a matcher is widened, check that the + assertions downstream came *back*, not that they still pass. +- **Following one call is not seeing both branches.** `resolveDestination` reads the exit function and + takes the branch the memory currently points at, which is a fact about the reader, not about the + file. A mutation putting `'flow'` in the *other* branch survived every reachability assertion, and + the fix was a separate rule over the tokens: **every state the exit is capable of naming must be a + declared one.** The reachability table can only ever prove one branch; the token rule covers the + other, and the gap is a property of static checking, not something to fix with a cleverer regex. + +The general form, and the one to check before writing a guard for any new control: **「is the control +wired correctly」 and 「does the control say the truth」 are different claims, and the second one is not +implied by the first.** D34 → D38 → D39 is three rounds of the same page rediscovering that, in +ascending order of how invisible it was: three class values with one unreachable, then one control +covering three heights, then two controls with one of them lying about its own destination. + +--- + +### 8.5 Reading the text is not the same as parsing it + +A guard that reads `index.html` sees the same characters the browser sees. It is still possible +for a rule to be in the file, every assertion about it to pass, and the browser to have no such +rule — because between "the characters are there" and "the parser built a rule out of them" +there is a step nothing in this suite was watching. + +That is what happened to D40. The wide-screen `@media (min-width: 1081px)` block shipped with +the inspector pinned to the top of the stack. The block was in the file. `verify-inspector-dock` +had ten assertions naming it, every mutation was caught, `npm run validate` said OK, 53/53 +passed twice, and the braces balanced. In the browser's CSSOM there were **zero** rules +mentioning `#inspector-panel`, and the panel computed `position: static`, `max-height: none`. + +The cause was the comment directly above it. CSS comments **do not nest** — the first `*/` +found inside one ends it there. That comment was explaining `verify-comment-shape`'s own rule +and spelled the terminator inline to do it. So it ended mid-sentence, the rest of the prose +plus the `@media` keyword became the prelude of a qualified rule, its selector was invalid, and +the browser dropped the rule, declarations included, without a word. The braces balanced +because both blocks were still there — one of them just swallowed the other. + +Two things make this worth its own section: + +- **Brace balance cannot see it.** The damaged parse has exactly the same `{` and `}` as the + healthy one. Any assertion of the form "the block is well formed" is satisfied by the broken + version, which is why `verify-comment-shape` was green. +- **The evidence was in the residue, not in the rule.** Prose standing at the top level of a + stylesheet is never valid CSS. This file quotes prose with a backtick every time, so an + early-closed comment leaves backticks standing outside any comment. That is an assertion now, + and `mutate-comment-shape.py` reproduces the shipped line on purpose. + +The rule: **an assertion about a rule proves the text is there; only the browser proves a rule +exists.** When a measurement contradicts a green suite, suspect the parse before the logic — +and check whether the runtime is even serving the bytes on disk, because a stale runtime looks +exactly like a broken rule and costs an hour to tell apart. + +Two more from the same afternoon, both about reading: + +**A hand-written character can vanish without a word.** The first version of that new assertion +held a regular expression typed by hand whose closing slash was lost in transit — `\*\/.test` +instead of `\*\//.test`. It did not fail an assertion; it failed to parse, and the error pointed +at a later line complaining about invalid flags, so it read like a V8 quirk. It was found by +printing the bytes: `2f 5c 2a 5c 2f 2e` where a second `2f` belonged. **When code you wrote by +hand will not parse, print the characters before you theorise.** The assertion now uses string +slicing and contains no pattern at all. + +**A probe that finds nothing must not exit 0.** The scan looking for a record taller than the +ceiling reported `0 samples, 0 skipped` — not because every panel was hidden, but because +`#tablist` is empty in the markup until a record is chosen, so the loop never ran. Only the +deliberate "found nothing → exit non-zero" turned that into a failure instead of a pass. And +`if (r.cssRules)` is no longer a safe way to walk CSSOM: **CSS Nesting gave `CSSStyleRule` a +`cssRules` property**, so an empty list is truthy, every ordinary style rule gets recursed into +instead of read, and the walker reports an empty stylesheet that is in fact full. +### 8.6 A rule's scope is part of the claim, and the reason for the scope expires + +The sticky panel header was scoped to the dock's media query, and the comment said why: above +1080px the panel is not a scroll container and there is nothing to stick to. Every assertion +about it matched that scope. It was all correct — and then D40 gave the wide panel a ceiling +and an inner scroller, which is exactly what made the reason false. The rule stopped applying +where it was needed, and two guards stayed green the whole time because what they asserted — +"sticky only where the panel scrolls" — was still a true statement about a premise that had +died underneath it. + +Two things follow, and both are about how the claim is written rather than about the CSS. + +**When a change invalidates the reason for a scope, move the reason.** The four declarations are +cheap; the sentence that justified where they sit is the part that rots, and it rots silently +because nothing reads it. Lifting the rule without rewriting that sentence leaves the next +reader with a justification they can act on — put it back. The comment has to end up stating +the reason that is true *now*. + +**A reader cannot tell top level from inside a query by reading a value out.** `valueIn(css, +sel, prop)` matches any line-initial selector, so a rule buried in a media block reads exactly +like one at the top of the stylesheet — the old assertions only found the narrow copy because +they were handed `narrow` explicitly. Once the rule was unscoped, "is this declaration at the +top level" became a question the existing helpers could not answer, so the assertion now counts +braces to find every media body and checks the rule is in none of them. **Adding a value helper +is not the same as adding a scope check; the second one is usually the one that matters.** + +And the premise itself became an assertion. An unscoped sticky head is only harmless while the +panel is its own scroll container at every width, so that is now stated as a rule about +`overflow-y` on both sides of the breakpoint, rather than as a sentence in a comment. + +The companion failure is the mirror image: **an assertion sharper than the rule it stands for.** +The probe checked `headTop === panelTop` and found a 1px difference at both widths — that is the +panel's own border, and the assertion was measuring a border. It also demanded 400px of internal +scroll, which a 751px-tall panel holding an 831px record does not have. Both were me asserting +a tidier number than the rule talks about, and both failed on correct behaviour. Write the +assertion as the rule, not as its ideal. +### 8.7 Every guard covered one side; none covered the relation + +The two inspector ceilings were the same expression written twice, in two functions, from two +different sources. `syncStickyOffsets()` measured the strips and computed from its own locals; +`applyInspectorDock()` read the variables back and computed from those. Each had its own guards — +correct, complete guards for what each one does — and no guard anywhere said the two halves had to +agree. So the sum they share could change in one place only, the suite would stay green, and the +panel's `top` (built from the variables) would end up describing a stack of one height while its +ceiling reserved room for another. + +The fix is three assertions, and the interesting one is not about any single function: + +``` +both ceilings subtract their reserve from one shared sum, NOT from their own copy of it + && /innerHeight - band - overview/.test(applyFn) === false + && /innerHeight - band - overview/.test(syncFn) === false +``` + +The two negative clauses are the teeth. A positive assertion that both callers *use* the helper +passes just as happily while both callers keep their own copy of the arithmetic behind it — which is +the state this shipped in for two releases. **When the fix is "share one thing", assert the sharing +by what is gone, not only by what is there.** + +Two more generalisable shapes came out of the same change: + +- **Count the readers, not the readers you know about.** The assertion is on the number of + `getPropertyValue('--sticky-band-h')` occurrences in the whole script, so a second reader added + anywhere — a new function, a new branch — is the failure. Naming the two functions that exist + today would have made it a snapshot. +- **A stub that encodes the answer is not a stub.** `verify-sticky.mjs` runs the measurement in a vm, + and the cheap way to make the new layout branch run is `matchMedia: () => ({ matches: false })`. + That makes the stub agree with the file whatever the file does — which is the exact hazard the + comment beside `INSPECTOR_WIDE_BOTTOM_GAP` already documents for a hard-coded constant. The stub + reads the query out of the file and evaluates it against a real width instead. + +And the meta-point, which is the one worth carrying: **assertions cover behaviour, not relations +between behaviours.** Any time two things share a fact, the guards will happily cover each of them +and leave the sharing unguarded. Ask what would have to be true of BOTH for the page to be wrong, and +whether any assertion says that. + +### 8.8 A guard that crashes has not caught anything; a member is not a whole + +The inspector's header was pinned by a rule whose own comment above it named the loss as three rows — +the header, the 「第 N 轮 · #NNNNN · 思考」 line, and the whole tab bar. The rule pinned one of them. +Every assertion about it was true the whole time: "the header is sticky", "declared once at the top +level", "for this panel alone". Measured in a real browser, the other two rows had **0 of 36** and +**0 of 39** pixels left inside the panel's scrollport after scrolling to the bottom. + +Three things generalise, and the first is the one that should change how you write an assertion: + +- **Assert the container, not its members.** "This element is sticky" is a claim about one element. + When several elements are supposed to move together, the property belongs to the thing that holds + them, and the assertion has to be *containment* — all three inside the pinned unit, which is itself + a child of the right parent. Containment is what failed; membership is what was checked. +- **Assert what disappeared.** A member that declares its own `position: sticky` pins to the wrapper, + which does not scroll, so the declaration does nothing — and reads as though the row were handled. + Nothing else in the block notices. The negative clause (`no row inside declares a sticky of its + own`) is the only thing that catches it. +- **A guard that throws has caught nothing, and the harness will say so in words that mislead you.** + The new block dereferenced `panelRegion.start` without a null check. A mutation that renames the + panel's class makes `panelRegion` null, the guard dies on the `TypeError` before it prints a single + verdict, and the harness reports `no assertion reported ''`. That is a *correct* verdict about + a guard that crashes and a *misleading* one about the assertion that was supposed to catch it — + the two are easy to tell apart only if you read the stack trace the harness prints underneath. Fix + the guard before believing the mutation, and when a guard reads a structure other assertions build, + guard the lookup itself: `panelRegion ? elementRegion(...) : null`. + +The measurement also produced a negative result worth its cost. The reviewer's screenshot showed an +expand control in the wide layout while every wide measurement read it `hidden`, which made a leak of +narrow dock classes across a resize a plausible second defect. Crossing the breakpoint and reading the +class list, both controls and the grip cleared everything: `class` back to `panel`, both controls and +the grip back to `hidden`, the cap back to the wide number. **A hypothesis that costs one probe to +kill is cheaper than one you carry into the fix and design around.** + +And the reason two rounds of measurement read green over a live defect: the probe asserted against +one viewport whose panel happened to scroll only 80px, where the tab bar survives by accident and the +turn line keeps 5 of its 36 pixels. **The defect was load-dependent and the measurement was not.** A +screenshot of a wide window is not a wide viewport — measure the shortest cap and the longest content +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: + +> Keyboard operation of the 多选 checkbox is not verified end to end. `Shift+Tab` does not move +> focus in this harness — the focus ring stayed on 重置 and `Space` did nothing. The guard asserts +> the binding is `change` rather than `click` (`click` does not fire for a keyboard-toggled +> checkbox), with a mutation that fails if it is rewritten. That plus the platform guarantee is the +> whole of the claim. + +A harness limitation reported as a limitation is useful. The same limitation silently treated as +"probably works" is how a keyboard-only path ships broken. + +--- + +## Checklist + +``` +[ ] walked the states in the browser first, and read a parent container for a row of controls +[ ] every expected number comes from the fixture, counted without calling the code under test +[ ] units, unions and partial selections worked out by hand before the assertion was written +[ ] each new invariant has a mutation that is non-trivial (differs under the condition) and unique-anchored +[ ] family-level assertions own the mutations; per-instance ones are for humans +[ ] prose assertions are produced by running the function, numbers parsed back out of the string +[ ] no assertion name contains a colon +[ ] everything one reader can compare came from one snapshot +[ ] no layout offset is written down; anything pinned is measured, and measured at the end of render +[ ] every variable the stylesheet reads is written by the measurement, and one written is one read +[ ] every element the measurement reads is bound to an id the markup actually ships +[ ] things that must move together are asserted as containment, not as one member's property +[ ] a fix that removes something is asserted by what is gone; a guard that can throw is null-safe +[ ] anything not verified is written down as not verified +``` + +``` +node tests/run.mjs # everything +node tests/run.mjs --real # same guards, this machine's ~/.minimax +node tests/make-fixtures.mjs --check # the committed fixture: parses, covers all ten shapes, no machine paths +node tests/make-fixtures.mjs --checkselftest # prove that --check can reject a bad fixture +``` + +**Never run two suites at once in the same working tree.** The mutation harnesses write in +place: `mutate-anomaly.py` mutates `index.html` and puts it back, `mutate-fixture.py` does the same +to `make-fixtures.mjs`. Two suites running together read each other's mid-mutation state as their +own baseline. Measured: `run.mjs` and `run.mjs --real` side by side produced "the kind test +inverted" and "got 1, want 2" — both artifacts of the two runs clobbering each other, not defects. +After stopping them, `index.html` was in fact left mid-mutation (SHA256 differed from the workspace +copy); restoring from the workspace brought `git hash-object` back in line with HEAD. + +Same root cause as the rest of this document: **a harness that wrote something must not be the only +thing that judges what it wrote.** Concurrency is just another way to become the thing you are +checking against. + +`--check` judges the committed tree in place and never reads `~/.minimax`, so it works on a clean +runner. It compares shape, not bytes: the live store moves under every run, so a byte comparison +would be permanently red and chasing it is how a fixture gets half-regenerated for no reason anyone +can name. `mutate-fixture.py` is the record of how that decision was forced — see the note on +mutation A below. \ No newline at end of file diff --git a/plugins/avatasia/mmc-trajectory/tests/README.md b/plugins/avatasia/mmc-trajectory/tests/README.md new file mode 100644 index 0000000..d4814b1 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/tests/README.md @@ -0,0 +1,250 @@ +# 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 +node tests/run.mjs --real read this machine's ~/.minimax instead of the fixture +``` + +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. + +`METHODOLOGY.md` is the other half: not what these guards are, but how to arrive at new ones — why +an expected number may never come from the code under test, what to do when a mutation survives, and +why an assertion that has to be deleted usually means a requirement was never written down. + +## The session store under test + +`verify-parsers.mjs` and `verify-prompt-fix.mjs` need a session store on disk. Reading the +developer's own `~/.minimax` made them machine-dependent: on a clean CI runner the suite dropped +to 13/15 — `verify-parsers` crashed and `verify-prompt-fix` failed two checks. They now read the +committed synthetic store under `fixtures/minimax-home/`. + +The fixture is **distilled from real transcripts, not hand-written**. `make-fixtures.mjs` reads +this machine's `~/.minimax` and keeps exactly the structure the assertions depend on — roles, +content-part types, `stopReason` (the reply/narration split turns on it), `toolCallId` pairing, +usage numbers, and the injection markers `splitUserMessage()` branches on — while replacing every +string payload. No conversation text, path, or identifier from the real machine reaches the +fixture: a leak assertion on the rendered bytes refuses to write if one would. + +Regenerate it when the store's format changes: + +``` +node tests/make-fixtures.mjs report what would change, write nothing +node tests/make-fixtures.mjs --record rewrite fixtures/minimax-home/ +node tests/make-fixtures.mjs --selftest prove the leak assertion can actually fail +``` + +The generator also refuses to write unless every shape the guards rely on is present — user +prompt, injected block, tool call, thinking, reply, narration, tool result, compaction. Without +that gate a sparse sample would pass CI by accident and fail the moment anyone read it. + +Run both ways when you touch the parsers: the fixture proves the verdicts are reproducible, +`--real` proves they still hold against transcripts nobody curated. + +## The payload + +Seven guards used to want one `/api/trajectory` body and got it by booting the service and +fetching from a port. The route at `server.mjs` is `buildTrajectoryPayload(...)` plus a handful of +`session.*` fields, so the same body falls out of the fixture store offline. `make-payload.mjs` +writes it once: + +``` +node tests/make-payload.mjs report, write nothing +node tests/make-payload.mjs --record rewrite fixtures/trajectory.json +node tests/make-payload.mjs --check fail if the committed payload is stale +node tests/make-payload.mjs --real the same report against this machine's ~/.minimax +``` + +`--check` is what keeps the two fixtures honest with each other: change `make-fixtures.mjs` and +forget `--record`, and the payload stops matching the store it claims to come from. + +The fixture distiller walks a session's whole lineage, not just `messages.jsonl`. A compacted +session keeps its old messages in `snapshots/g--ctx_*.jsonl`, and the shapes the guards need +— an empty reply above all — live in those older generations. Reading only the active file finds +none of them, and the guards then fail for want of something to find. + +## 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) | +| `verify-anomaly.mjs` | the four 异常 rules, their units, their position on the page, and their exclusivity with the other axis | +| `verify-sticky.mjs` | both filter rows pin as one band, and every band below it is measured rather than written down | +| `verify-block-order.mjs` | assistant blocks are ordered per message: thinking → text → toolCall | +| `verify-response-rail.mjs` | every row carries the rail of the response it came from | +| `verify-call-fold-coverage.mjs` | folding every tool call really removes them from the rendered build | +| `make-fixtures.mjs` | distils the local session store into the committed fixture; refuses to write on a leak | +| `make-payload.mjs` | builds `/api/trajectory` offline from that fixture; `--check` catches drift | +| `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 | +| `mutate-anomaly.py` | each of the 74 异常 mutations, in the page, has a guard that can fail | +| `mutate-comment-shape.py` | the comment blocks an editing probe leaves behind are the shape it was asked to remove, not a doubled opener | +| `mutate-steer.py` | the steer split and the turn classification hold | +| `mutate-window-default.py` | the window defaults, labels, resets and load-more hold | +| `mutate-sticky.py` | each of the 18 pinned-band rules has a guard that can fail | + +## The 异常 axis, and what it promises + +`异常` is a second filter system, not a fourth field of the first one. Entering it switches the +type axis, the search box, the time range and the overview brush off **and puts them back to their +default**; `重置` returns the row to its default, where the table is filtered by the row above +again. Five properties are asserted, each with a mutation control, because each was observed to +be false in this file's own history: + +1. **Ticking a rule puts the row above back to its default.** In this mode that row filters + nothing, so leaving it holding 「用户」 would be the page displaying a condition that is not in + force — the same defect as a button whose number does not match its list, one control over. + `resetBaseFilters()` sets the state *and* calls `syncKindButtons()`; a write without the sync + leaves the axis drawing what was there before, and that is what the last mutation control here + breaks. `重置` is not a toggle either: the second press filters nothing, it does nothing at all. + And `重置` carries no count, because 「怎么多会留下多少」 is a question about a rule, and a control + that keeps whatever the row above says has no answer to it. +2. **The number on a button is what you get when you tick that button.** `anomalyCounts()` walks + the same records `visibleEntries()` does and neither of them walks the other axis. Counting + through the shared visibility entry — which is what this guard used to *require* — produced a + button promising 283 rows above a list of 1,494, and no functional assertion could tell, + because the ledger was filtering the same way the count was. +3. **The unit is part of the claim.** A rule scoped to a record is published in 条; a rule scoped + to a turn is published in 轮, and counted once per *turn*. `无答复` counted in records is how + a session broken 13 times came to advertise 1,244 "unanswered" rows, none of which carried a + sign of being unanswered. `verify-anomaly.mjs` checks the unit against the rule table, not + against a literal, so renaming or rescoping a rule cannot leave the two disagreeing. +4. **There is one entry, and the mode branches first.** `recordVisible(record, turn, selection)` + is the only thing that answers "is this record on screen". `visibleEntries()` walks it once + per row and `spanPassesFilter()` reaches it after looking the turn back up; neither holds a + predicate. The strip has to agree with the list: when it drew spans for records the list was + not rendering, the "filtered but nothing changed" failure the overview had already paid for + once came straight back. Two assertions pin the shape that stops it coming back — the strip + keeps no predicate, and 异常 branches and returns before any other axis is consulted. +5. **The one caller that answers less has to say so.** The overview brush is an argument to + `recordVisible()` and not a read of state, because the strip passes `null` on purpose: the + brush is drawn on that strip, and a strip that hid the records you were dragging a window + over could not be used to drag one. Every other condition applies to both surfaces, so the + brush is the only thing that legitimately differs — and naming it in the signature is what + stops the next reader from tidying the difference away. +6. **A control that cannot change what is on screen must not look like it can.** The inactive + axis is `inert`, not merely dimmed, so neither the pointer nor keyboard focus can reach a + button that does nothing. The session and generation pickers are deliberately unmarked — they + choose which session is on screen, not which records of it — and the guard asserts exactly + which four elements carry the marker. + +Position is asserted too, on its own terms: the axis has to come after the type axis, must not +be a field of the search form any more, and the KPI cards must not come between them. It used +to be the last field of the form, four cards above the axis it reads as part of — a layout defect +that no functional assertion in the suite could see. + +## The pinned bands, and why none of them carries a number + +Four things pin themselves while the ledger scrolls under them: the filter band (both axes), the +overview strip, the window bar, and the turn head. Three properties are asserted, and each one was +false in this file's own history. + +1. **The two filter rows pin as one block.** They answer the same question — which records the + ledger shows — so a state where the type row is still pinned and the 异常 row has scrolled away + is the worst of both: the reader is told nothing is filtered while half the filter is off + screen. Only the type row ever claimed `position: sticky` and it did not survive; the guard + now asserts that neither row pins itself and that both are inside the band, because a band that + does not contain both is the same defect wearing a different hat. +2. **No pinned offset is written down.** The band is two rows whose height changes when the 异常 row + wraps at narrow widths and when its note appears or disappears, so `0–32` and `32–83` were + correct only for a layout without the 异常 row. `syncStickyOffsets()` measures the three bands at + the end of `render()` and writes them into `--sticky-band-h`, `--sticky-overview-h` and + `--sticky-window-h`; the stylesheet only ever reads those. The guard runs that function against + stub heights of 137 / 65 / 57 — no constant in the file is any of them — so a version that + writes a number instead of a measurement fails, and so does one that measures the band for the + strip. +3. **Anything pinned over the ledger has to paint a background.** The rows underneath are + `background: transparent`, so a pinned band that stops covering them shows them through. The + strip paints through `.overview-plot` rather than through the sticky section, which is why the + guard names that rule and not `.overview`. + +Two more assertions exist because the mutation control found them missing: the set of variables the +stylesheet reads has to be **exactly** the set the measurement writes (one side alone lets a stale +fallback or a dead measurement through), and every band the function reads has to be bound to an +element the markup actually ships. A band that is never bound measures nothing, all three offsets +fall back to their constants, and no error is reported anywhere — the stub the guard measures with +cannot catch it, so the binding is checked against the markup instead. + +The order of `render()` is asserted too: the measurement has to be the last statement, because the +异常 note is written by `syncAnomalyMode()` and that note is what changes the band's height. + +## Deliberately not in the default run + +Shipping a suite that is red teaches reviewers to ignore it, so these are present but skipped, +with the reason stated rather than quietly dropped. + +- **`mutate-filter-guards.mjs`** — not carried. 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`** — not carried. Guards the 问答 turn-lens. Same removal; no lens symbol + remains. +- **`verify-time-range.mjs`** and its control **`mutate-time-range.py`** — present, skipped by + `run.mjs`. Its assertions are calibrated against a full multi-generation session: + `stats.messages > 1000`, and a checkpoint nested inside a turn rather than being one. The + committed fixture is a single-file session of a few dozen rows. Reproducing those numbers + faithfully would mean committing a multi-megabyte payload; relaxing them would stop exercising + the timezone arithmetic the file exists for. Run it by hand against a booted service: + `node tests/verify-time-range.mjs `. + +### Two anchors that were wrong, and one mutation that could not be caught + +Kept here because each cost real time to find, and each would look like "the guard is stale": + +- **`mutate-steer.py`** was not stale. `server.mjs` was CRLF and the anchor joined its lines with + `\n`, so a multi-line anchor matched zero times and read as drift. Single-line anchors in the + same file matched fine. Every harness now measures the ending out of the file it is about to + edit, and no anchor contains one at all. +- **`mutate-host-only.py`** was the last harness still hardcoding `L = "\r\n"`. `.gitattributes` + pinned the working tree to LF and it stopped matching — a correct failure, for a reason nobody + had chosen. It is the same class as the one above, and the reason the rule is now "no anchor + contains a line ending" rather than "build it in the right ending". +- **`mutate-window-default.py`** was genuinely stale, but not where it looked: the rule still + exists, and `rePinWindow` had gained a time-range early-return *between* the two lines the + anchor spanned. It is anchored on the one line that is the rule. +- **`mutate-anomaly.py`** does not carry a case that nulls the page's `liveTurn`, even though that + looked like the obvious thing to break. `verify-anomaly.mjs` re-derives the live turn itself + instead of calling the page's function, so the mutation is invisible to every assertion in the + file — it was tried and the probe passed. Shipping it would mean shipping a mutation known to + be unobservable. Until the harness stops re-deriving it, the page's `liveTurn` has no guard of + its own. + +## One-shot patches are not here either + +Development also produced scripts that **write the package** to apply one specific edit. They are +deliberately excluded: 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. They have since been removed rather than kept with a warning header — what each one did is +recorded in the development directory's index instead. + +**Do not go looking for a one-shot patch runner.** 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 ')); +const css = lines.slice(styleOpen + 1, styleClose).join('\n'); + +// Named once so the tokenizer and the assertions cannot drift apart, and so comment +// syntax never has to be retyped as a pattern anywhere in this file. See the note in the +// block below about a slash lost in transcription. +const OPEN = '/*'; +const CLOSE = '*/'; + +// Browser semantics, walked the only way that shows the residue: an opener starts a comment, the +// FIRST terminator ends it, and a second opener inside the body is inert text. +const cssComments = []; +{ + let at = 0; + while (at < css.length) { + const open = css.indexOf(OPEN, at); + if (open < 0) break; + const close = css.indexOf(CLOSE, open + OPEN.length); + if (close < 0) break; + cssComments.push(css.slice(open, close + 2)); + at = close + 2; + } +} + +{ + // An opener with no terminator swallows everything after it — the same outage, different + // mechanism, equally invisible from outside. + const openers = css.split(OPEN).length - 1; + assert('every CSS comment in the style block is terminated', openers === cssComments.length, + openers + ' opener(s) but ' + cssComments.length + ' terminated comment(s)'); +} + +{ + // The terminator this file's CSS comments ride on. A line whose terminator is followed by + // anything else is a comment that ended before its author meant it to: the intended closer is + // the one at the end of the prose, so anything trailing an earlier one is that prose spilling + // out. This is deliberately NOT derived from the tokenizer above — a comment sliced out by CSS + // semantics cannot contain a terminator by construction, so asking that would be a check that + // cannot fail. This one reads the file's convention instead, and can. + // + // String slicing rather than a regular expression, on purpose: this very line was hand-written + // once already and came out missing the slash that closes its pattern, which turned the guard + // into a file that does not parse. See METHODOLOGY.md 8.5 — a character lost in transcription is + // indistinguishable from a design mistake until you read the bytes back. + const inStyle = (n) => n >= styleOpen + 2 && n <= styleClose; + const trailing = lines + .map((l, i) => [i + 1, l]) + .filter(([n, l]) => { + if (!inStyle(n) || !l.includes(CLOSE)) return false; + return l.slice(l.lastIndexOf(CLOSE) + 2).trim() !== ''; + }); + assert('every comment terminator in the style block ends its line', trailing.length === 0, + trailing.length ? trailing.slice(0, 3).map(([n, l]) => 'line ' + n + ': ' + l.trim().slice(0, 60)).join(' / ') : ''); +} + +{ + // Prose leaking to the top level of a stylesheet is never valid CSS, and since prose is quoted + // here, a backtick still standing outside a comment is that leak made visible. + const outside = []; + let at = 0; + for (const c of cssComments) { + // Advance from where the comment was actually FOUND, not from the previous position — + // the first comment starts well after 0, and stepping by length alone leaves the gap + // misaligned, which reports perfectly good comments as spilled prose. + const start = css.indexOf(c, at); + outside.push(css.slice(at, start)); + at = start + c.length; + } + outside.push(css.slice(at)); + const leaked = outside.join('').split('\n') + .map((l, i) => [styleOpen + 2 + i, l]) + .filter(([, l]) => l.includes('`')); + assert('no prose survives outside a CSS comment', leaked.length === 0, + leaked.length ? leaked.slice(0, 3).map(([n, l]) => 'line ' + n + ': ' + l.trim().slice(0, 60)).join(' / ') : ''); +} + +{ + // The same convention the JS assertions above hold the JS syntax to, applied to the other one: + // the closer rides on the last line of the prose rather than alone on a line of its own. + const bare = cssComments.filter((c) => c.split('\n').pop().trim().slice(0, -2).trim() === ''); + assert('every CSS comment closes after its prose, not on a line of its own', bare.length === 0, + bare.length ? bare.length + ' comment(s) close bare' : ''); +} + +if (process.argv.includes('--list')) { + for (const name of declared) console.log(name); + process.exit(0); +} + +console.log(pass + ' checks passed' + (failures.length ? ', ' + failures.length + ' FAILED' : ', ALL PASSED')); +process.exit(failures.length ? 1 : 0); \ No newline at end of file diff --git a/plugins/avatasia/mmc-trajectory/tests/verify-compaction-turn.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-compaction-turn.mjs new file mode 100644 index 0000000..5717c7a --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/tests/verify-compaction-turn.mjs @@ -0,0 +1,164 @@ +// The runtime writes a turn's compaction checkpoint under ":compaction" while every row +// after it carries the bare id. Keying on the whole string gave the checkpoint a turn of its own, +// emitted after the whole turn it interrupts. These checks pin the corrected grouping. +// +// Read-only: imports the pure builder, never starts the runtime. +import { buildTrajectoryPayload } from '../miniapp/node/server.mjs'; + +let pass = 0; +const failures = []; +function check(name, actual, expected) { + const ok = JSON.stringify(actual) === JSON.stringify(expected); + if (ok) pass += 1; + else failures.push(`${name}: got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)}`); +} +function assert(name, cond) { + if (cond) pass += 1; + else failures.push(name); +} + +const T = 1_700_000_000_000; +const user = (turn, text, at) => ({ + message_id: `u-${turn}`, + turn_id: turn, + message: { role: 'user', content: [{ type: 'text', text }], timestamp: at }, +}); +const assistant = (turn, text, at) => ({ + message_id: `a-${turn}`, + turn_id: turn, + message: { role: 'assistant', content: [{ type: 'text', text }], timestamp: at, stopReason: 'stop' }, +}); +const checkpoint = (turn, generation, at) => ({ + message_id: `c-${turn}`, + turn_id: `${turn}:compaction`, + history_artifact: { generation, parentSnapshot: { generation: generation - 1 } }, + message: { role: 'compactionSummary', content: [{ type: 'text', text: 'summary' }], timestamp: at }, +}); + +/* ---- a turn interrupted by a compaction stays one turn, in order ---- */ +const TURN = 'turn-1'; +const payload = buildTrajectoryPayload({ + session: { id: 'mvs_1', createdAtMs: T }, + rows: [ + user(TURN, 'do the thing', T), + assistant(TURN, 'working on it', T + 1000), + checkpoint(TURN, 1, T + 2000), + assistant(TURN, 'done', T + 3000), + ], +}); + +check('the checkpoint does not open a turn of its own', payload.turns.length, 1); +check( + 'every record is in that one turn', + payload.turns[0].records.map((r) => r.kind), + ['user', 'reply', 'compaction', 'reply'], +); +check('indexes run 1..n with no gap', payload.turns[0].records.map((r) => r.index), [1, 2, 3, 4]); +check('the turn keeps the bare id it interrupted', payload.turns[0].id, TURN); +assert( + 'the turn is classified by what it did, not by the checkpoint inside it', + payload.turns[0].turnClass !== 'compaction', +); +check('the checkpoint is still counted as a record', payload.stats.compactions, 1); +check('no turn is left classified as a checkpoint', payload.stats.compactionTurns, 0); +// A record has to name a turn that exists. The file's suffixed id names none, so leaving it on +// the record is how a checkpoint ends up claiming a turn the payload never contains. +check( + 'every record names a turn that exists', + payload.turns.every((t) => t.records.every((r) => r.turnId === t.id)), + true, +); +check('the checkpoint record names that turn', payload.turns[0].records[2].turnId, TURN); + +/* ---- the suffix is only stripped from an actual checkpoint ---- */ +// Same id shape, but an ordinary assistant row. It must keep its own turn: the rule is gated on +// the role, not on the string, so a turn genuinely named "...:compaction" is not swallowed. +const impostor = buildTrajectoryPayload({ + session: { id: 'mvs_1', createdAtMs: T }, + rows: [ + user(TURN, 'hello', T), + assistant(TURN, 'hi', T + 10), + { message_id: 'x', turn_id: `${TURN}:compaction`, message: { role: 'assistant', content: [{ type: 'text', text: 'not a checkpoint' }], timestamp: T + 20 } }, + ], +}); +check('a non-checkpoint keeps its own turn', impostor.turns.length, 2); +check('and it is not counted as a compaction', impostor.stats.compactions, 0); + +/* ---- a bare suffix must not collapse into the turn-less bucket ---- */ +// turn_id exactly ":compaction" strips to "", which is the key every row with no turn_id uses. +// Guarded by the length check so it stays its own turn instead of merging with all of them. +// Built inline rather than through `checkpoint()`, which would have made it ":compaction:compaction". +const bareCheckpoint = { + message_id: 'bare', + turn_id: ':compaction', + history_artifact: { generation: 1, parentSnapshot: { generation: 0 } }, + message: { role: 'compactionSummary', content: [{ type: 'text', text: 'summary' }], timestamp: T + 5 }, +}; +const bare = buildTrajectoryPayload({ + session: { id: 'mvs_1', createdAtMs: T }, + rows: [ + { message_id: 'n1', message: { role: 'user', content: [{ type: 'text', text: 'no turn id' }], timestamp: T } }, + bareCheckpoint, + ], +}); +check('a bare ":compaction" id stays separate from turn-less rows', bare.turns.length, 2); +check('and the turn-less row is still first', bare.turns[0].records[0].kind, 'user'); + +/* ---- a turn with no prompt and no reply is not a checkpoint just for holding one ---- */ +// It is a stretch of work the file never opened. Calling it a checkpoint would take it out of the +// prompted count and name it after something it is not. +// Built without the prompt on purpose: what is under test is the classification of a turn that +// holds a checkpoint alongside work but was never opened by the reader. +const workOnly = buildTrajectoryPayload({ + session: { id: 'mvs_1', createdAtMs: T }, + rows: [ + checkpoint('turn-w', 1, T + 10), + { message_id: 'c1', turn_id: 'turn-w', message: { role: 'toolCall', content: [{ type: 'toolCall', name: 'bash' }], timestamp: T + 20 } }, + { message_id: 'c2', turn_id: 'turn-w', message: { role: 'toolResult', content: [{ type: 'text', text: 'ok' }], timestamp: T + 30 } }, + ], +}); +assert('a turn holding work and a checkpoint is not classed as a checkpoint', workOnly.turns[0].turnClass !== 'compaction'); +check('it counts as a turn without a prompt', workOnly.stats.turnsWithoutPrompt, 1); +check('and it is not in the prompted count either', workOnly.stats.turnsWithPrompt, 0); + +/* ---- two turns separated by a checkpoint each keep their own ---- */ +const both = buildTrajectoryPayload({ + session: { id: 'mvs_1', createdAtMs: T }, + rows: [ + user('turn-a', 'first', T), + checkpoint('turn-a', 1, T + 10), + assistant('turn-a', 'a done', T + 20), + user('turn-b', 'second', T + 1000), + checkpoint('turn-b', 2, T + 1010), + assistant('turn-b', 'b done', T + 1020), + ], +}); +check('two turns stay two turns', both.turns.map((t) => t.id), ['turn-a', 'turn-b']); +check('each turn keeps its own checkpoint', both.turns.map((t) => t.records.length), [3, 3]); +check('indexes are contiguous across both', both.turns.flatMap((t) => t.records.map((r) => r.index)), [1, 2, 3, 4, 5, 6]); +check('both checkpoints counted', both.stats.compactions, 2); + +/* ---- timestamps going backwards inside a turn stay put ---- */ +// A steer carries the time the reader typed, which is before the tool result it interrupted. The +// ledger order is causal and must not be re-sorted to hide that. +const steered = buildTrajectoryPayload({ + session: { id: 'mvs_1', createdAtMs: T }, + rows: [ + user('turn-s', 'go', T), + { message_id: 'r1', turn_id: 'turn-s', message: { role: 'toolResult', content: [{ type: 'text', text: 'ok' }], timestamp: T + 5000 } }, + { message_id: 's1', turn_id: 'turn-s', message: { role: 'user', content: [{ type: 'text', text: 'actually, do this instead' }], timestamp: T + 1000 } }, + ], +}); +check( + 'a steer stays after the tool result it interrupted', + steered.turns[0].records.map((r) => r.kind), + ['user', 'toolResult', 'steer'], +); +check('and its index still follows the file order', steered.turns[0].records.map((r) => r.index), [1, 2, 3]); + +if (failures.length > 0) { + console.error(`FAIL ${failures.length} of ${pass + failures.length}`); + for (const failure of failures) console.error(' - ' + failure); + process.exit(1); +} +console.log(`\nALL ${pass} CHECKS PASSED`); \ No newline at end of file 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..0931736 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/tests/verify-cross-layer.mjs @@ -0,0 +1,276 @@ +/** + * 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 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. + */ +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) { + // 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 = htmlNoComments.indexOf('{', at); j < htmlNoComments.length; j += 1) { + if (htmlNoComments[j] === '{') depth += 1; + else if (htmlNoComments[j] === '}') { + depth -= 1; + if (depth === 0) return htmlNoComments.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; +} + +// ----------------------------------------------------------------- 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 +// 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', + /
src !== null && /state\.overviewSelection\s*=[^=]/.test(src); + + 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); +console.log(pass + ' checks passed' + (failures.length ? ', ' + failures.length + ' FAILED' : ', ALL PASSED')); +process.exit(failures.length ? 1 : 0); diff --git a/plugins/avatasia/mmc-trajectory/tests/verify-fixture.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-fixture.mjs new file mode 100644 index 0000000..ae383a4 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/tests/verify-fixture.mjs @@ -0,0 +1,178 @@ +/** + * `--check` used to be a command that could not fail. + * + * make-fixtures.mjs printed "(dry run -- pass --record to write)" and exited 0 for every input, + * `--check` was not an option at all, and AGENTS.md listed `node tests/make-fixtures.mjs --check` + * as a command whose output had to stay green. Six of the eight committed fixture files had + * meanwhile drifted away from the workspace copies, and no signal existed: the command that was + * supposed to notice was a no-op, and the real problem -- the fixture being half-regenerated for + * reasons nobody could name -- could not be seen through a byte comparison anyway, because the + * live store moves under it. + * + * So the promise had to be re-derived rather than implemented literally. What was actually ever + * promised is not "identical to what this machine distils today": that drifts with every session + * written and chasing it is what half-regenerates the tree. It is the weaker, durable one -- the + * committed fixture still parses the way the service reads it, still covers every shape + * verify-anomaly.mjs asserts on, still carries nothing from this machine, and still sits inside the + * space the distil rules describe (distil it again and it must read the same). + * + * Two properties this guard depends on, both of them checked here rather than assumed: + * + * 1. `--check` can go red. `--checkselftest` builds a good fixture and six broken ones -- a + * leaked machine path, a shape nobody covers any more, an empty-string payload that stops + * being empty once synthesised, too few sessions, too few rows, and one that cannot pass -- + * and requires each to be rejected for its own reason. Without this, "fixture check OK" is a + * string, not a verdict. + * 2. The committed fixture passes. That is the state anyone else will clone. + * + * The second half of the file drives the `--check` branch itself rather than checkFixture(), and + * that split is not tidiness -- it is the whole lesson of mutation A. Disabling the branch that + * prints the verdict leaves the function judging correctly, leaves --checkselftest passing, and + * leaves `node tests/make-fixtures.mjs --check` printing "fixture check OK" and exiting 0. It + * survived the first version of this guard. A property can be computed correctly and still be + * invisible from every path anybody actually runs, so the broken trees are pushed through the real + * CLI and read by exit code, using --root and --emit-demo-tree so the committed bytes are never + * the thing being broken. + * + * mutate-fixture.py carries those mutations as a permanent record; the shapeOf() structure check + * is deliberately not in it, with the reason written there. + */ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const script = join(HERE, 'make-fixtures.mjs'); + +let pass = 0; +const failures = []; +function assert(name, ok, detail) { + if (ok) { pass += 1; return; } + failures.push(detail ? `${name} — ${detail}` : name); +} + +function run(...flags) { + const r = spawnSync(process.execPath, [script, ...flags], { encoding: 'utf8', cwd: HERE, maxBuffer: 16 * 1024 * 1024 }); + return { code: r.status, out: `${r.stdout ?? ''}${r.stderr ?? ''}`.trim() }; +} + +/* ---------- 1. the gate has to be able to reject ---------- */ + +const self = run('--checkselftest'); +assert('--checkselftest passes: the gate clears a good tree and rejects each broken one', + self.code === 0, self.out.split('\n').filter((l) => /MISSED|FAILED/.test(l)).join(' | ') || `exit ${self.code}`); + +// Four cases, and each must be rejected for its *own* reason. A gate that rejected the broken trees +// with some unrelated complaint -- "too few sessions", say -- would satisfy a weaker version of +// this and prove nothing about leaks or coverage. +for (const [label, kind] of [ + ['machine path leaked in', 'fixture leak'], + ['a shape went missing', 'MISSING coverage'], + ['empty payload drifts', 'changes coverage when distilled again'], +]) { + assert(`--check rejects "${label}" by naming ${kind}`, + new RegExp(`ok\\s+${label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`).test(self.out)); +} +assert('the good tree is the one case that comes back clean', + /ok\s+good tree\s+clean/.test(self.out) && !/MISSED/.test(self.out)); + +// The wiring, not the verdict. On a healthy tree every case passes, so `ok` is never cleared and +// "a case was missed -> the process exits non-zero" is invisible from outside. Force the failure. +const brokenSelf = run('--checkselftest-broken'); +assert('a missed case makes --checkselftest exit non-zero and say so', + brokenSelf.code === 1 && /MISSED\s+this case cannot pass/.test(brokenSelf.out) && /check self-test FAILED/.test(brokenSelf.out), + `exit ${brokenSelf.code}: ${brokenSelf.out.split('\n').filter((l) => /MISSED|FAILED/.test(l)).join(' | ')}`); + +/* ---------- 2. the committed fixture has to pass ---------- */ + +const checked = run('--check'); +assert('the committed fixture passes --check', checked.code === 0, + checked.out.split('\n').filter((l) => /^ {2}- /.test(l)).slice(0, 3).join(' | ') || `exit ${checked.code}`); +// The success line, not just the exit code. Delete the --check branch and the script falls through +// to the distil path, which on a machine that happens to have ~/.minimax also exits 0 and also +// prints a coverage list -- the guard would call that a passing check. Only this line is unique to +// the branch that judges the committed tree. +assert('--check announces itself rather than falling through to the distiller', + /\nfixture check OK/.test(checked.out) && !/dry run/.test(checked.out)); +// This used to read "--check reports coverage for all ten shapes" and match the fixed NEEDED_NAMES +// list the script prints. It is boilerplate: the list is a constant, so the assertion would still +// pass with coverage enforcement deleted. It is gone rather than reworded. The real coverage claim +// is the `hole` case below, which drives the branch and reads the exit code. +assert('--check finds no coverage problem in the committed fixture', + !/MISSING coverage/.test(checked.out), checked.out.split('\n').filter((l) => /MISSING/.test(l)).join(' | ')); + +// "It never reads the live store" used to be asserted by counting a number in the output, which +// proves nothing -- any run prints a count. Asserted properly instead: run it with the home +// directory pointed at an empty temp dir, so ~/.minimax cannot be there. If the branch ever grows +// a read of the real store, this is the assertion that notices. +const noHome = mkdtempSync(join(tmpdir(), 'fixture-nohome-')); +const orphaned = spawnSync(process.execPath, [script, '--check'], { + encoding: 'utf8', cwd: HERE, maxBuffer: 16 * 1024 * 1024, + env: { ...process.env, HOME: noHome, USERPROFILE: noHome, HOMEDRIVE: '', HOMEPATH: '' }, +}); +rmSync(noHome, { recursive: true, force: true }); +assert('--check passes with no ~/.minimax anywhere, so it really is independent of the live store', + orphaned.status === 0 && /\nfixture check OK/.test(orphaned.stdout ?? ''), + `exit ${orphaned.status}: ${`${orphaned.stdout ?? ''}${orphaned.stderr ?? ''}`.split('\n').slice(-1)[0] ?? ''}`); + +/* ---------- 3. the --check branch itself must report the verdict ---------- */ + +// Everything above proves checkFixture() works. None of it proves the branch a person actually +// types reports it: neuter `if (r.problems.length)` and the function still judges correctly, the +// self-test still passes, and `node tests/make-fixtures.mjs --check` still prints "fixture check +// OK" and exits 0. That mutation survived the first version of this guard. These four assertions +// are the ones that catch it -- they drive the real branch, over a real tree, and read its exit +// code rather than its prose. + +function checkTree(which) { + const made = run(`--emit-demo-tree=${which}`); + if (made.code !== 0) return { code: made.code, out: made.out, root: '' }; + const root = made.out.split('\n').filter(Boolean).at(-1); + const r = spawnSync(process.execPath, [script, '--check', `--root=${root}`], { encoding: 'utf8', cwd: HERE, maxBuffer: 16 * 1024 * 1024 }); + const out = `${r.stdout ?? ''}${r.stderr ?? ''}`; + rmSync(root, { recursive: true, force: true }); + return { code: r.status, out: out.trim(), root }; +} + +const COMMITTED_ROOT = join(HERE, 'fixtures', 'minimax-home'); + +const goodTree = checkTree('good'); +assert('--check exits 0 on a tree that is genuinely fine', goodTree.code === 0 && /fixture check OK/.test(goodTree.out), + `exit ${goodTree.code}: ${goodTree.out.split('\n').slice(-2).join(' | ')}`); +// Which tree it judged is the one thing a reader of the output takes away from it. A branch that +// computed the right verdict against the wrong directory and printed the committed one's name +// would satisfy every other assertion here while lying to whoever reads the output. +assert('--check names the tree it actually judged', + goodTree.out.includes(`fixture root : ${goodTree.root}`) && !goodTree.out.includes(COMMITTED_ROOT), + goodTree.out.split('\n')[0]); + +for (const [which, named] of [ + ['leak', /fixture leak/], + ['hole', /MISSING coverage/], + ['drift', /changes coverage when distilled again/], + ['thin', /verify-parsers\.mjs asserts entries\.length > 5/], + ['short', /below the 12 floor/], + // The three codex found this check disagreeing with the service it stands in for. Each was + // green before the fix, which is the only reason any of them are here. + ['unpaired', /no toolResult pairs with a toolCall/], + ['badid', /is not mvs_<32 hex>/], + ['misplaced', /the service finds no sessions under/], +]) { + const t = checkTree(which); + assert(`--check exits 1 on a "${which}" tree and names the problem`, t.code === 1 && named.test(t.out), + `exit ${t.code}: ${t.out.split('\n').slice(-1)[0] ?? ''}`); +} + +/* ---------- 4. the writer must not be able to grade itself ---------- */ + +const both = run('--check', '--record'); +assert('--check and --record refuse to run together', both.code === 2 && /pick one/.test(both.out), + `exit ${both.code}: ${both.out.split('\n').slice(-1)[0] ?? ''}`); + +console.log(`[fixture] ${pass} passed, ${failures.length} failed`); +if (failures.length) { + console.log('FAILURES:\n - ' + failures.join('\n - ')); + process.exitCode = 1; +} \ No newline at end of file diff --git a/plugins/avatasia/mmc-trajectory/tests/verify-generation-pin.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-generation-pin.mjs new file mode 100644 index 0000000..6bb084d --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/tests/verify-generation-pin.mjs @@ -0,0 +1,407 @@ +/** + * The 上下文代 picker has to be readable from anywhere in the ledger, and it was not. + * + * What the reviewer reported on 2026-10-12 was 「上下文代的筛选条件没有了」. It was not gone: the element + * ships without `hidden`, carries all 29 options and holds 「全部(默认)」. It lived in #toolbar, + * which is the one block above the band that scrolls away — so a reader 6,000 px into the ledger + * had to climb back to the top of the page to change generation, and from up there the control + * that says which slice of the conversation is on screen is not on screen. Nothing was broken; + * the control was simply somewhere the reader was not. No existing guard asserted where it lived, + * which is why nothing complained. + * + * The invariants, and what each one is for: + * + * 1. the picker ships inside #filter-sticky. This is the fix. Everything else is what keeps it + * correct once it is there. + * 2. it is NOT inside #kind-controls. That element is `role="toolbar" aria-label="按类型筛选轨迹"`, + * so a control that filters generations inside it makes the label announce something false. + * 3. it carries no `data-filter-scope`. The 异常 axis dims and disables every control carrying + * that attribute, on the grounds that they cannot change what is on screen while a rule is + * on. Generation is not such a control — a rule narrows one context, it does not forbid + * choosing another — and marking it would disable the one thing a reader most wants to do + * while reading a list of failures. + * 4. the banner ships inside the band too. It is the picker's `aria-describedby` target and it + * only exists while a generation is pinned, which is exactly when a reader who has scrolled + * deep into a frozen pre-compaction snapshot has forgotten. Left in the ledger panel it + * scrolls away while the control that caused it stays pinned. + * 5. the picker no longer ships in #toolbar. Asserted directly rather than as the absence of + * something, so a copy left behind fails instead of quietly serving a second instance. + * 6. the row wraps. `.traj-controls` is `flex: none`, so without `flex-wrap` a tail that does not + * fit pushes the row wider than the page instead of stepping down to a line of its own. + * 7. the toolbar's own narrow-screen rules are overridden for the band. `.field { flex: 1 1 100% }` + * and `select { width: 100% }` under `max-width: 767px` were written for fields that get a + * whole line each. In a `flex: none` tail they would size the picker against the tail and grow + * the band sideways. Written even though no browser here is narrow enough to exercise it. + * 8. the picker is sized like the chips around it (20px, not the toolbar's 36px). The band is + * pinned over the ledger: its height is reading area the reader does not get. + * 9. the band is not driven from outside itself. It used to be: a `body.is-reading-down` class, + * toggled from a scroll listener by the direction the reader was scrolling, decided whether the + * picker was on screen. the reviewer reported on 2026-10-12 that it 「只要滚动就不见」. A filter that + * hides itself the moment the reader starts reading is gone exactly when it is wanted, scroll + * direction is not a statement about which filters the reader wants, and visibility inside a + * band is the band's own business. D19 is withdrawn; these three are what stops it coming back. + * + * `mutate-generation-pin.py` breaks each of these in the shipped file 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 HTML = fileURLToPath(new URL('../miniapp/client/index.html', import.meta.url)); +const html = readFileSync(HTML, 'utf8'); + +const styleMatch = / 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 one that was missing, and the one that cost a day: the class goes on LAST. +// +// `content-visibility: auto` lays a skipped element out by its contain-intrinsic-size, and an +// element with none is laid out as if it were empty. Switching skipping on before sizeBuiltGroup() +// therefore does not merely degrade the measurement — it makes it read 0, and 0 is then written +// onto the group. A group sized 0px takes up no space, so it can never re-enter the viewport, so +// it is never re-rendered: the ledger stays collapsed and a 140 KB list becomes a 2,000 px page. +// It survived D15's own A/B because that ran on 显示全部, where the batch is long enough to be in +// the viewport; in the default window 200 records span fewer turns than LEDGER_BATCH_GROUPS, so +// the whole ledger is this batch and every group in it is measured under skipping. +assert('the class goes on after the first batch has been measured, not before', + sizeFirst > 0 && classOn > sizeFirst); + +// The one that actually shipped the bug, and the reason the ordering above was not enough. +// +// `is-skipping-offscreen` is set on the ledger element and `clear(ledger)` empties that +// element's children without touching its own class. So the class survives into the next build, +// every group appended by it lands already skipped, and the first batch is measured through +// `content-visibility: auto` — which lays a skipped element out by its contain-intrinsic-size, +// i.e. by the number being written. First build after a page load measures correctly; every +// rebuild after it measures 0. That is why the ledger worked on arrival and collapsed a couple of +// seconds later, and why fixing only the ordering left the symptom in place. +const render = functionSource('renderLedger'); +const skipOff = render ? render.indexOf("classList.remove('is-skipping-offscreen')") : -1; +// Anchored with the semicolon and its own line, not on the bare call: renderLedger's own doc +// comment contains the sentence "clear(ledger) below collapses a 716,000px document", and a +// bare-name anchor finds that comment first and puts the statement 2,500 characters late. An +// anchor that can match a comment is not an anchor. +const clearAt = render ? render.indexOf('\n clear(ledger);') : -1; +assert('the ledger turns skipping off before it starts building, so a rebuild does not measure under it', + skipOff > 0 && clearAt > skipOff + // And it has to be a remove on the same element, not a stray remove of something else. + && new RegExp("ledger\\.classList\\.remove\\('is-skipping-offscreen'\\)").test(render)); + +/* ---------- 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 diff --git a/plugins/avatasia/mmc-trajectory/tests/verify-ledger-batch.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-ledger-batch.mjs new file mode 100644 index 0000000..8d7946b --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/tests/verify-ledger-batch.mjs @@ -0,0 +1,248 @@ +/** + * A window too large for one frame has to stop being a frozen page. + * + * The defect: pressing 「显示全部」 on an 18,924-row session put every row into the ledger inside a + * single synchronous loop. Measured on this machine, that is about 3,956 ms of main thread during + * which the page answers nothing, with no indication that anything was happening. The reader's + * only evidence was that clicking had not worked yet. + * + * The fix does not change what 「显示全部」 promises — every record is still shown. It changes how + * many rows are built before the browser gets the thread back, and it says so while it does it. + * See docs/LAYERS.md D12, which also records the `content-visibility: auto` alternative that was + * measured (3,956 ms → 979 ms) and rejected: its `contain-intrinsic-size` is one number for a + * distribution spanning 2,667 px to 29,754 px, so the scrollbar it produces does not describe the + * page. + * + * Four things have to hold together, and each one is a way this can go wrong on its own: + * + * 1. The frame is actually handed back. A loop that recurses synchronously instead of through + * requestAnimationFrame is the original defect wearing a batch. + * 2. A new exit retires the build in flight. Ten writers change "what the table shows"; two + * loops appending to one ledger is how the page ends up with two tables interleaved. + * 3. Progress is reported through the one writer of that count. setLedgerProgress() reaching for + * the node itself is the two-writers defect D8 exists to prevent, one layer in. + * 4. A poll never replaces a table that is still being built, and the tick it holds back is + * actually run afterwards. The build takes longer than the poll interval, so this is not a + * theoretical concern; and a deferral that is never flushed is a silently dropped refresh. + * + * The batch size is checked for the property that makes it terminate rather than for the value that + * was measured: a batch of zero groups never advances `built`, so the loop would re-schedule itself + * every frame forever. 25 is what the measurement gave; 1 is what correctness requires. + * + * mutate-ledger-batch.py breaks each of these in the shipped file and requires this guard to fail + * 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); +} + +/** The body of a top-level `function name(...)`, braces matched. */ +function functionBody(name) { + const at = html.indexOf('function ' + name + '('); + if (at < 0) return null; + const open = html.indexOf('{', at); + let depth = 0; + for (let j = open; j < html.length; j += 1) { + if (html[j] === '{') depth += 1; + else if (html[j] === '}') { + depth -= 1; + if (depth === 0) return html.slice(open + 1, j); + } + } + return null; +} + +/** The body of the nested `function buildStep()` inside renderLedger. */ +function buildStepBody() { + const ledger = functionBody('renderLedger'); + if (!ledger) return null; + const at = ledger.indexOf('function buildStep()'); + if (at < 0) return null; + const open = ledger.indexOf('{', at); + let depth = 0; + for (let j = open; j < ledger.length; j += 1) { + if (ledger[j] === '{') depth += 1; + else if (ledger[j] === '}') { + depth -= 1; + if (depth === 0) return ledger.slice(open + 1, j); + } + } + return null; +} + +const ledger = functionBody('renderLedger'); +const step = buildStepBody(); + +assert('the ledger exit still exists and the batch step is declared inside it', + !!ledger && !!step); + +/* ---------- 1. the frame is handed back ---------- */ + +assert('the build hands the frame back between batches', + !!step && /window\.requestAnimationFrame\(buildStep\);/.test(step)); + +// Not merely present — reached only when there is more to build. A rAF outside the "is there more" +// test costs every window, including the default 200-row one, a frame it never needed to spend. +const moreBlock = step && step.indexOf('if (build.at < groups.length)'); +const rafAt = step && step.indexOf('window.requestAnimationFrame(buildStep);'); +assert('a window that fits in one batch does not spend a frame', + moreBlock >= 0 && rafAt > moreBlock + && step.slice(moreBlock, step.indexOf('}', moreBlock) + 1).includes('requestAnimationFrame')); + +/* ---------- the promise is unchanged ---------- */ + +// LEDGER_BATCH_GROUPS gates how much runs per frame. It must not gate how much exists: the loop's +// progress bound is the group count, and the batch size is a second, narrower term beside it. +assert('显示全部 still means every record: the batch size is not a cap', + !!step && /while \(build\.at < groups\.length && built < LEDGER_BATCH_GROUPS\)/.test(step)); + +assert('the batch always advances its cursor', + !!step && /build\.at \+= 1;/.test(step)); + +// A batch of zero groups never increments `built`, so the loop would re-schedule itself on every +// frame forever and the page would spin instead of building. Correctness needs 1; the measurement +// chose 25. +const batchDecl = /var LEDGER_BATCH_GROUPS = (\d+);/.exec(html); +assert('the batch is at least one group', + !!batchDecl && Number(batchDecl[1]) >= 1); + +/* ---------- 2. a new exit retires the build in flight ---------- */ + +assert('every pass through the exit retires the build in flight', + !!ledger && (ledger.match(/ledgerBuildToken \+= 1;/g) ?? []).length === 1); + +// The bump has to precede the line that *records* the new build's token. If it came after, the +// build would be handed the token the previous build was still using, and two concurrent builds +// would compare equal — the exact interleaving the token exists to prevent. +const bumpAt = ledger && ledger.indexOf('ledgerBuildToken += 1;'); +const claimAt = ledger && ledger.indexOf('var build = { token: ledgerBuildToken'); +assert('the exit retires the old build before the new one claims a token', + bumpAt >= 0 && claimAt >= 0 && bumpAt < claimAt); + +// And it has to sit above `clear(ledger)`, not merely somewhere earlier in the function. Four +// early returns sit between them — loading, hard error, no payload, no sessions — and each of +// them clears the ledger too. A bump below them means a pass that returned early retired +// nothing, so the previous build stayed alive and went on appending turn groups into whatever +// that pass had put there. Counting one bump in the source cannot see that; counting the +// returns between the two lines can. See docs/LAYERS.md D14. +const clearAt = ledger && ledger.indexOf('clear(ledger);'); +const between = bumpAt >= 0 && clearAt > bumpAt ? ledger.slice(bumpAt, clearAt) : ''; +assert('no early return sits between the token bump and the clear it precedes', + !!between && !/\breturn;/.test(between)); + +assert('a build whose exit has been replaced stops without appending', + !!step && /if \(build\.token !== ledgerBuildToken\) return;/.test(step)); + +// The check has to come first. A version that appended one more batch before testing would put a +// section into a ledger that renderLedger() had already emptied — rows appearing in a table that +// does not exist yet. +assert('the token is tested before the first row is built', + !!step && step.indexOf('if (build.token !== ledgerBuildToken) return;') + < step.indexOf('while (build.at < groups.length')); + +/* ---------- 3. one writer of the count ---------- */ + +const setProgress = functionBody('setLedgerProgress'); +const updateBar = functionBody('updateWindowBar'); + +assert('progress is reported through the one writer of the count', + !!setProgress && /updateWindowBar\(/.test(setProgress) + && !/textContent/.test(setProgress) && !/window-count/.test(setProgress)); + +assert('the window bar count has exactly one writer', + !!updateBar && (updateBar.match(/parts\.count\.textContent =/g) ?? []).length === 2 + && (html.match(/parts\.count\.textContent =/g) ?? []).length === 2); + +// ---------- 3b. the count line closes on itself ---------- +// +// 「共 N 条」 is the whole session, unfiltered. 已显示 is the window and 更早的 is what sits above +// it, and those two add up to what the *filter* matched. Printed side by side with nothing saying +// so, one line offers the reader two different pairs to add and no way to tell which one the +// arithmetic was meant to use. 命中 is what makes the line close on itself. + +assert('the matched count is derived from the window and what is above it, not passed in as a fourth number', + !!updateBar && /var\s+matchedCount\s*=\s*shown\.length\s*\+\s*hiddenAbove\s*;/.test(updateBar) + // A caller-supplied figure would be a fourth copy of one fact, and four copies of a count is how + // two of them start disagreeing. The signature is what makes that checkable rather than a style note. + && /function\s+updateWindowBar\(\s*shown\s*,\s*hiddenAbove\s*,\s*total\s*\)/.test(html)); + +assert('the count line prints the filter result next to the session total', + !!updateBar && /\+ matchedLabel \+ ' · 已显示 /.test(updateBar)); + +assert('it prints it only when it is a different number, so an unfiltered session is not told twice', + !!updateBar && /matchedCount === total \? ''/.test(updateBar)); + +// And it must stay off the mid-build line. `ledgerProgress.total` is already the window being +// built, so 「正在显示 R / W」 is not addable to 共 N — there is no ambiguity there to fix. Worse, +// `shown` / `hiddenAbove` are the *previous* render's there (held in ledgerWindow), so a 命中 +// printed on that line would be a stale filter result sitting beside a live progress count. +const progressLine = !!updateBar + ? updateBar.slice(updateBar.indexOf('if (ledgerProgress)'), updateBar.indexOf('return;')) + : ''; +assert('the mid-build line does not print a filter result derived from the previous render', + !!progressLine && !progressLine.includes('matchedLabel')); + +// Two because updateWindowBar reports two states, and both have to be honest. Mid-build the bar +// must not claim 已显示 for rows that do not exist yet — the count promising a table that is not +// there is the same defect D8 was written for. +assert('the mid-build count reports rows built, not rows promised', + !!updateBar && /正在显示 ' \+ formatInt\(ledgerProgress\.rows\)/.test(updateBar) + && /\/ ' \+ formatInt\(ledgerProgress\.total\)/.test(updateBar)); + +assert('progress cannot outlive the build', + !!step && /setLedgerProgress\(null\);/.test(step)); + +assert('a settled window is not reported as mid-build', + !!updateBar && /if \(ledgerProgress\) \{[\s\S]*?return;\n \}/.test(updateBar)); + +/* ---------- 4. the poll defers, and the deferral is not swallowed ---------- */ + +const pollSrc = functionBody('startPolling'); +assert('a poll never replaces a table that is still being built', + !!pollSrc && /if \(ledgerBuilding\(\)\) \{/.test(pollSrc) + && /pollDeferred = true;/.test(pollSrc) + && /return;/.test(pollSrc)); + +// The deferral has to be cleared before the fetch, or every flush would re-arm itself and the +// page would poll once per frame instead of once per 2.5 s. +const flush = functionBody('flushDeferredPoll'); +assert('the held-back poll is run once, and only once', + !!flush && flush.indexOf('pollDeferred = false;') >= 0 + && flush.indexOf('pollDeferred = false;') < flush.indexOf('fetchTrajectory(false);')); + +assert('the held-back poll is not swallowed at the end of a build', + !!step && /flushDeferredPoll\(\);/.test(step)); + +// One source of truth. A separate "is a build running" flag is a second thing that has to agree +// with the progress flag, and the day it stops agreeing the poll starts replacing tables that are +// still being built while the bar still says they are. +assert('the in-flight flag is derived from the progress, not a second variable', + /function ledgerBuilding\(\) \{\s*return ledgerProgress !== null;\s*\}/.test(html) + && (html.match(/var\s+ledgerBuilding\b/g) ?? []).length === 0); + +// The finishing path, in order. restoreRowAnchor moves the page, so measuring before it would pin +// the strips against a scroll position the reader is no longer at; verify-sticky.mjs pins that +// same ordering and its own mutations break it. +if (step) { + const atRestore = step.indexOf('restoreRowAnchor(anchor, scrollY);'); + const atFlush = step.indexOf('flushDeferredPoll();'); + const atMeasure = step.lastIndexOf('syncStickyOffsets();'); + assert('the finishing path anchors, then flushes, then measures', + atRestore >= 0 && atFlush > atRestore && atMeasure > atFlush); +} + +console.log('[ledger-batch] ' + pass + ' passed, ' + failures.length + ' failed'); +if (failures.length) { + console.log('FAILURES:\n - ' + failures.join('\n - ')); + process.exit(1); +} +console.log('[ledger-batch] OK'); 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..71abd47 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/tests/verify-ledger-jump.mjs @@ -0,0 +1,256 @@ +/** + * 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)); +// 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; +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 && /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', + (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-list-cache.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-list-cache.mjs new file mode 100644 index 0000000..68fa19d --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/tests/verify-list-cache.mjs @@ -0,0 +1,143 @@ +/** + * 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); +} + +/* ---------- 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 - ')); + process.exit(1); +} +console.log('[list-cache] OK'); diff --git a/plugins/avatasia/mmc-trajectory/tests/verify-overview.mjs b/plugins/avatasia/mmc-trajectory/tests/verify-overview.mjs new file mode 100644 index 0000000..09f05ed --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/tests/verify-overview.mjs @@ -0,0 +1,592 @@ +// Behavioural self-check for the overview strip and the view controls. +// +// These assertions are the evidence that the type chips filter the list, the overview and +// the turn headers together, and that the 时长 button reports the strip it draws. Every +// expected value below is derived by hand from the fixture, not from running the code, so +// the probe can fail rather than merely agree with itself. +// +// It loads the delivered index.html itself and runs its real inline script in a +// sandbox, so there is no copied logic that could drift from what ships. +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import vm from 'node:vm'; + +const HTML = fileURLToPath(new URL('../miniapp/client/index.html', import.meta.url)); +const html = readFileSync(HTML, 'utf8'); + +const script = [...html.matchAll(/