From fa000c92ab1957eda0ba7575716f9ff99a1aa0eb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 21 Apr 2026 13:23:21 -0400 Subject: [PATCH] Add always-visible version badge on simulation result pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements PolicyEngine/policyengine-app#2831 — the plain version- identification surface (distinct from the TRACE cite-this-result flow in #2830, which is blocked on api backend work). - Adds `src/layout/VersionBadge.jsx`: small monospace strip showing `policyengine-@ · ` with a click-to-expand popover listing the full identifiers and cross-linking to #2830 for the forthcoming TRO citation flow. - Wires the badge into policy output (`src/pages/policy/output/Display.jsx`, inline with the existing bottom text) and household output (`src/pages/household/output/HouseholdOutput.jsx`, bottom-aligned after computed panes). - Accepts optional `dataVersion` and `h5Sha` props so that once the api v4 migration (PolicyEngine/policyengine-api#3486) exposes the policyengine--data version and calibrated h5 SHA, the badge can consume those fields without further component changes. - Five jest tests covering: country-package labeling for us/uk, dataset fallback when not set, truncated h5 sha display, renders-nothing when modelVersion missing, and correct composite output when all fields are passed. Based on feedback in the 2026-04-21 meeting with Lars Vilhuber and the TRACE team: Casper explicitly distinguished version identification (what every user needs) from TRACE certification (what reviewers of papers citing runs need). This issue is the former. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + src/__tests__/layout/VersionBadge.test.js | 59 ++++++++ src/layout/VersionBadge.jsx | 139 ++++++++++++++++++ .../household/output/HouseholdOutput.jsx | 22 +++ src/pages/policy/output/Display.jsx | 34 +++-- 5 files changed, 246 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/layout/VersionBadge.test.js create mode 100644 src/layout/VersionBadge.jsx diff --git a/.gitignore b/.gitignore index d4d9883ff..bc6e40f98 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ yarn-error.log* .idea venv/* +bun.lock diff --git a/src/__tests__/layout/VersionBadge.test.js b/src/__tests__/layout/VersionBadge.test.js new file mode 100644 index 000000000..85557c171 --- /dev/null +++ b/src/__tests__/layout/VersionBadge.test.js @@ -0,0 +1,59 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import VersionBadge from "../../layout/VersionBadge"; + +describe("VersionBadge", () => { + test("renders a compact strip with the country package version and dataset", () => { + render( + , + ); + expect( + screen.getByLabelText(/versions used for this result/i), + ).toHaveTextContent("policyengine-us@1.653.3 · enhanced_cps"); + }); + + test("renders nothing when modelVersion is missing", () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + test("maps country ids to package names", () => { + render( + , + ); + expect(screen.getByLabelText(/versions used/i)).toHaveTextContent( + "policyengine-uk@2.88.0", + ); + }); + + test("falls back to 'default' when dataset is not set", () => { + render(); + expect(screen.getByLabelText(/versions used/i)).toHaveTextContent( + "policyengine-us@1.653.3 · default", + ); + }); + + test("includes data version and truncated h5 sha when provided", () => { + render( + , + ); + const badge = screen.getByLabelText(/versions used/i); + expect(badge).toHaveTextContent("enhanced_cps@1.85.2"); + expect(badge).toHaveTextContent("sha256:abc12345"); + expect(badge).not.toHaveTextContent( + "fedcba1234567890fedcba1234567890fedcba1234567890abc", + ); + }); +}); diff --git a/src/layout/VersionBadge.jsx b/src/layout/VersionBadge.jsx new file mode 100644 index 000000000..e829d62ea --- /dev/null +++ b/src/layout/VersionBadge.jsx @@ -0,0 +1,139 @@ +import { useState } from "react"; +import { Popover } from "antd"; +import style from "../style"; + +/** + * Small always-visible strip that identifies which model version and + * dataset produced a simulation result. + * + * See PolicyEngine/policyengine-app#2831 for motivation. This is the + * plain version-identification surface — the TRACE-bound "Cite this + * result" download is scoped separately in #2830 and blocked on the + * api work in PolicyEngine/policyengine-api#3485. + * + * Today the api metadata endpoint only exposes the country-package + * version and the selectable dataset name. Once the api migrates to + * policyengine.py v4 (api#3486) and begins returning + * policyengine-{country}-data version and h5 content hash on every + * simulation response, this component should read those additional + * fields and expand the visible badge. + */ +export default function VersionBadge(props) { + const { + countryId, + modelVersion, + dataset, + dataVersion, + h5Sha, + compact = false, + } = props; + + const [popoverOpen, setPopoverOpen] = useState(false); + + if (!modelVersion) { + return null; + } + + const modelPackageName = + countryId === "us" + ? "policyengine-us" + : countryId === "uk" + ? "policyengine-uk" + : countryId === "canada" + ? "policyengine-canada" + : `policyengine-${countryId}`; + + // Human-readable dataset label. The api exposes canonical names like + // "enhanced_cps" or "cps" — surface them as-is so a reader who + // knows the pipeline can tell exactly what ran. + const datasetLabel = dataset || "default"; + + const compactStrip = ( + + {`${modelPackageName}@${modelVersion} · ${datasetLabel}`} + {dataVersion && `@${dataVersion}`} + {h5Sha && ` · h5 sha256:${h5Sha.substring(0, 8)}…`} + + ); + + const detailContent = ( +
+
+ Model package +
+ {modelPackageName}=={modelVersion} +
+
+
+ Dataset +
+ {datasetLabel} + {dataVersion ? `@${dataVersion}` : ""} +
+
+ {h5Sha && ( +
+ h5 content hash +
+ sha256:{h5Sha} +
+
+ )} +
+ These identify the pinned software and microdata that produced this + result. A citable TRO that binds them under a SHA-256 composition + fingerprint is coming — see issue{" "} + + policyengine-app#2830 + + . +
+
+ ); + + return ( + + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setPopoverOpen(!popoverOpen); + } + }} + style={{ cursor: "pointer" }} + > + {compactStrip} + + + ); +} diff --git a/src/pages/household/output/HouseholdOutput.jsx b/src/pages/household/output/HouseholdOutput.jsx index 8f9956edc..f6b4b6d48 100644 --- a/src/pages/household/output/HouseholdOutput.jsx +++ b/src/pages/household/output/HouseholdOutput.jsx @@ -10,6 +10,7 @@ import PoliciesModelledPopup from "../../../modals/PoliciesModelledPopup"; import React from "react"; import { message } from "antd"; import ResultActions from "layout/ResultActions"; +import VersionBadge from "../../../layout/VersionBadge"; export default function HouseholdOutput(props) { const [searchParams] = useSearchParams(); @@ -131,6 +132,9 @@ export default function HouseholdOutput(props) { const facebookLink = `https://www.facebook.com/sharer/sharer.php?u=${url}`; const linkedInLink = `https://www.linkedin.com/sharing/share-offsite/?url=${url}`; + const selectedVersion = urlParams.get("version") || metadata.version; + const dataset = urlParams.get("dataset"); + return ( {pane} + {householdBaseline && !loading && ( +
+ +
+ )}
); } diff --git a/src/pages/policy/output/Display.jsx b/src/pages/policy/output/Display.jsx index e5e278ded..deaeb834b 100644 --- a/src/pages/policy/output/Display.jsx +++ b/src/pages/policy/output/Display.jsx @@ -18,6 +18,7 @@ import PolicyBreakdown from "./PolicyBreakdown"; import { Helmet } from "react-helmet"; import useCountryId from "../../../hooks/useCountryId"; import BottomImpactDescription from "../../../layout/BottomImpactDescription"; +import VersionBadge from "../../../layout/VersionBadge"; import { Link } from "react-router-dom"; import MultiYearBudgetaryImpact from "./budget/MultiYearBudgetaryImpact"; @@ -352,19 +353,34 @@ export function LowLevelDisplay(props) { //eslint-disable-next-line const bottomElements = mobile & !embed ? null : ( -

- {bottomText} - {bottomLink && ( - - Learn more - - )} -

+

+ {bottomText} + {bottomLink && ( + + Learn more + + )} +

+ + ); // If ?embed=True, just show `pane`, full screen.