diff --git a/frontend/src/leftoverMapAxisBadge.ts b/frontend/src/leftoverMapAxisBadge.ts index 84941caa9..8f1a44e87 100644 --- a/frontend/src/leftoverMapAxisBadge.ts +++ b/frontend/src/leftoverMapAxisBadge.ts @@ -16,6 +16,12 @@ export const LEFTOVER_MAP_AXIS_BADGE_SINGULAR_ONLY = "leftover axis {axis} σ {v export const LEFTOVER_MAP_AXIS_TICK_SINGULAR = "leftover axis {axis} tick {value} σ {singular}"; +export const LEFTOVER_MAP_AXIS_TICK_SHARE = + "leftover axis {axis} tick {value} {share}%"; + +export const LEFTOVER_MAP_AXIS_TICK_SINGULAR_SHARE = + "leftover axis {axis} tick {value} σ {singular} {share}%"; + export type LeftoverMapAxisBadge = { template: string; values: Record; @@ -74,18 +80,32 @@ export function leftoverMapAxisBadge( }; } -/** Project finite persisted σ onto one report-axis tick without inventing share evidence. */ +/** Compose persisted report-axis tick σ/share without deriving either field. */ export function leftoverMapAxisTickBadge( axisIndex: number, tickLabel: string, leftoverSingular: number | null | undefined, + leftoverShare?: LeftoverMapAxis["leftover_share"] | null, ): LeftoverMapAxisBadge | 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_AXIS_TICK_SHARE, + values: { axis: axisIndex, value: tickLabel, share: share as string }, + }; + } + if (share === null) { + return { + template: LEFTOVER_MAP_AXIS_TICK_SINGULAR, + values: { axis: axisIndex, value: tickLabel, singular }, + }; + } return { - template: LEFTOVER_MAP_AXIS_TICK_SINGULAR, - values: { axis: axisIndex, value: tickLabel, singular }, + template: LEFTOVER_MAP_AXIS_TICK_SINGULAR_SHARE, + values: { axis: axisIndex, value: tickLabel, singular, share }, }; } diff --git a/frontend/src/leftoverMapAxisTickBadge.test.ts b/frontend/src/leftoverMapAxisTickBadge.test.ts new file mode 100644 index 000000000..b17e5a643 --- /dev/null +++ b/frontend/src/leftoverMapAxisTickBadge.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { + LEFTOVER_MAP_AXIS_TICK_SHARE, + LEFTOVER_MAP_AXIS_TICK_SINGULAR, + LEFTOVER_MAP_AXIS_TICK_SINGULAR_SHARE, + leftoverMapAxisTickBadge, +} from "./leftoverMapAxisBadge"; + +describe("leftoverMapAxisTickBadge", () => { + it("keeps persisted singular and share evidence independently observable", () => { + expect(leftoverMapAxisTickBadge(1, "0.25", 0.5, 0.4)).toEqual({ + template: LEFTOVER_MAP_AXIS_TICK_SINGULAR_SHARE, + values: { axis: 1, value: "0.25", singular: "0.50", share: "40" }, + }); + expect(leftoverMapAxisTickBadge(1, "0.00", 0, null)).toEqual({ + template: LEFTOVER_MAP_AXIS_TICK_SINGULAR, + values: { axis: 1, value: "0.00", singular: "0.00" }, + }); + expect(leftoverMapAxisTickBadge(2, "−0.25", null, 0.4)).toEqual({ + template: LEFTOVER_MAP_AXIS_TICK_SHARE, + values: { axis: 2, value: "−0.25", share: "40" }, + }); + }); + + it("fails closed when neither persisted evidence value is usable", () => { + expect( + leftoverMapAxisTickBadge(1, "0.25", Number.NaN, Number.POSITIVE_INFINITY), + ).toBeNull(); + expect(leftoverMapAxisTickBadge(1, "0.25", null, undefined)).toBeNull(); + }); +}); diff --git a/tests/test_leftover_axis_tick_share_contract.py b/tests/test_leftover_axis_tick_share_contract.py new file mode 100644 index 000000000..f6ddcf8d7 --- /dev/null +++ b/tests/test_leftover_axis_tick_share_contract.py @@ -0,0 +1,40 @@ +"""Executable contract for report leftover-axis tick share captions.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BADGE_SOURCE = ROOT / "frontend" / "src" / "leftoverMapAxisBadge.ts" +BADGE_TEST_SOURCE = ROOT / "frontend" / "src" / "leftoverMapAxisTickBadge.test.ts" + + +def test_report_axis_tick_keeps_axis_share_independent_of_singular() -> None: + """A report leftover-axis tick may name persisted share when singular evidence is absent.""" + assert BADGE_SOURCE.exists(), "report-axis badge helper is missing" + source = BADGE_SOURCE.read_text(encoding="utf-8") + + assert "leftoverMapAxisTickBadge" in source + assert "leftover axis {axis} tick {value} {share}%" in source + assert "leftover axis {axis} tick {value} σ {singular} {share}%" in source + + +def test_report_axis_tick_never_infers_share_from_singular() -> None: + """Share is formatted from its persisted field rather than inferred from σ.""" + assert BADGE_SOURCE.exists(), "report-axis badge helper is missing" + source = BADGE_SOURCE.read_text(encoding="utf-8") + tick_badge = source.split("export function leftoverMapAxisTickBadge", 1)[1] + + assert "formatLeftoverMapPlotAxisShare(leftoverShare)" in tick_badge + assert "formatLeftoverMapPlotAxisSingular(leftoverSingular)" in tick_badge + assert "Math.sqrt(" not in tick_badge + assert "Math.max(" not in tick_badge + + +def test_report_axis_tick_share_states_are_executable() -> None: + """Frontend tests execute combined, share-only, and invalid persisted-share states.""" + assert BADGE_TEST_SOURCE.exists(), "report-axis tick badge behavior test is missing" + test_source = BADGE_TEST_SOURCE.read_text(encoding="utf-8") + + assert 'leftoverMapAxisTickBadge(1, "0.25", 0.5, 0.4)' in test_source + assert 'leftoverMapAxisTickBadge(2, "−0.25", null, 0.4)' in test_source + assert "Number.POSITIVE_INFINITY" in test_source diff --git a/tests/test_leftover_axis_tick_singular_contract.py b/tests/test_leftover_axis_tick_singular_contract.py index e13c8f657..e806cb1d6 100644 --- a/tests/test_leftover_axis_tick_singular_contract.py +++ b/tests/test_leftover_axis_tick_singular_contract.py @@ -5,6 +5,7 @@ ROOT = Path(__file__).resolve().parents[1] BADGE_SOURCE = ROOT / "frontend" / "src" / "leftoverMapAxisBadge.ts" +BADGE_TEST_SOURCE = ROOT / "frontend" / "src" / "leftoverMapAxisTickBadge.test.ts" def test_report_leftover_axis_tick_keeps_persisted_singular_without_share() -> None: @@ -17,11 +18,22 @@ def test_report_leftover_axis_tick_keeps_persisted_singular_without_share() -> N def test_report_leftover_axis_tick_never_infers_sigma_or_share() -> None: - """Invalid σ falls back to an ordinary leftover-axis tick; share is never synthesized.""" + """Tick σ/share are delegated to their persisted-value formatters without derivation.""" assert BADGE_SOURCE.exists(), "report-axis badge helper is missing" source = BADGE_SOURCE.read_text(encoding="utf-8") + tick_badge = source.split("export function leftoverMapAxisTickBadge", 1)[1] - assert "formatLeftoverMapPlotAxisSingular" in source - assert "Math.sqrt" not in source - tail = source.split("leftoverMapAxisTickBadge", 1)[-1][:1600] - assert "leftover_share" not in tail + assert "formatLeftoverMapPlotAxisSingular(leftoverSingular)" in tick_badge + assert "formatLeftoverMapPlotAxisShare(leftoverShare)" in tick_badge + assert "Math.sqrt(" not in tick_badge + assert "Math.max(" not in tick_badge + + +def test_report_leftover_axis_tick_singular_states_are_executable() -> None: + """Frontend tests execute singular-only zero and fail-closed invalid evidence states.""" + assert BADGE_TEST_SOURCE.exists(), "report-axis tick badge behavior test is missing" + test_source = BADGE_TEST_SOURCE.read_text(encoding="utf-8") + + assert 'leftoverMapAxisTickBadge(1, "0.00", 0, null)' in test_source + assert 'singular: "0.00"' in test_source + assert "Number.NaN" in test_source