418 lines
18 KiB
TypeScript
418 lines
18 KiB
TypeScript
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<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", () => {
|
||
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 <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/);
|
||
});
|
||
|
||
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\)\);/);
|
||
});
|