/** * 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, datedEventLabel, 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 = {}) { 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); assert.equal(view?.datedEventLabel, 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); assert.equal(datedEventLabel(3), "已对照 3 件"); assert.equal(datedEventLabel(null), null); assert.equal(datedEventLabel(0), 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, / { assert.match(board, /working \? 当前时间<\/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 , never a div with a click handler. assert.doesNotMatch(board, /]*\sopen[\s>]/); assert.doesNotMatch(board, /role="button"|onClick=\{[^}]*toggle/); const summaries = board.match(//g)?.length ?? 0; assert.ok(summaries >= 6, `expected every block to keep its summary, saw ${summaries}`); });