Files
Jyotisha/frontend/tests/rectification-timeline-20260909.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

474 lines
21 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.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { parseRectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
import {
buildRectificationTimeline,
formatClockMinutes,
parseClockMinutes,
timelineDurationLabel,
timelineTickStepMinutes,
} from "../src/lib/rectification-timeline-scale.ts";
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
import { deriveRectificationChatView } from "../src/lib/rectification-chat-view.ts";
import { workingRectificationTime } from "../src/lib/rectification-board-model.ts";
import { chatViewInput, offerableCandidateResult } from "./rectification-chat-run-fixtures.ts";
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
const component = read("../src/components/rectification-timeline.tsx");
const scale = read("../src/lib/rectification-timeline-scale.ts");
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
const anchor = read("../src/hooks/use-conversation-scroll-anchor.ts");
const styles = read("../src/app/globals.css");
/** ±15 around a declared 05:00, the most common opening window. */
const declaredWindow = ["04:45", "05:15"] as const;
test("clock parsing rejects anything that is not a real reading", () => {
assert.equal(parseClockMinutes("04:45"), 285);
assert.equal(parseClockMinutes("4:45"), 285);
assert.equal(parseClockMinutes("05:07:00"), 307);
assert.equal(parseClockMinutes("24:00"), null);
assert.equal(parseClockMinutes("05:60"), null);
assert.equal(parseClockMinutes(""), null);
assert.equal(parseClockMinutes(undefined), null);
assert.equal(formatClockMinutes(307), "05:07");
assert.equal(formatClockMinutes(1440), "00:00");
});
test("tick step coarsens as the window grows, and labels stay on round clock values", () => {
assert.equal(timelineTickStepMinutes(30), 5); // ±15
assert.equal(timelineTickStepMinutes(60), 10); // ±30
assert.equal(timelineTickStepMinutes(120), 20); // ±60
assert.equal(timelineTickStepMinutes(240), 60); // ±120
assert.equal(timelineTickStepMinutes(1439), 240); // whole day
const half = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["04:47", "05:14"],
candidateTimes: [],
stage: "minute",
});
assert.ok(half);
assert.deepEqual(half.ticks.map((tick) => tick.label), [
"04:45", "04:50", "04:55", "05:00", "05:05", "05:10", "05:15",
]);
const fourHours = buildRectificationTimeline({
searchWindow: ["03:00", "07:00"],
credibleRange: null,
candidateTimes: [],
stage: "block_scan",
});
assert.ok(fourHours);
assert.deepEqual(fourHours.ticks.map((tick) => tick.label), [
"03:00", "04:00", "05:00", "06:00", "07:00",
]);
const wholeDay = buildRectificationTimeline({
searchWindow: ["00:00", "23:59"],
credibleRange: null,
candidateTimes: [],
stage: "block_scan",
});
assert.ok(wholeDay);
assert.deepEqual(wholeDay.ticks.map((tick) => tick.label), [
"00:00", "04:00", "08:00", "12:00", "16:00", "20:00",
]);
});
test("the band is placed against the axis and never escapes it", () => {
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:07", "05:09"],
candidateTimes: [],
stage: "minute",
});
assert.ok(view);
// 05:07 is 22 minutes into a 30-minute axis.
assert.equal(Math.round(view.bandStartPercent), 73);
assert.equal(Math.round(view.bandWidthPercent), 7);
assert.ok(view.bandStartPercent + view.bandWidthPercent <= 100);
assert.equal(view.rangeLabel, "05:07–05:09");
// Inclusive of both ends: 05:07, 05:08, 05:09. Exclusive difference was "2 分钟".
assert.equal(view.widthLabel, "3 分钟");
// A range reported wider than the window is clamped rather than overflowing.
const overflowing = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["04:00", "06:00"],
candidateTimes: [],
stage: "minute",
});
assert.ok(overflowing);
assert.equal(overflowing.bandStartPercent, 0);
assert.equal(overflowing.bandWidthPercent, 100);
});
test("candidate marks follow inference status, not band position", () => {
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:07", "05:09"],
candidateTimes: ["05:07", "05:08", "05:09"],
inferenceMarks: [
{ time: "05:06", eliminated: true },
{ time: "05:07", eliminated: false },
{ time: "05:08", eliminated: true },
{ time: "05:09", eliminated: false },
{ time: "05:10", eliminated: true },
],
stage: "minute",
});
assert.ok(view);
// 05:08 sits inside the band but is eliminated → hollow. Position vs band is not the judge.
assert.deepEqual(view.marks.map((mark) => [mark.key, mark.state]), [
["m306", "out"],
["m307", "in"],
["m308", "out"],
["m309", "in"],
["m310", "out"],
]);
assert.equal(view.marks.every((mark) => mark.state === "in" || mark.state === "out"), true);
});
test("without inference marks every engine candidate is solid", () => {
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:07", "05:09"],
candidateTimes: ["05:06", "05:07", "05:08", "05:09", "05:10"],
stage: "minute",
});
assert.ok(view);
assert.deepEqual(view.marks.map((mark) => mark.state), ["in", "in", "in", "in", "in"]);
});
test("marks outside the window are dropped and duplicates collapse", () => {
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:00", "05:10"],
candidateTimes: ["03:00", "05:02", "05:02", "06:30", "05:08"],
stage: "minute",
});
assert.ok(view);
assert.deepEqual(view.marks.map((mark) => mark.key), ["m302", "m308"]);
});
test("block scan draws the window as the range and no minute marks", () => {
const view = buildRectificationTimeline({
searchWindow: ["00:00", "23:59"],
credibleRange: null,
candidateTimes: ["05:07", "06:20"],
stage: "block_scan",
});
assert.ok(view);
// Inclusive 00:00–23:59 is a full day. Exclusive difference was "23 小时 59 分".
assert.equal(view.bandStartPercent, 0);
assert.equal(view.bandWidthPercent, 100);
assert.equal(view.rangeLabel, "00:00–23:59");
assert.equal(view.widthLabel, "24 小时");
assert.deepEqual(view.marks, []);
});
test("minute stage draws marks where block scan draws none, from the same candidates", () => {
const shared = { searchWindow: ["06:20", "07:40"] as const, credibleRange: ["06:50", "07:06"] as const, candidateTimes: ["06:55", "07:02"] };
const minute = buildRectificationTimeline({ ...shared, stage: "minute" });
const block = buildRectificationTimeline({ ...shared, stage: "block_scan" });
assert.ok(minute);
assert.ok(block);
assert.equal(minute.marks.length, 2);
assert.equal(block.marks.length, 0);
});
test("widening the window rescales the axis and keeps the band inside it", () => {
// BUG-572: a low event fit rate near the window edge widens ±15 to ±30.
const before = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:01", "05:09"],
candidateTimes: ["05:02", "05:08"],
stage: "minute",
});
const after = buildRectificationTimeline({
searchWindow: ["04:30", "05:30"],
credibleRange: ["04:56", "05:20"],
candidateTimes: ["05:02", "05:08"],
stage: "minute",
});
assert.ok(before);
assert.ok(after);
// The same candidate minute sits at a different percentage once the axis grows.
assert.notEqual(before.marks[0]?.percent, after.marks[0]?.percent);
assert.equal(Math.round(before.marks[0]!.percent), 57);
assert.equal(Math.round(after.marks[0]!.percent), 53);
// The widened range still fits: an axis pinned to the opening window would not hold it.
assert.ok(after.bandStartPercent >= 0);
assert.ok(after.bandStartPercent + after.bandWidthPercent <= 100);
// 04:56–05:20 inclusive is 25 minutes. Exclusive difference was "24 分钟".
assert.equal(after.widthLabel, "25 分钟");
});
test("a window that crosses midnight stays monotonic", () => {
// late_night is 23:00–03:59, which arrives with an end earlier than its start.
const view = buildRectificationTimeline({
searchWindow: ["23:00", "03:59"],
credibleRange: ["00:30", "01:30"],
candidateTimes: ["23:30", "01:00", "03:00"],
stage: "minute",
});
assert.ok(view);
assert.equal(view.axisStartLabel, "23:00");
assert.equal(view.axisEndLabel, "03:59");
assert.equal(view.rangeLabel, "00:30–01:30");
// Inclusive 00:30–01:30 is 61 minutes. Exclusive difference was "1 小时".
assert.equal(view.widthLabel, "1 小时 1 分");
assert.deepEqual(view.marks.map((mark) => mark.state), ["in", "in", "in"]);
assert.ok(view.marks.every((mark) => mark.percent >= 0 && mark.percent <= 100));
});
test("widthLabel counts both clock ends, matching the delivery-report inclusive minute shape", () => {
const label = (
searchWindow: readonly [string, string],
credibleRange: readonly [string, string] | null,
) => buildRectificationTimeline({
searchWindow,
credibleRange,
candidateTimes: [],
stage: credibleRange ? "minute" : "block_scan",
})?.widthLabel;
assert.equal(label(["04:45", "05:15"], ["04:51", "04:59"]), "9 分钟");
assert.equal(label(["04:45", "05:15"], ["05:07", "05:09"]), "3 分钟");
assert.equal(label(["04:45", "05:15"], ["04:51", "04:53"]), "3 分钟");
assert.equal(label(["04:45", "05:15"], ["05:07", "05:07"]), "1 分钟");
assert.equal(label(["00:00", "23:59"], null), "24 小时");
});
test("eliminated inference minutes stay as hollow dots and keep stable keys", () => {
const searchWindow = ["04:45", "05:15"] as const;
const credibleRange = ["04:51", "04:53"] as const;
const times = [
"04:45", "04:47", "04:49", "04:51", "04:52", "04:53", "04:55", "04:57", "04:59",
] as const;
const marksFor = (active: ReadonlySet<string>) => times.map((time) => ({
time,
eliminated: !active.has(time),
}));
const first = buildRectificationTimeline({
searchWindow,
credibleRange,
candidateTimes: ["04:51", "04:53"],
inferenceMarks: marksFor(new Set(["04:51", "04:53"])),
stage: "minute",
});
assert.ok(first);
assert.equal(first.marks.length, 9);
assert.equal(first.marks.filter((mark) => mark.state === "out").length, 7);
assert.equal(first.marks.filter((mark) => mark.state === "in").length, 2);
const firstKeys = first.marks.map((mark) => mark.key);
const second = buildRectificationTimeline({
searchWindow,
credibleRange: ["04:53", "04:53"],
candidateTimes: ["04:53"],
inferenceMarks: marksFor(new Set(["04:53"])),
stage: "minute",
});
assert.ok(second);
assert.equal(second.marks.length, 9);
assert.equal(second.marks.filter((mark) => mark.state === "out").length, 8);
assert.equal(second.marks.filter((mark) => mark.state === "in").length, 1);
assert.deepEqual(second.marks.map((mark) => mark.key), firstKeys);
});
test("timeline chat wiring reads parsed inferenceMarks and never the raw receipt", () => {
// 原值: 从 chat 源码切 `const timelineView = buildRectificationTimeline` 到 `const persistedOfferKey`,
// 匹配 `inferenceMarks: candidateResult?.inferenceMarks`,且不含 decisionReceipt / inference_state。
// 新值: 调用 deriveRectificationChatView:时间轴刻度来自解析后的 inferenceMarks(与直接用它们建轴一致、
// 与只用候选分钟建轴不同);只改 decisionReceipt 里的 inference_state,时间轴不变。
// 原因: TASK-rectification-code-split-20260926 时间轴派生原样搬进纯函数 rectification-chat-view.ts。
const marks = [
{ time: "04:50", eliminated: true },
{ time: "04:53", eliminated: false },
{ time: "04:57", eliminated: true },
];
const base = offerableCandidateResult();
const withMarks = { ...base, inferenceMarks: marks };
const input = chatViewInput({
candidateResult: withMarks,
searchWindow: ["04:45", "05:15"],
caseStage: "minute",
});
const view = deriveRectificationChatView(input);
assert.ok(view.timelineView);
assert.equal(view.timelineView.marks.length, 3);
const direct = buildRectificationTimeline({
searchWindow: ["04:45", "05:15"],
credibleRange: withMarks.credibleRange ?? null,
candidateTimes: withMarks.candidates.map((candidate) => candidate.time),
inferenceMarks: marks,
stage: "minute",
workingTime: workingRectificationTime(withMarks),
workingAdopted: false,
answeredProbeCount: withMarks.answeredProbeCount ?? null,
datedEventCount: withMarks.rangeDelivery?.event_count ?? null,
});
assert.deepEqual(view.timelineView, direct);
const candidatesOnly = deriveRectificationChatView({ ...input, candidateResult: { ...base, inferenceMarks: [] } });
assert.notDeepEqual(candidatesOnly.timelineView?.marks, view.timelineView.marks);
const receiptChanged = deriveRectificationChatView({
...input,
candidateResult: {
...withMarks,
decisionReceipt: {
...(withMarks.decisionReceipt ?? {}),
inference_state: { candidates: [{ time: "05:10", eliminated: false }] },
} as typeof withMarks.decisionReceipt,
},
});
assert.deepEqual(receiptChanged.timelineView, view.timelineView);
});
test("parsed inference_state marks reach the timeline even when engine candidates are only the active minutes", () => {
const activeId = "88888888-8888-4888-8888-888888888881";
const secondId = "88888888-8888-4888-8888-888888888882";
const times = [
"04:45", "04:47", "04:49", "04:51", "04:52", "04:53", "04:55", "04:57", "04:59",
] as const;
const result = parseRectificationCandidateResult({
resultId: "11111111-1111-4111-8111-111111111111",
candidates: [
{ candidateId: activeId, rank: 1, time: "04:51", relativeSupport: 40, tiedMinuteCount: 3 },
{ candidateId: secondId, rank: 2, time: "04:53", relativeSupport: 38, tiedMinuteCount: 3 },
],
overallConfidence: "low",
selectionAllowed: false,
canAdopt: false,
confirmationAllowed: false,
representativeTime: "04:53",
selectedTime: null,
selectionKind: null,
credibleRange: ["04:51", "04:53"],
decisionReceipt: {
inference_state: {
candidates: times.map((time) => ({
time,
status: time === "04:51" || time === "04:53" ? "active" : "eliminated",
})),
},
},
});
assert.ok(result);
assert.ok(result.inferenceMarks);
assert.equal(result.candidates.length, 2);
assert.equal(result.inferenceMarks.length, 9);
assert.equal(result.inferenceMarks.filter((mark) => mark.eliminated).length, 7);
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: result.credibleRange,
candidateTimes: result.candidates.map((candidate) => candidate.time),
inferenceMarks: result.inferenceMarks,
stage: "minute",
});
assert.ok(view);
assert.equal(view.marks.length, 9);
assert.equal(view.marks.filter((mark) => mark.state === "out").length, 7);
assert.equal(view.marks.filter((mark) => mark.state === "in").length, 2);
assert.equal(view.widthLabel, "3 分钟");
});
test("duration reads as clock language, not raw minutes", () => {
assert.equal(timelineDurationLabel(2), "2 分钟");
assert.equal(timelineDurationLabel(59), "59 分钟");
assert.equal(timelineDurationLabel(60), "1 小时");
assert.equal(timelineDurationLabel(80), "1 小时 20 分");
assert.equal(timelineDurationLabel(240), "4 小时");
});
test("no window means no bar content; the caller still renders the same box", () => {
assert.equal(buildRectificationTimeline({ searchWindow: null, credibleRange: ["05:07", "05:09"], candidateTimes: [], stage: "minute" }), null);
assert.equal(buildRectificationTimeline({ searchWindow: ["bad", "05:15"], credibleRange: null, candidateTimes: [], stage: "minute" }), null);
// Same element, same height, no spinner and no loading copy.
assert.match(component, /if \(!view\) \{\s*return <div className="rectification-timeline" data-state="pending"/);
assert.doesNotMatch(component, /InlineSpinner|正在加载|骨架/);
});
test("the bar is read-only and carries no confidence grading", () => {
// BUG-575 banned hover-revealed detail on this surface; BUG-560 blocks grading.
assert.doesNotMatch(component, /onMouseEnter|onMouseOver|onClick|onFocus/);
assert.doesNotMatch(component, /<button|relativeSupport|probability/);
assert.doesNotMatch(scale, /relativeSupport|probability/);
// Two elements only: the readout and the axis.
assert.match(component, /rectification-timeline__readout/);
assert.match(component, /rectification-timeline__axis/);
// The cut list stays cut.
assert.doesNotMatch(component, /已对照|已排除|仍在范围内|范围从|只读|时段阶段|分钟阶段/);
assert.doesNotMatch(component, /rectification-step-state/);
});
test("the band and marks move by transform, never by animating layout", () => {
// globals.css bans transitions on width outright (sidebar-contract guards it);
// transform also keeps the motion off the layout path.
assert.match(component, /transform: `translateX\(\$\{view\.bandStartPercent\}%\) scaleX\(/);
assert.match(component, /transform: `translateX\(\$\{mark\.percent\}%\)`/);
// The two moving parts never take an inline left/width; ticks may, because a
// rescale rebuilds the whole tick set rather than sliding it.
assert.doesNotMatch(component, /__band"[\s\S]{0,120}(?:left|width): `/);
assert.doesNotMatch(component, /__mark-at"[\s\S]{0,120}(?:left|width): `/);
const barStyles = styles.slice(
styles.indexOf(".rectification-timeline__band"),
styles.indexOf(".rectification-timeline__tick"),
);
assert.match(barStyles, /transition: transform 420ms/);
assert.doesNotMatch(barStyles, /transition:[^;}]*\b(?:width|left|height)\b/);
});
test("nothing on the bar advances on a timer", () => {
// BUG-601's principle: never animate motion the backend has not made.
assert.doesNotMatch(component, /setInterval|setTimeout|requestAnimationFrame/);
assert.doesNotMatch(scale, /setInterval|setTimeout|requestAnimationFrame|Date\.now/);
});
test("the bar is its own grid row, outside the scroll container", () => {
// Rendered as a sibling before <section className="conversation">, not inside it.
assert.match(
chat,
/<RectificationTimeline view=\{timelineView\} \/>\s*<section\s*ref=\{conversation\}\s*className="conversation is-rectification"/,
);
assert.doesNotMatch(component, /position:\s*sticky|sticky/);
// The scroll anchor knows nothing about the timeline and needs no changes.
assert.doesNotMatch(anchor, /timeline|rectification-timeline/i);
});
test("the chat grid has three rows and the timeline row is a fixed height", () => {
const chatRule = styles.slice(styles.indexOf(".rectification-workspace__chat {"));
assert.match(chatRule, /grid-template-rows: var\(--rectification-timeline-height\) minmax\(0, 1fr\) auto;/);
assert.match(styles, /--rectification-timeline-height: 64px;/);
const barRule = styles.slice(styles.indexOf(".rectification-timeline {"), styles.indexOf(".rectification-timeline__readout"));
assert.match(barRule, /height: var\(--rectification-timeline-height\);/);
// Not min-height and not content-driven: a growing bar would silently break
// stick-to-bottom, since it sits outside the observed scroll container.
assert.doesNotMatch(barRule, /min-height|height:\s*auto/);
// Inner padding matches the assistant column, not the chat-panel edge.
// 原值 `0px` / 新值 `calc(32px + var(--space-3))`
// / 原因:产品在真机走查后要求把 agent 头像加回来(推翻 R3 的 D7);
// 这条缩进跟着头像走,时间轴仍与助手正文左边界对齐,规则本身没变。
assert.match(chatRule, /--assistant-content-inset: calc\(32px \+ var\(--space-3\)\);/);
assert.match(barRule, /padding: 0 calc\(var\(--space-8\) \+ var\(--assistant-content-inset\)\);/);
});
test("the mobile bar stays within the 44px touch rhythm", () => {
const mobile = styles.slice(styles.indexOf("@media (max-width: 767px) {\n .rectification-workspace__chat {"));
assert.match(mobile, /--rectification-timeline-height: 56px;/);
// 原值: padding-inline: calc(var(--space-4) + var(--assistant-content-inset));
// 新值: padding-inline: var(--space-4);
// 原因: BUG-918。真机 375px 下这 44px 头像沟槽让读数放不下,第四项被
// text-overflow 裁成「已…」。条不在消息列里,手机上对齐头像列没有
// 可见收益,页面边距才是诚实的边界。桌面那条规则不变(上一条测试)。
assert.match(mobile, /padding-inline: var\(--space-4\);/);
// 56px is the existing jump-clearance constant, not a new number.
assert.match(styles, /--rectification-jump-clearance: calc\(44px \+ var\(--space-3\)\);/);
});