fix(rectification): count timeline width inclusively and keep eliminated minutes hollow (BUG-602, BUG-603)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -307,4 +307,40 @@ test("session_outcome gates adopt cards independently of leaked can_adopt", () =
|
||||
assert.equal(canShowRectificationReadonlyRange(adopted), false);
|
||||
});
|
||||
|
||||
test("parses inference marks from decisionReceipt.inference_state.candidates", () => {
|
||||
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({
|
||||
...camelCaseSnapshot,
|
||||
candidates: [
|
||||
{ candidateId: CANDIDATE_ID, rank: 1, time: "04:51", relativeSupport: 40, tiedMinuteCount: 3 },
|
||||
{ candidateId: SECOND_CANDIDATE_ID, rank: 2, time: "04:53", relativeSupport: 38, tiedMinuteCount: 3 },
|
||||
],
|
||||
credible_range: ["04:51", "04:53"],
|
||||
decisionReceipt: {
|
||||
...camelCaseSnapshot.decisionReceipt,
|
||||
inference_state: {
|
||||
candidates: times.map((clock) => ({
|
||||
time: clock,
|
||||
status: clock === "04:51" || clock === "04:53" ? "active" : "eliminated",
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.ok(result);
|
||||
assert.ok(result.inferenceMarks);
|
||||
assert.equal(result.candidates.length, 2);
|
||||
assert.deepEqual(result.inferenceMarks.map((mark) => mark.time), [...times]);
|
||||
assert.equal(result.inferenceMarks.filter((mark) => mark.eliminated).length, 7);
|
||||
assert.equal(result.inferenceMarks.find((mark) => mark.time === "04:51")?.eliminated, false);
|
||||
assert.equal(result.inferenceMarks.find((mark) => mark.time === "04:45")?.eliminated, true);
|
||||
});
|
||||
|
||||
test("inference marks are empty when inference_state is absent", () => {
|
||||
const result = parseRectificationCandidateResult(camelCaseSnapshot);
|
||||
assert.ok(result);
|
||||
assert.deepEqual(result.inferenceMarks, []);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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,
|
||||
@@ -86,7 +87,8 @@ test("the band is placed against the axis and never escapes it", () => {
|
||||
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 分钟");
|
||||
// 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({
|
||||
@@ -100,7 +102,33 @@ test("the band is placed against the axis and never escapes it", () => {
|
||||
assert.equal(overflowing.bandWidthPercent, 100);
|
||||
});
|
||||
|
||||
test("candidate marks are binary and the range boundary counts as inside", () => {
|
||||
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"],
|
||||
@@ -108,12 +136,7 @@ test("candidate marks are binary and the range boundary counts as inside", () =>
|
||||
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);
|
||||
assert.deepEqual(view.marks.map((mark) => mark.state), ["in", "in", "in", "in", "in"]);
|
||||
});
|
||||
|
||||
test("marks outside the window are dropped and duplicates collapse", () => {
|
||||
@@ -135,11 +158,11 @@ test("block scan draws the window as the range and no minute marks", () => {
|
||||
stage: "block_scan",
|
||||
});
|
||||
assert.ok(view);
|
||||
// Saying "23 小时 59 分" here is the truth: nothing has been ruled out yet.
|
||||
// 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, "23 小时 59 分");
|
||||
assert.equal(view.widthLabel, "24 小时");
|
||||
assert.deepEqual(view.marks, []);
|
||||
});
|
||||
|
||||
@@ -176,7 +199,8 @@ test("widening the window rescales the axis and keeps the band inside it", () =>
|
||||
// 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 分钟");
|
||||
// 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", () => {
|
||||
@@ -191,11 +215,126 @@ test("a window that crosses midnight stays monotonic", () => {
|
||||
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"]);
|
||||
// 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 分钟");
|
||||
|
||||
Reference in New Issue
Block a user