Files
Jyotisha/frontend/tests/rectification-range-bar-20260916.test.ts
T
Jesse_ChenandClaude Opus 5 ca0204984b
Independent Staging Quality Gate / validate (push) Canceled after 3m14s
Independent Staging Quality Gate / publish (push) Canceled after 0s
feat(rectification): 区间常驻条补代表分钟与已答题数,盘面五段折叠收成两段
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
2026-09-16 06:44:49 +00:00

255 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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:0705: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:0705: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:0705: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}`);
});