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"; 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"); const chat = read("../src/components/rectification-agentic-chat.tsx"); 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) => 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", () => { const timelineBlock = chat.slice( chat.indexOf("const timelineView = buildRectificationTimeline"), chat.indexOf("const persistedOfferKey"), ); assert.match(timelineBlock, /inferenceMarks:\s*candidateResult\?\.inferenceMarks/); assert.doesNotMatch(timelineBlock, /decisionReceipt|inference_state/); }); 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
{ // BUG-575 banned hover-revealed detail on this surface; BUG-560 blocks grading. assert.doesNotMatch(component, /onMouseEnter|onMouseOver|onClick|onFocus/); assert.doesNotMatch(component, / { // 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
, not inside it. assert.match( chat, /\s* { 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/); }); 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;/); // 56px is the existing jump-clearance constant, not a new number. assert.match(styles, /--rectification-jump-clearance: calc\(44px \+ var\(--space-3\)\);/); });