feat(rectification): 区间常驻条补代表分钟与已答题数,盘面五段折叠收成两段
Independent Staging Quality Gate / validate (push) Canceled after 3m14s
Independent Staging Quality Gate / publish (push) Canceled after 0s

R7 / 决策 D10。任务书 docs/tasks/TASK-cend-surfaces-claude-alignment-20260916.md。

T7.1 常驻条。任务书 E9 说区间只在盘面标题行,实际读代码:对话区顶部
已有常驻条 RectificationTimeline 且已显示区间与宽度,窄屏 .is-compact 下
也可见。真正只存在于 .rectification-board__clock 的是代表分钟——窄屏盘面
是默认关闭的 overlay,代表分钟就此看不见。所以没有另造第二条(会撞固定条
高这条正确性约束),而是在既有条上补齐缺的两项:

  05:07–05:09 · 3 分钟 · 代表分钟 05:08 · 已答 6 题

代表分钟与盘面标题行是同一次 workingRectificationTime(result) 调用,一个
值两处用,不是两条推导路径。已答题数逐字读服务端的
inference_state.answered_probe_count,投影没给就整项不显示,不补「已答 0 题」。
条上不写确认,边界句仍只在交付/采用旁白里出现一次。读数只增宽不增高:条高
在滚动容器外,变高会让贴底读者的内容无声滑出视野。

收窄进度未做,触发让步顺序第 5 条。没有这个字段(服务端只把这句话拼进旁白
正文),且前端自己减会算错——candidate_range 是当前窗口且会放宽(BUG-572),
拿它当「最初」相减会把一次放宽报成一次收窄。缺口与解除条件写进 BLOCKED.md。

T7.2 盘面折叠。实际有 8 个 details(任务书记的五处漏了交界节奏、错标了两块),
按内容归成「候选分钟」(换升时刻 + 次级候选)与「参数与口径」(宫位表、确认门、
本轮技法、两段本命上升、经历与大运对照含双轨一致性、交界节奏)。每块保留原有
渲染条件,engineMeaningToDisplayCopy() 仍是 5 处,逐分钟行的 is-changed /
当前时间 一字未改。共享组件 technique-audit-disclosure.tsx 未动。

顺带修掉一个静默失效的断言:rectification-agentic-entry 用
board.slice(indexOf(A), indexOf(B)),B 改名后 indexOf 返回 -1,slice(i, -1)
会悄悄退化成几乎整份文件,底下四条 doesNotMatch 照样绿。已换成带越界断言的
sliceBetween()。两处改动的断言都在测试里写了原值/新值/原因。

tsc 0 错;lint 0 error / 118 warning(同基线);npm test 3350 → 3360,失败
仍是 31 条无 Docker 用例且与基线逐条一致;next build exit 0,/ 仍 ○ Static;
产物 CSS gzip 39,017 → 38,973 B(−0.11%)。

