Files
Jyotisha/frontend/tests/rectification-range-bar-20260916.test.ts
T
Jesse_ChenandClaude Opus 5.5 3a66c39f48 refactor(rectification): split the chat component into hooks, parameter functions and blocks
Move-only (TASK-rectification-code-split-20260926 T1/T4). No behavior, API,
copy, DB, Skill or scoring change.

- rectification-agentic-chat.tsx: 2043 -> 737 lines; component body
  1625 -> 630; useState 35 -> 12, useRef 16 -> 9, effects 9 -> 5,
  useCallback 12 -> 6.
- Snapshot sync -> hooks/use-rectification-case-snapshot.ts; board layout
  and live label clock -> two small hooks.
- send / submitStructuredChoice / acceptCandidate bodies ->
  lib/rectification-chat-{turn,choice,accept}-run.ts parameter functions
  (bodies verbatim, deps destructured to the same names); copy/regenerate,
  question repair, pure transcript and snapshot helpers and the per-render
  view derivation -> lib/rectification-chat-*.ts (no React hooks).
- Question-gap blocks and the read-only range line ->
  components/rectification-question-gap-notices.tsx.
- Dependency arrays kept exactly as before (lint warnings +3, listed in
  PROGRESS); no dep added to appease the linter.
- Tests: whole-source contracts read tests/rectification-chat-surface.ts
  (container + split files, like home-surface.ts); slices of moved code now
  call the extracted functions or render the extracted block, each with a
  原值/新值/原因 comment. New tests/rectification-growth-contract.test.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 19:23:52 +08:00

262 lines
12 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,
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";
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
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");
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
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);
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, /<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}`);
});