diff --git a/frontend/src/components/LeftoverMapPlot.tsx b/frontend/src/components/LeftoverMapPlot.tsx index 572efbce7..427734cdc 100644 --- a/frontend/src/components/LeftoverMapPlot.tsx +++ b/frontend/src/components/LeftoverMapPlot.tsx @@ -364,18 +364,14 @@ export function LeftoverMapPlot({ {layout.ticks.map((tick) => { const singular = leftoverSingularForAxis(leftoverMapAxes, tick.axis); + const share = leftoverShareForAxis(leftoverMapAxes, tick.axis); const reportTickBadge = variant === "report" - ? leftoverMapPlotTickAxisBadge(tick.axis, tick.label, singular) + ? leftoverMapPlotTickAxisBadge(tick.axis, tick.label, singular, share) : null; const comparisonTickBadge = variant === "comparison" - ? leftoverMapComparePlotTickAxisBadge( - tick.axis, - tick.label, - singular, - leftoverShareForAxis(leftoverMapAxes, tick.axis), - ) + ? leftoverMapComparePlotTickAxisBadge(tick.axis, tick.label, singular, share) : null; const tickAriaLabel = variant === "comparison" diff --git a/frontend/src/leftoverMapPlotAxisSingular.test.ts b/frontend/src/leftoverMapPlotAxisSingular.test.ts new file mode 100644 index 000000000..c766dc99d --- /dev/null +++ b/frontend/src/leftoverMapPlotAxisSingular.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + leftoverMapCompareAxisTickBadge, + leftoverMapComparePlotTickAxisBadge, + leftoverMapPlotTickAxisBadge, + LEFTOVER_MAP_COMPARE_AXIS_TICK_SINGULAR, + LEFTOVER_MAP_COMPARE_PLOT_TICK_SHARE, + LEFTOVER_MAP_COMPARE_PLOT_TICK_SINGULAR, + LEFTOVER_MAP_COMPARE_PLOT_TICK_SINGULAR_SHARE, + LEFTOVER_MAP_PLOT_TICK_SHARE, + LEFTOVER_MAP_PLOT_TICK_SINGULAR, + LEFTOVER_MAP_PLOT_TICK_SINGULAR_SHARE, +} from "./leftoverMapPlotAxisSingular"; + +describe("leftoverMapPlotTickAxisBadge", () => { + it("keeps report-graphic tick singular and share evidence independent", () => { + expect(leftoverMapPlotTickAxisBadge(1, "-1.0", null, null)).toBeNull(); + expect(leftoverMapPlotTickAxisBadge(1, "-1.0", 1.24, null)).toEqual({ + template: LEFTOVER_MAP_PLOT_TICK_SINGULAR, + values: { axis: 1, value: "-1.0", singular: "1.24" }, + }); + expect(leftoverMapPlotTickAxisBadge(2, "0.5", null, 0.18)).toEqual({ + template: LEFTOVER_MAP_PLOT_TICK_SHARE, + values: { axis: 2, value: "0.5", share: "18" }, + }); + expect(leftoverMapPlotTickAxisBadge(2, "0.5", 0, 0.18)).toEqual({ + template: LEFTOVER_MAP_PLOT_TICK_SINGULAR_SHARE, + values: { axis: 2, value: "0.5", singular: "0.00", share: "18" }, + }); + }); + + it("fails closed for invalid persisted tick evidence without fabricating the other field", () => { + expect( + leftoverMapPlotTickAxisBadge(1, "0", Number.NaN, Number.POSITIVE_INFINITY), + ).toBeNull(); + expect(leftoverMapPlotTickAxisBadge(1, "0", -0.01, 0.42)).toEqual({ + template: LEFTOVER_MAP_PLOT_TICK_SHARE, + values: { axis: 1, value: "0", share: "42" }, + }); + }); +}); + +describe("leftoverMapComparePlotTickAxisBadge", () => { + it("keeps comparison-graphic tick singular and share evidence independent", () => { + expect(leftoverMapComparePlotTickAxisBadge(1, "-1.0", null, null)).toBeNull(); + expect(leftoverMapComparePlotTickAxisBadge(1, "-1.0", 1.24, null)).toEqual({ + template: LEFTOVER_MAP_COMPARE_PLOT_TICK_SINGULAR, + values: { axis: 1, value: "-1.0", singular: "1.24" }, + }); + expect(leftoverMapComparePlotTickAxisBadge(2, "0.5", null, 0.18)).toEqual({ + template: LEFTOVER_MAP_COMPARE_PLOT_TICK_SHARE, + values: { axis: 2, value: "0.5", share: "18" }, + }); + expect(leftoverMapComparePlotTickAxisBadge(2, "0.5", 0, 0.18)).toEqual({ + template: LEFTOVER_MAP_COMPARE_PLOT_TICK_SINGULAR_SHARE, + values: { axis: 2, value: "0.5", singular: "0.00", share: "18" }, + }); + }); +}); + +describe("leftoverMapCompareAxisTickBadge", () => { + it("projects only finite persisted singular evidence onto comparison-strip ticks", () => { + expect(leftoverMapCompareAxisTickBadge(1, "0", 1.24)).toEqual({ + template: LEFTOVER_MAP_COMPARE_AXIS_TICK_SINGULAR, + values: { axis: 1, value: "0", singular: "1.24" }, + }); + expect(leftoverMapCompareAxisTickBadge(2, "1", 0)).toEqual({ + template: LEFTOVER_MAP_COMPARE_AXIS_TICK_SINGULAR, + values: { axis: 2, value: "1", singular: "0.00" }, + }); + expect(leftoverMapCompareAxisTickBadge(1, "0", null)).toBeNull(); + expect(leftoverMapCompareAxisTickBadge(1, "0", Number.NaN)).toBeNull(); + expect(leftoverMapCompareAxisTickBadge(1, "0", -0.01)).toBeNull(); + }); +}); diff --git a/frontend/src/leftoverMapPlotAxisSingular.ts b/frontend/src/leftoverMapPlotAxisSingular.ts index bad935e7f..7a2111c08 100644 --- a/frontend/src/leftoverMapPlotAxisSingular.ts +++ b/frontend/src/leftoverMapPlotAxisSingular.ts @@ -30,6 +30,12 @@ export const LEFTOVER_MAP_COMPARE_AXIS_SHARE = export const LEFTOVER_MAP_PLOT_TICK_SINGULAR = "leftover-map axis {axis} tick {value} σ {singular}"; +export const LEFTOVER_MAP_PLOT_TICK_SHARE = + "leftover-map axis {axis} tick {value} {share}%"; + +export const LEFTOVER_MAP_PLOT_TICK_SINGULAR_SHARE = + "leftover-map axis {axis} tick {value} σ {singular} {share}%"; + export const LEFTOVER_MAP_COMPARE_PLOT_TICK_SINGULAR = "leftover map comparison graphic leftover-map axis {axis} tick {value} σ {singular}"; @@ -147,19 +153,33 @@ export function leftoverMapCompareAxisBadge( }; } -/** Project finite persisted σ onto one report-graphic tick without inventing share evidence. */ +/** Compose persisted report-graphic tick σ/share without deriving either field. */ export function leftoverMapPlotTickAxisBadge( axisIndex: number, tickLabel: string, leftoverSingular: number | null | undefined, + leftoverShare?: LeftoverMapAxis["leftover_share"] | null, ): LeftoverMapCompareAxisBadge | null { const singular = formatLeftoverMapPlotAxisSingular(leftoverSingular); - if (singular === null) { + const share = formatLeftoverMapPlotAxisShare(leftoverShare); + if (singular === null && share === null) { return null; } + if (singular === null) { + return { + template: LEFTOVER_MAP_PLOT_TICK_SHARE, + values: { axis: axisIndex, value: tickLabel, share: share as string }, + }; + } + if (share === null) { + return { + template: LEFTOVER_MAP_PLOT_TICK_SINGULAR, + values: { axis: axisIndex, value: tickLabel, singular }, + }; + } return { - template: LEFTOVER_MAP_PLOT_TICK_SINGULAR, - values: { axis: axisIndex, value: tickLabel, singular }, + template: LEFTOVER_MAP_PLOT_TICK_SINGULAR_SHARE, + values: { axis: axisIndex, value: tickLabel, singular, share }, }; } diff --git a/tests/test_grouping_comparison_graphic_tick_singular_contract.py b/tests/test_grouping_comparison_graphic_tick_singular_contract.py index da559bf74..68d8755bc 100644 --- a/tests/test_grouping_comparison_graphic_tick_singular_contract.py +++ b/tests/test_grouping_comparison_graphic_tick_singular_contract.py @@ -18,20 +18,29 @@ def test_comparison_graphic_tick_keeps_persisted_singular_value_without_share() def test_comparison_graphic_consumes_tick_singular_projection() -> None: - """The buyer-visible comparison graphic must consume its persisted-σ tick projection.""" + """The buyer-visible comparison graphic consumes independently persisted σ/share.""" source = PLOT_COMPONENT_SOURCE.read_text(encoding="utf-8") assert "leftoverMapComparePlotTickAxisBadge" in source + assert "leftoverSingularForAxis(leftoverMapAxes, tick.axis)" in source + assert "leftoverShareForAxis(leftoverMapAxes, tick.axis)" in source assert "tf(comparisonTickBadge.template, comparisonTickBadge.values)" in source def test_comparison_graphic_tick_does_not_infer_sigma_or_share() -> None: - """Invalid σ falls back to the comparison tick and tick captions never synthesize share.""" + """Comparison ticks compose independently persisted σ/share without deriving either.""" assert SINGULAR_SOURCE.exists(), "singular-value axis 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 - tail = source.split("leftoverMapComparePlotTickAxisBadge", 1)[-1][:1600] - assert "leftover_share" not in tail + helper = source.split("export function leftoverMapComparePlotTickAxisBadge", 1)[-1].split( + "export function leftoverMapCompareAxisTickBadge", 1 + )[0] + + assert "formatLeftoverMapPlotAxisSingular" in helper + assert "formatLeftoverMapPlotAxisShare" in helper + assert "leftoverSingular" in helper + assert "leftoverShare" in helper + assert "LEFTOVER_MAP_COMPARE_PLOT_TICK_SHARE" in helper + assert "LEFTOVER_MAP_COMPARE_PLOT_TICK_SINGULAR_SHARE" in helper + assert "Math.sqrt" not in helper + assert "Math.max" not in helper + assert "Math.min" not in helper diff --git a/tests/test_leftover_axis_report_singular_contract.py b/tests/test_leftover_axis_report_singular_contract.py index 192efc04f..ee41db70f 100644 --- a/tests/test_leftover_axis_report_singular_contract.py +++ b/tests/test_leftover_axis_report_singular_contract.py @@ -9,16 +9,24 @@ def test_report_axis_badge_keeps_singular_value_and_share_semantically_distinct() -> None: - """Report badges must expose persisted σ without borrowing comparison-graphic copy.""" + """Report badges compose persisted σ/share independently without comparison copy.""" assert BADGE_SOURCE.exists(), "report-axis singular-value badge helper is missing" badge_source = BADGE_SOURCE.read_text(encoding="utf-8") assert 'LEFTOVER_MAP_AXIS_BADGE_SHARE = "leftover axis {axis}{share}"' in badge_source - assert 'LEFTOVER_MAP_AXIS_BADGE_SINGULAR = "leftover axis {axis} σ {value}{share}"' in badge_source - assert 'LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY = "leftover axis {axis} σ {value}"' in badge_source + assert ( + 'LEFTOVER_MAP_AXIS_BADGE_SINGULAR = "leftover axis {axis} σ {value}{share}"' + in badge_source + ) + assert ( + 'LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY = "leftover axis {axis} σ {value}"' + in badge_source + ) + assert "leftoverMapAxisBadgeShare" in badge_source + assert "formatLeftoverMapPlotAxisShare" in badge_source + assert 'return share === null ? "" : ` ${share}%`;' in badge_source assert "leftoverSingularForAxis" in badge_source assert "formatLeftoverMapPlotAxisSingular" in badge_source - assert "formatLeftoverMapPlotAxisShare" in badge_source assert "leftover map comparison graphic" not in badge_source diff --git a/tests/test_leftover_map_report_graphic_axis_singular_contract.py b/tests/test_leftover_map_report_graphic_axis_singular_contract.py index 10ecc77f2..10a5b8964 100644 --- a/tests/test_leftover_map_report_graphic_axis_singular_contract.py +++ b/tests/test_leftover_map_report_graphic_axis_singular_contract.py @@ -14,8 +14,16 @@ def test_report_graphic_axis_has_distinct_singular_and_singular_share_copy() -> source = SINGULAR_SOURCE.read_text(encoding="utf-8") plot_source = PLOT_SOURCE.read_text(encoding="utf-8") - assert '"leftover-map axis {axis} σ {value}"' in source - assert '"leftover-map axis {axis} σ {value} ({share}%)"' in source + assert ( + 'export const LEFTOVER_MAP_PLOT_AXIS_SINGULAR =\n' + ' "leftover-map axis {axis} σ {value}";' + in source + ) + assert ( + 'export const LEFTOVER_MAP_PLOT_AXIS_SINGULAR_SHARE =\n' + ' "leftover-map axis {axis} σ {value} ({share}%)";' + in source + ) assert "LEFTOVER_MAP_PLOT_AXIS_SINGULAR" in plot_source assert "LEFTOVER_MAP_PLOT_AXIS_SINGULAR_SHARE" in plot_source diff --git a/tests/test_leftover_map_report_graphic_tick_share_contract.py b/tests/test_leftover_map_report_graphic_tick_share_contract.py new file mode 100644 index 000000000..ca71d132a --- /dev/null +++ b/tests/test_leftover_map_report_graphic_tick_share_contract.py @@ -0,0 +1,29 @@ +"""Executable contract for report-graphic tick axis-share captions.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SINGULAR_SOURCE = ROOT / "frontend" / "src" / "leftoverMapPlotAxisSingular.ts" + + +def test_report_graphic_tick_keeps_axis_share_independent_of_singular() -> None: + """A report-graphic tick may name persisted share when singular evidence is absent.""" + assert SINGULAR_SOURCE.exists(), "report graphic axis projection helper is missing" + source = SINGULAR_SOURCE.read_text(encoding="utf-8") + + assert "leftoverMapPlotTickAxisBadge" in source + assert "leftover-map axis {axis} tick {value} {share}%" in source + assert "leftover-map axis {axis} tick {value} σ {singular} {share}%" in source + + +def test_report_graphic_tick_never_infers_share_from_singular() -> None: + """Share-only, singular-only, combined, and empty states remain persisted-data decisions.""" + assert SINGULAR_SOURCE.exists(), "report graphic axis projection helper is missing" + source = SINGULAR_SOURCE.read_text(encoding="utf-8") + + assert "Number.isFinite" in source + assert "leftover_share" in source + assert "formatLeftoverMapPlotAxisSingular" in source + assert "Math.sqrt" not in source + assert "Math.max" not in source diff --git a/tests/test_leftover_map_report_tick_singular_contract.py b/tests/test_leftover_map_report_tick_singular_contract.py index cfd1da29b..7790b6e66 100644 --- a/tests/test_leftover_map_report_tick_singular_contract.py +++ b/tests/test_leftover_map_report_tick_singular_contract.py @@ -18,20 +18,29 @@ def test_report_graphic_tick_keeps_persisted_singular_value_without_share() -> N def test_report_graphic_consumes_tick_singular_projection() -> None: - """The buyer-visible report graphic must consume the persisted-σ tick projection.""" + """The buyer-visible report graphic consumes independently persisted σ/share.""" source = PLOT_COMPONENT_SOURCE.read_text(encoding="utf-8") assert "leftoverMapPlotTickAxisBadge" in source assert "leftoverSingularForAxis(leftoverMapAxes, tick.axis)" in source + assert "leftoverShareForAxis(leftoverMapAxes, tick.axis)" in source assert "tf(reportTickBadge.template, reportTickBadge.values)" in source def test_report_graphic_tick_does_not_infer_sigma_or_share() -> None: - """Invalid σ falls back to the ordinary tick and ticks never synthesize share.""" + """Report ticks compose independently persisted σ/share without deriving either.""" assert SINGULAR_SOURCE.exists(), "singular-value axis 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 "leftover_share" not in source.split("leftoverMapPlotTickAxisBadge", 1)[-1][:1400] + helper = source.split("export function leftoverMapPlotTickAxisBadge", 1)[-1].split( + "export function leftoverMapComparePlotTickAxisBadge", 1 + )[0] + + assert "formatLeftoverMapPlotAxisSingular" in helper + assert "formatLeftoverMapPlotAxisShare" in helper + assert "leftoverSingular" in helper + assert "leftoverShare" in helper + assert "LEFTOVER_MAP_PLOT_TICK_SHARE" in helper + assert "LEFTOVER_MAP_PLOT_TICK_SINGULAR_SHARE" in helper + assert "Math.sqrt" not in helper + assert "Math.max" not in helper + assert "Math.min" not in helper