窄屏 .is-compact 下常驻条的实际可见性无 Chrome 无法验证,已按任务书写成环境
缺口,未标通过;CSS 上已确认无任何规则在该状态下隐藏该条,并写成测试断言。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
Jesse_Chen
2026-09-16 06:44:49 +00:00
co-authored by Claude Opus 5
parent a1a78c7ebc
commit ca0204984b
13 changed files with 751 additions and 72 deletions
@@ -510,18 +510,33 @@ test("the natal house table is a live board beside the chat, not a message", ()
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{savedTime &&"),
);
const snapshot = board.slice(
board.indexOf("className=\"rectification-snapshot\""),
board.indexOf("{scoringMinutes.length > 0 || displayMinutes.length > 0"),
);
// A slice whose end marker has gone missing silently becomes `slice(i, -1)`,
// i.e. almost the whole file, and every doesNotMatch below stops meaning what
// it says. Fail loudly on a renamed marker instead.
const sliceBetween = (source: string, from: string, to: string): string => {
const start = source.indexOf(from);
const end = source.indexOf(to);
assert.ok(start >= 0, `marker not found: ${from}`);
assert.ok(end > start, `marker not found after ${from}: ${to}`);
return source.slice(start, end);
};
// 原值: snapshot 切到 `{scoringMinutes.length > 0 || displayMinutes.length > 0`,
// 并断言 `<RectificationHouseTableView` 就在 snapshot 里。
// 新值: snapshot 切到 `{hasMinuteDetail && (`,宫位表改在「参数与口径」段里断言。
// 原因: D10 把盘面五个折叠块收敛成「候选分钟」与「参数与口径」两段,本命宫位表
// 随之从 `.rectification-snapshot` 移进「参数与口径」。旧终点标记已不存在,
// 留着会让 slice 退化成整份文件。宫位表仍在盘面、不在消息流,断言不变。
const snapshot = sliceBetween(board, "className=\"rectification-snapshot\"", "{hasMinuteDetail && (");
const parameterSection = board.slice(board.indexOf("{hasParameterDetail && ("));
// 原值: `<ChatMessageRow` 在容器 map 循环里。
// 新值: 循环渲染 `RectificationMessageEntry`,ChatMessageRow 在行组件内。
// 原因: BUG-725。
const rowIndex = messageLoop.indexOf("<RectificationMessageEntry");
const snapshotIndex = snapshot.indexOf("className=\"rectification-snapshot\"");
const houseIndex = snapshot.indexOf("<RectificationHouseTableView");
assert.ok(rowIndex >= 0);
assert.ok(snapshotIndex >= 0 && houseIndex > snapshotIndex);
// The chart is what stays unfolded; the table is one click into 参数与口径.
assert.match(snapshot, /<VedicChartSvg/);
assert.doesNotMatch(snapshot, /<RectificationHouseTableView/);
assert.ok(parameterSection.indexOf("<RectificationHouseTableView") > 0);
assert.doesNotMatch(snapshot, /<RectificationCandidateCards/);
assert.doesNotMatch(snapshot, /当前可能的出生时间/);
assert.doesNotMatch(messageLoop, /className="rectification-snapshot"/);
@@ -161,9 +161,16 @@ test("board shows a north-indian chart and folds 换升时刻 by default", () =>
}));
assert.match(html, /role="img"/);
assert.match(html, /当前本命盘,上升金牛座/);
assert.match(html, /换升时刻 · 2 个/);
// 原值: assert.match(html, /换升时刻 · 2 个/)
// 新值: 「候选分钟」段的 summary 带 `2 个时刻`,`换升时刻` 变成段内组标题。
// 原因: D10 五段收敛成两段,换升时刻与次级候选并入「候选分钟」。计数仍是
// grouped.length,仍然默认折叠——只是标签换了归属,内容一条不丢。
assert.match(html, /候选分钟/);
assert.match(html, /2 个时刻/);
assert.match(html, /换升时刻/);
assert.match(html, /<details/);
assert.doesNotMatch(html, /<details[^>]*\sopen/);
assert.match(html, /本命宫位表/);
assert.match(html, /参数与口径/);
assert.doesNotMatch(html, /<section[^>]*aria-label="换升时刻"/);
});
@@ -0,0 +1,254 @@
/**
* D10 (2026-09-16): the credible range is promoted to a bar that is on screen at
* every width, and the board's five folds collapse to two.
*
* What these tests are really guarding is the honesty rule, not the layout.
* Every figure on that bar has to be one the server already computed — see
* frontend/docs/VOICE.md rule 2. The narrowing-progress readout the brief asked
* for is deliberately absent because no projection carries it; the assertions
* below keep it absent rather than letting someone add the arithmetic later.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
answeredProbeLabel,
buildRectificationTimeline,
workingMinuteLabel,
} from "../src/lib/rectification-timeline-scale.ts";
import { parseRectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
import { workingRectificationTime } from "../src/lib/rectification-board-model.ts";
import { RectificationTimeline } from "../src/components/rectification-timeline.tsx";
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
const scale = read("../src/lib/rectification-timeline-scale.ts");
const bar = read("../src/components/rectification-timeline.tsx");
const board = read("../src/components/rectification-board.tsx");
const chat = read("../src/components/rectification-agentic-chat.tsx");
const styles = read("../src/app/globals.css");
const RESULT_ID = "77777777-7777-4777-8777-777777777770";
const CANDIDATE_ID = "77777777-7777-4777-8777-777777777771";
function candidateSnapshot(extra: Record<string, unknown> = {}) {
return parseRectificationCandidateResult({
resultId: RESULT_ID,
candidates: [
{ candidateId: CANDIDATE_ID, rank: 1, time: "05:08", relativeSupport: 16, tiedMinuteCount: 1 },
],
overallConfidence: "low",
selectionAllowed: true,
confirmationAllowed: false,
representativeTime: "05:08",
selectedTime: null,
credibleRange: ["05:07", "05:09"],
...extra,
});
}
test("the bar's working minute is the board clock's own value, not a second derivation", () => {
// The board title row renders `workingRectificationTime(result)`. The chat
// passes the result of that same call into the bar, so the two surfaces
// cannot drift apart — there is one expression, not two that agree today.
assert.match(board, /const workingTime = workingRectificationTime\(result\);/);
assert.match(board, /className="rectification-board__clock">\{workingTime\}/);
assert.match(chat, /workingTime: workingRectificationTime\(candidateResult\)/);
const result = candidateSnapshot();
assert.ok(result);
const view = buildRectificationTimeline({
searchWindow: ["04:45", "05:15"],
credibleRange: result.credibleRange,
candidateTimes: ["05:08"],
stage: "minute",
workingTime: workingRectificationTime(result),
workingAdopted: Boolean(result.selectedTime),
});
assert.equal(view?.workingLabel, "代表分钟 05:08");
assert.equal(view?.rangeLabel, "05:07–05:09");
});
test("an adopted minute is labelled adopted, and adoption is still not confirmation", () => {
const adopted = candidateSnapshot({ selectedTime: "05:08" });
assert.ok(adopted);
assert.equal(workingRectificationTime(adopted), "05:08");
assert.equal(workingMinuteLabel("05:08", true), "已采用 05:08");
assert.equal(workingMinuteLabel("05:08", false), "代表分钟 05:08");
// The bar states which minute is in use. It must never assert the birth
// minute is settled — that boundary lives in the delivery/adopt narration,
// and AGENTS.md Part B4 bans a confirmed candidate outright.
assert.doesNotMatch(bar, /已确认|确认的唯一|精确到分钟|confirmed/);
assert.doesNotMatch(scale, /已确认|确认的唯一/);
});
test("an unparseable or absent minute yields no label rather than a guess", () => {
assert.equal(workingMinuteLabel(null, false), null);
assert.equal(workingMinuteLabel(undefined, false), null);
assert.equal(workingMinuteLabel("", false), null);
assert.equal(workingMinuteLabel("25:99", false), null);
const view = buildRectificationTimeline({
searchWindow: ["04:45", "05:15"],
credibleRange: ["05:07", "05:09"],
candidateTimes: [],
stage: "minute",
});
assert.equal(view?.workingLabel, null);
assert.equal(view?.answeredLabel, null);
});
test("the answered count is read off the projection, never counted here", () => {
// `inference_state.answered_probe_count` is the server's own figure.
const withCount = candidateSnapshot({
decisionReceipt: { inference_state: { answered_probe_count: 6 } },
});
assert.equal(withCount?.answeredProbeCount, 6);
assert.equal(answeredProbeLabel(6), "已答 6 题");
// No count on the wire means no readout. A zero invented here would be a
// number the projection never said.
const withoutCount = candidateSnapshot();
assert.equal(withoutCount?.answeredProbeCount, null);
assert.equal(answeredProbeLabel(null), null);
assert.equal(answeredProbeLabel(0), null);
assert.equal(answeredProbeLabel(1.5), null);
// The label echoes its argument. Nothing in its body counts, adds or walks a
// transcript to arrive at the number.
const body = scale.slice(
scale.indexOf("export function answeredProbeLabel"),
scale.indexOf("export function answeredProbeLabel") + 400,
);
assert.match(body, /return `已答 \$\{count\} 题`;/);
assert.doesNotMatch(body, /\.length|\+\+|answered_probes|\.filter\(|\.reduce\(/);
});
test("the bar shows no narrowing progress, because no projection carries it", () => {
// Concession 5 of TASK-cend-surfaces-claude-alignment-20260916: without an
// "已从 N 分钟收到 M 分钟" field, the bar shows the range and its width and
// stops. The opening window is on the wire, but comparing it here would both
// break VOICE.md rule 2 and misreport: the search window *widens* (BUG-572),
// so the subtraction would announce progress through a widening.
assert.doesNotMatch(bar, /已从|收到|最初|进度/);
assert.doesNotMatch(scale, /已从|收到|最初|进度|openingRange|openingWidth/);
const view = buildRectificationTimeline({
searchWindow: ["04:45", "05:15"],
credibleRange: ["05:07", "05:09"],
candidateTimes: ["05:08"],
stage: "minute",
workingTime: "05:08",
answeredProbeCount: 6,
});
assert.equal(view?.widthLabel, "3 分钟");
assert.deepEqual(
Object.keys(view ?? {}).filter((key) => /progress|opening|initial/i.test(key)),
[],
);
});
test("the bar renders all four readings on one line and stays a fixed height", () => {
const html = renderToStaticMarkup(createElement(RectificationTimeline, {
view: buildRectificationTimeline({
searchWindow: ["04:45", "05:15"],
credibleRange: ["05:07", "05:09"],
candidateTimes: ["05:08"],
stage: "minute",
workingTime: "05:08",
answeredProbeCount: 6,
}),
}));
assert.match(html, /05:07–05:09/);
assert.match(html, /3 分钟/);
assert.match(html, /代表分钟 05:08/);
assert.match(html, /已答 6 题/);
// All four are in the single readout paragraph, and the assistive label for
// the axis says the same things in the same order.
assert.match(html, /rectification-timeline__readout[\s\S]{0,400}已答 6 题<\/span><\/p>/);
assert.match(html, /aria-label="[^"]*当前区间 05:07–05:09[^"]*代表分钟 05:08[^"]*已答 6 题"/);
// Height is correctness here: the bar sits outside the scroll container, so a
// taller bar would move content with nothing observing it. The row grew wider
// and must not be allowed to wrap.
const readout = styles.slice(
styles.indexOf(".rectification-timeline__readout {"),
styles.indexOf(".rectification-timeline__range {"),
);
assert.match(readout, /flex-wrap: nowrap/);
assert.match(readout, /white-space: nowrap/);
assert.match(styles, /--rectification-timeline-height: 64px;/);
assert.match(styles, /--rectification-timeline-height: 56px;/);
});
test("nothing hides the bar on a compact viewport — that is the whole point", () => {
// On a narrow screen the board becomes an overlay that is closed by default,
// so this bar is the only place the range and the working minute appear. The
// chat column keeps its three grid rows in the compact cell.
assert.match(
styles,
/\.rectification-workspace\.is-compact \.rectification-workspace__chat,\s*\n\.rectification-workspace\.is-compact \.rectification-board-overlay \{\s*\n\s*grid-area: 1 \/ 1;/,
);
// No rule anywhere hides or collapses the bar under `.is-compact`.
for (const match of styles.matchAll(/\.is-compact[^{}]*\.rectification-timeline[^{}]*\{([^}]*)\}/g)) {
assert.doesNotMatch(match[1], /display:\s*none|visibility:\s*hidden|height:\s*0/);
}
// Nor does the timeline hide itself at any width.
const barRule = styles.slice(
styles.indexOf(".rectification-timeline {"),
styles.indexOf(".rectification-timeline__readout"),
);
assert.doesNotMatch(barRule, /display:\s*none|visibility:\s*hidden/);
});
test("the board folds to two sections and loses none of the five blocks", () => {
// Exactly two top-level disclosures.
assert.equal(board.match(/aria-label="候选分钟"/g)?.length, 1);
assert.equal(board.match(/aria-label="参数与口径"/g)?.length, 1);
const minutes = board.slice(
board.indexOf("{hasMinuteDetail && ("),
board.indexOf("{hasParameterDetail && ("),
);
const parameters = board.slice(board.indexOf("{hasParameterDetail && ("));
// 候选分钟 carries the transition minutes and the secondary candidates.
assert.match(minutes, /换升时刻/);
assert.match(minutes, /次级候选/);
assert.match(minutes, /scoringMinutes\.map/);
assert.match(minutes, /displayMinutes\.map/);
// 参数与口径 carries the rest: house table, the confirmation gate, the
// technique audit, the two-lagna split, the ledger with its dual-track
// agreement line, and the nakshatra note.
assert.match(parameters, /<RectificationHouseTableView/);
assert.match(parameters, /<ConfirmationGateDisclosure/);
assert.match(parameters, /<TechniqueAuditDisclosure/);
assert.match(parameters, /两段本命上升/);
assert.match(parameters, /经历与大运对照/);
assert.match(parameters, /交界节奏/);
assert.match(parameters, /agreement \? <p/);
// Every engine string still goes through the consumer-side display layer.
assert.equal(board.match(/engineMeaningToDisplayCopy\(/g)?.length, 5);
});
test("regrouping changed no minute-row behaviour", () => {
assert.match(board, /working \? <span className="rectification-board__minute-note">当前时间<\/span>/);
assert.match(board, /changed \? "is-changed" : ""/);
assert.match(board, /working \? "is-working" : ""/);
assert.match(
board,
/changedMinutes\.has\(row\.at\) \? "rectification-board__minute is-changed" : "rectification-board__minute"/,
);
});
test("both sections stay closed by default and every summary is still reachable", () => {
// Keyboard access must be no worse than before: nesting adds a level, so each
// disclosure has to remain a real <summary>, never a div with a click handler.
assert.doesNotMatch(board, /<details[^>]*\sopen[\s>]/);
assert.doesNotMatch(board, /role="button"|onClick=\{[^}]*toggle/);
const summaries = board.match(/<summary>/g)?.length ?? 0;
assert.ok(summaries >= 6, `expected every block to keep its summary, saw ${summaries}`);
});