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.