diff --git a/frontend/src/components/LeftoverMapPlot.comparisonAccessibility.test.tsx b/frontend/src/components/LeftoverMapPlot.comparisonAccessibility.test.tsx new file mode 100644 index 000000000..941829f0c --- /dev/null +++ b/frontend/src/components/LeftoverMapPlot.comparisonAccessibility.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { LeftoverPair } from "../api"; +import { setLocale } from "../i18n"; +import { LeftoverMapPlot } from "./LeftoverMapPlot"; +import { LeftoverPairList } from "./LeftoverPairList"; + +const PAIR: LeftoverPair = { + pair_kind: "closest", + post_id: "post-demo-public", + post_title: "Public post", + criterion_code: "sales_lead_quality", + leftover_distance: 0.12, + leftover_residual: 0.4, + leftover_map_person_axis_1: 0.5, + leftover_map_person_axis_2: 0.1, + leftover_map_item_axis_1: 0.5, + leftover_map_item_axis_2: -0.02, +}; + +const PAIR_WITHOUT_PERSON_COORDINATES: LeftoverPair = { + ...PAIR, + leftover_map_person_axis_1: null, +}; + +describe("LeftoverMapPlot comparison post accessibility", () => { + afterEach(() => setLocale("en")); + + it("composes the comparison marker name from existing localized copy", () => { + setLocale("ko"); + render( + "sales-lead"} + onSelectPost={vi.fn()} + variant="comparison" + />, + ); + + expect( + screen.getByRole("button", { + name: "잔여 지도 비교 그림: 잔여 지도 글 Public post 열기 (ξ (+0.50, +0.10))", + }), + ).toBeInTheDocument(); + }); + + it("does not invent a post marker when persisted person coordinates are unavailable", () => { + const { container } = render( + "sales-lead"} + onSelectPost={vi.fn()} + variant="comparison" + />, + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it("keeps the pair-button open action when missing coordinates make the plot non-plottable", () => { + render( + "sales-lead"} + onSelectPost={vi.fn()} + />, + ); + + expect(screen.queryByLabelText("Leftover-map graphic display")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Open leftover closest pair: Public post · sales-lead", + }), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/LeftoverMapPlot.tsx b/frontend/src/components/LeftoverMapPlot.tsx index 7055679ea..fc3d9b06e 100644 --- a/frontend/src/components/LeftoverMapPlot.tsx +++ b/frontend/src/components/LeftoverMapPlot.tsx @@ -42,6 +42,7 @@ import { firstPlottablePairForPost, layoutLeftoverMapPlot, leftoverMapComparePlotCriterionBadge, + leftoverMapComparePlotPostBadge, leftoverMapPlotCriterionBadge, LEFTOVER_MAP_COMPARE_PLOT_CAPTION, LEFTOVER_MAP_COMPARE_PLOT_LABEL, @@ -148,12 +149,31 @@ function leftoverMapPlotCriterionText( return `${t("Criterion ζ")} ${badge.values.label} · ζ ${badge.values.item}`; } +function leftoverMapPlotPostText( + marker: { label: string; axis1: number; axis2: number }, + variant: LeftoverMapPlotVariant, +): string { + if (variant === "comparison") { + const badge = leftoverMapComparePlotPostBadge(marker.label, marker.axis1, marker.axis2); + if (badge === null) { + return `${t(LEFTOVER_MAP_COMPARE_PLOT_LABEL)}: ${t("Post ξ")} ${marker.label}`; + } + return `${t(LEFTOVER_MAP_COMPARE_PLOT_LABEL)}: ${tf(LEFTOVER_MAP_PLOT_POST_ACTION, badge.values)}`; + } + const person = formatLeftoverMapCoordinatePair(marker.axis1, marker.axis2) ?? ""; + return tf(LEFTOVER_MAP_PLOT_POST_ACTION, { + title: marker.label, + person, + }); +} + /** * Gabriel leftover-map graphic display of persisted ``ξ_{1:2}`` / ``ζ_{1:2}``. * * Person markers are posts; item markers are leftover criteria. Report and * comparison criterion markers keep distinct accessible names while composing - * the already-localized criterion label with persisted ζ. + * the already-localized criterion label with persisted ζ. Comparison post + * markers name only persisted finite person axes with distinct accessible copy. * Click a post marker to open that post. Caption leftover-map axes with persisted * Gabriel inertia share when finite, including rank-0 zero-share axes. * Report and comparison graphic axes additionally name finite, non-negative @@ -605,34 +625,28 @@ export function LeftoverMapPlot({ ))} - {layout.persons.map((marker) => { - const person = formatLeftoverMapCoordinatePair(marker.axis1, marker.axis2) ?? ""; - return ( - openPost(marker.id)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - openPost(marker.id); - } - }} - > - - - - {marker.label} - - - ); - })} + {layout.persons.map((marker) => ( + openPost(marker.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openPost(marker.id); + } + }} + > + + + + {marker.label} + + + ))} diff --git a/frontend/src/leftoverMapPlotLayout.ts b/frontend/src/leftoverMapPlotLayout.ts index 2810b2303..f70bb98d1 100644 --- a/frontend/src/leftoverMapPlotLayout.ts +++ b/frontend/src/leftoverMapPlotLayout.ts @@ -67,6 +67,30 @@ export function leftoverMapComparePlotCriterionBadge( return { key: LEFTOVER_MAP_COMPARE_PLOT_CRITERION, values: { label, item } }; } +export const LEFTOVER_MAP_PLOT_POST_ACTION_OMITTED = + "Open leftover-map post {title}"; + +export const LEFTOVER_MAP_COMPARE_PLOT_POST_ACTION = + "Open leftover map comparison graphic leftover-map post {title} at ξ {person}"; + +export type LeftoverMapPlotPostBadge = { + key: string; + values: { title: string; person: string }; +}; + +/** Name only finite persisted comparison-post person coordinates; never infer ξ from item coordinates. */ +export function leftoverMapComparePlotPostBadge( + title: string, + axis1: number | null | undefined, + axis2: number | null | undefined, +): LeftoverMapPlotPostBadge | null { + const person = formatLeftoverMapCoordinatePair(axis1, axis2); + if (person === null) { + return null; + } + return { key: LEFTOVER_MAP_COMPARE_PLOT_POST_ACTION, values: { title, person } }; +} + export const LEFTOVER_MAP_PLOT_TICK = "leftover-map axis {axis} tick {value}"; diff --git a/tests/test_grouping_comparison_graphic_post_person_coordinate_contract.py b/tests/test_grouping_comparison_graphic_post_person_coordinate_contract.py new file mode 100644 index 000000000..1787cc74a --- /dev/null +++ b/tests/test_grouping_comparison_graphic_post_person_coordinate_contract.py @@ -0,0 +1,49 @@ +"""Executable contract for comparison-graphic post person-coordinate captions.""" + +from pathlib import Path +import re + + +ROOT = Path(__file__).resolve().parents[1] +LAYOUT_SOURCE = ROOT / "frontend" / "src" / "leftoverMapPlotLayout.ts" +PLOT_SOURCE = ROOT / "frontend" / "src" / "components" / "LeftoverMapPlot.tsx" + + +def test_comparison_graphic_post_names_persisted_person_coordinates() -> None: + """Comparison post markers use distinct ξ copy without changing report markers.""" + layout_source = LAYOUT_SOURCE.read_text(encoding="utf-8") + plot_source = PLOT_SOURCE.read_text(encoding="utf-8") + + assert ( + 'LEFTOVER_MAP_COMPARE_PLOT_POST_ACTION =\n' + ' "Open leftover map comparison graphic leftover-map post {title} at ξ {person}";' + in layout_source + ) + assert "leftoverMapComparePlotPostBadge" in layout_source + assert "leftoverMapComparePlotPostBadge" in plot_source + assert "LEFTOVER_MAP_PLOT_POST_ACTION" in plot_source + assert re.search( + r'variant\s*===\s*"comparison".*?leftoverMapComparePlotPostBadge.*?LEFTOVER_MAP_PLOT_POST_ACTION', + plot_source, + re.DOTALL, + ) + + +def test_comparison_post_badge_consumes_person_axes_fail_closed_without_item_inference() -> None: + """Comparison ξ comes only from finite persisted person axes; zero remains explicit.""" + layout_source = LAYOUT_SOURCE.read_text(encoding="utf-8") + helper = re.search( + r"export function leftoverMapComparePlotPostBadge\(.*?\n}\n", + layout_source, + re.DOTALL, + ) + assert helper is not None + helper_source = helper.group(0) + + assert "formatLeftoverMapCoordinatePair(axis1, axis2)" in helper_source + assert "person === null" in helper_source + assert "return null" in helper_source + assert "LEFTOVER_MAP_COMPARE_PLOT_POST_ACTION" in helper_source + assert "leftover_map_item_axis" not in helper_source + assert "Math.sqrt" not in helper_source + assert "Math.abs" not in helper_source