import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; 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"); assert.equal(view.widthLabel, "2 分钟"); // 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 are binary and the range boundary counts as inside", () => { 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), ["out", "in", "in", "in", "out"]); // Closed interval: both ends of the range are in, not on the fence. assert.equal(view.marks[1]?.state, "in"); assert.equal(view.marks[3]?.state, "in"); // No third state exists to grade confidence with. assert.equal(view.marks.every((mark) => mark.state === "in" || mark.state === "out"), true); }); 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); // Saying "23 小时 59 分" here is the truth: nothing has been ruled out yet. assert.equal(view.bandStartPercent, 0); assert.equal(view.bandWidthPercent, 100); assert.equal(view.rangeLabel, "00:00–23:59"); assert.equal(view.widthLabel, "23 小时 59 分"); 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); assert.equal(after.widthLabel, "24 分钟"); }); 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"); assert.equal(view.widthLabel, "1 小时"); assert.deepEqual(view.marks.map((mark) => mark.state), ["out", "in", "out"]); assert.ok(view.marks.every((mark) => mark.percent >= 0 && mark.percent <= 100)); }); 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\)\);/); });