diff --git a/frontend/src/components/LeftoverMapPlot.tsx b/frontend/src/components/LeftoverMapPlot.tsx index 9e36987ae..4fc42abd6 100644 --- a/frontend/src/components/LeftoverMapPlot.tsx +++ b/frontend/src/components/LeftoverMapPlot.tsx @@ -29,7 +29,7 @@ import { } from "../leftoverMapPlotAxisShare"; import { formatLeftoverMapPlotAxisSingular, - leftoverMapCompareAxisBadge, + leftoverMapComparePlotAxisBadge, leftoverSingularForAxis, LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR, LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR_SHARE, @@ -77,10 +77,12 @@ export type LeftoverMapPlotProps = { variant?: LeftoverMapPlotVariant; }; +/** Return the four vertices for a diamond marker centered at the persisted plot point. */ function diamondPoints(x: number, y: number, radius: number): string { return `${x},${y - radius} ${x + radius},${y} ${x},${y + radius} ${x - radius},${y}`; } +/** Compose one axis caption from persisted share and singular evidence for the requested surface. */ function leftoverMapPlotAxisText( axisIndex: 1 | 2, leftoverMapAxes: LeftoverMapAxis[] | undefined, @@ -236,7 +238,7 @@ export function LeftoverMapPlot({ const comparisonAxisBadges = variant === "comparison" ? (leftoverMapAxes ?? []) - .map((axis) => leftoverMapCompareAxisBadge(axis)) + .map((axis) => leftoverMapComparePlotAxisBadge(axis)) .filter((badge): badge is NonNullable => badge !== null) : []; diff --git a/frontend/src/leftoverMapAxisBadge.test.ts b/frontend/src/leftoverMapAxisBadge.test.ts index 66d5d099c..5c167824d 100644 --- a/frontend/src/leftoverMapAxisBadge.test.ts +++ b/frontend/src/leftoverMapAxisBadge.test.ts @@ -7,8 +7,10 @@ import { LEFTOVER_MAP_AXIS_BADGE_SINGULAR, LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY, } from "./leftoverMapAxisBadge"; +import { LEFTOVER_MAP_COMPARE_PLOT_AXIS_SHARE } from "./leftoverMapPlotAxisShare"; import { leftoverMapCompareAxisBadge, + leftoverMapComparePlotAxisBadge, LEFTOVER_MAP_COMPARE_AXIS_SHARE, LEFTOVER_MAP_COMPARE_AXIS_SINGULAR, LEFTOVER_MAP_COMPARE_AXIS_SINGULAR_SHARE, @@ -115,6 +117,57 @@ describe("leftoverMapAxisBadge", () => { }); }); +describe("leftoverMapComparePlotAxisBadge", () => { + it("returns no graphic badge when neither persisted measure is usable", () => { + expect( + leftoverMapComparePlotAxisBadge({ + axis_index: 1, + leftover_singular_value: Number.NaN, + leftover_share: Number.POSITIVE_INFINITY, + }), + ).toBeNull(); + }); + + it("keeps graphic share-only evidence independent", () => { + expect( + leftoverMapComparePlotAxisBadge({ + axis_index: 2, + leftover_singular_value: null, + leftover_share: 0.18, + }), + ).toEqual({ + template: LEFTOVER_MAP_COMPARE_PLOT_AXIS_SHARE, + values: { axis: 2, share: "18" }, + }); + }); + + it("keeps graphic singular-only evidence independent and preserves finite zero", () => { + expect( + leftoverMapComparePlotAxisBadge({ + axis_index: 1, + leftover_singular_value: 0, + leftover_share: null, + }), + ).toEqual({ + template: LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR, + values: { axis: 1, value: "0.00" }, + }); + }); + + it("combines persisted graphic singular and share evidence without deriving either", () => { + expect( + leftoverMapComparePlotAxisBadge({ + axis_index: 1, + leftover_singular_value: 1.24, + leftover_share: 0.42, + }), + ).toEqual({ + template: LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR_SHARE, + values: { axis: 1, value: "1.24", share: "42" }, + }); + }); +}); + describe("leftoverMapCompareAxisBadge", () => { it("keeps persisted singular and share evidence independent", () => { expect( diff --git a/frontend/src/leftoverMapPlotAxisSingular.ts b/frontend/src/leftoverMapPlotAxisSingular.ts index aed961ef0..85ae687e8 100644 --- a/frontend/src/leftoverMapPlotAxisSingular.ts +++ b/frontend/src/leftoverMapPlotAxisSingular.ts @@ -1,7 +1,10 @@ /** Project persisted leftover-map singular-value evidence into buyer-visible axis captions. */ import type { LeftoverMapAxis } from "./api"; -import { formatLeftoverMapPlotAxisShare } from "./leftoverMapPlotAxisShare"; +import { + formatLeftoverMapPlotAxisShare, + LEFTOVER_MAP_COMPARE_PLOT_AXIS_SHARE, +} from "./leftoverMapPlotAxisShare"; export const LEFTOVER_MAP_PLOT_AXIS_SINGULAR = "leftover-map axis {axis} σ {value}"; @@ -59,6 +62,41 @@ export function formatLeftoverMapPlotAxisSingular( return leftoverSingular.toFixed(2); } +/** + * Compose comparison-graphic axis evidence without deriving one persisted + * measurement from the other. Missing or invalid evidence is omitted + * independently; when both are absent, no badge is rendered. + */ +export function leftoverMapComparePlotAxisBadge( + axis: Pick & { + leftover_share?: LeftoverMapAxis["leftover_share"] | null; + leftover_singular_value?: LeftoverMapAxis["leftover_singular_value"] | null; + }, +): LeftoverMapCompareAxisBadge | null { + const singular = formatLeftoverMapPlotAxisSingular(axis.leftover_singular_value); + const share = formatLeftoverMapPlotAxisShare(axis.leftover_share); + + if (singular === null && share === null) { + return null; + } + if (singular === null && share !== null) { + return { + template: LEFTOVER_MAP_COMPARE_PLOT_AXIS_SHARE, + values: { axis: axis.axis_index, share }, + }; + } + if (singular !== null && share === null) { + return { + template: LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR, + values: { axis: axis.axis_index, value: singular }, + }; + } + return { + template: LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR_SHARE, + values: { axis: axis.axis_index, value: singular as string, share: share as string }, + }; +} + /** * Compose comparison-strip axis evidence without deriving one persisted * measurement from the other. Missing or invalid evidence is omitted diff --git a/tests/test_grouping_comparison_graphic_axis_badge_contract.py b/tests/test_grouping_comparison_graphic_axis_badge_contract.py new file mode 100644 index 000000000..9801e7b03 --- /dev/null +++ b/tests/test_grouping_comparison_graphic_axis_badge_contract.py @@ -0,0 +1,44 @@ +"""Executable contract for comparison-graphic axis singular/share composition.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SINGULAR_SOURCE = ROOT / "frontend" / "src" / "leftoverMapPlotAxisSingular.ts" +PLOT_SOURCE = ROOT / "frontend" / "src" / "components" / "LeftoverMapPlot.tsx" + + +def test_comparison_graphic_axis_badge_preserves_singular_when_share_is_missing() -> None: + """Valid persisted σ remains visible on the comparison graphic without share.""" + assert SINGULAR_SOURCE.exists(), "comparison singular-value helper is missing" + source = SINGULAR_SOURCE.read_text(encoding="utf-8") + + assert "leftoverMapComparePlotAxisBadge" in source + assert ( + 'LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR =\n' + ' "leftover map comparison graphic leftover-map axis {axis} σ {value}"' + in source + ) + assert "LEFTOVER_MAP_COMPARE_PLOT_AXIS_SINGULAR_SHARE" in source + assert "LEFTOVER_MAP_COMPARE_PLOT_AXIS_SHARE" in source + + +def test_comparison_graphic_axis_badge_keeps_sigma_and_share_independent() -> None: + """σ-only, share-only, combined, and empty states remain persisted-data decisions.""" + assert SINGULAR_SOURCE.exists(), "comparison singular-value helper is missing" + source = SINGULAR_SOURCE.read_text(encoding="utf-8") + + assert "formatLeftoverMapPlotAxisSingular" in source + assert "Number.isFinite" in source + assert "Math.sqrt" not in source + assert "Math.max" not in source + + +def test_comparison_graphic_renders_graphic_specific_axis_badges() -> None: + """Comparison plot badges must use graphic naming, not comparison-strip naming.""" + assert PLOT_SOURCE.exists(), "comparison plot component is missing" + source = PLOT_SOURCE.read_text(encoding="utf-8") + + assert "leftoverMapComparePlotAxisBadge" in source + assert ".map((axis) => leftoverMapComparePlotAxisBadge(axis))" in source + assert ".map((axis) => leftoverMapCompareAxisBadge(axis))" not in source