diff --git a/src/components/ComparisonDependencies.test.tsx b/src/components/ComparisonDependencies.test.tsx new file mode 100644 index 00000000..1d35eebc --- /dev/null +++ b/src/components/ComparisonDependencies.test.tsx @@ -0,0 +1,226 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import ComparisonDependencies from "./ComparisonDependencies"; +import type { DependencyRow } from "./DependenciesTable"; +import type { PathwayMetadataType } from "../types"; +import pathwayMetadataSchema from "../schema/pathwayMetadata.v2.json" with { type: "json" }; + +const dependency = (overrides: Partial = {}): DependencyRow => ({ + dependency_name: "Policy strategy", + dependency_description: "Relies on a national carbon price.", + sector: "Power", + evidence_type: "Qualitative", + ...overrides, +}); + +const pathway = ( + id: string, + dependencies: DependencyRow[], +): PathwayMetadataType => + ({ + id, + name: { full: `Pathway ${id}`, short: id }, + dependencies, + }) as unknown as PathwayMetadataType; + +const rowLabels = () => + screen + .getAllByText( + (_, el) => + el?.tagName === "P" && + el.className.includes("uppercase") && + el.className.includes("font-semibold"), + ) + .map((el) => el.textContent); + +describe("ComparisonDependencies", () => { + it("lists every dependency category from the schema, in schema order", () => { + render( + , + ); + + expect(rowLabels()).toEqual( + pathwayMetadataSchema.properties.dependencies.items.properties + .dependency_name.enum, + ); + }); + + it("shows each pathway's evidence and description under its category", () => { + render( + , + ); + + expect( + screen.getByText("Relies on a national carbon price."), + ).toBeInTheDocument(); + expect(screen.getByText("Qualitative")).toBeInTheDocument(); + expect(screen.getByText("Assumes CCUS at scale.")).toBeInTheDocument(); + expect(screen.getByText("Anecdotal")).toBeInTheDocument(); + }); + + it("says when a pathway records nothing under a category", () => { + render( + , + ); + + // Two columns, ten categories, one recorded in each column. + expect(screen.getAllByText("No information")).toHaveLength(18); + }); + + it("shows only the selected sector's dependencies", () => { + render( + , + ); + + expect(screen.getByText("Needs green hydrogen.")).toBeInTheDocument(); + expect(screen.getByText("Needs scrap supply.")).toBeInTheDocument(); + expect( + screen.queryByText("Relies on a national carbon price."), + ).not.toBeInTheDocument(); + }); + + it("stacks a category a pathway records more than once", () => { + render( + , + ); + + expect(screen.getByText("Also needs grid reform.")).toBeInTheDocument(); + expect( + screen.getAllByText("Relies on a national carbon price."), + ).toHaveLength(2); + }); + + it("explains a column with nothing for the sector once, leaving its cells blank", () => { + render( + , + ); + + expect( + screen.getAllByText( + "This pathway has no dependencies recorded for Power.", + ), + ).toHaveLength(1); + // Only column a's nine unrecorded categories say so; column b stays blank. + expect(screen.getAllByText("No information")).toHaveLength(9); + expect(rowLabels()).toHaveLength(10); + }); + + it("leaves out the column note when every column has something", () => { + render( + , + ); + + expect( + screen.queryByText(/has no dependencies recorded/), + ).not.toBeInTheDocument(); + }); + + it("shows one message instead of empty rows when no pathway has any", () => { + render( + , + ); + + expect( + screen.getByText("None of these pathways record dependencies for Steel."), + ).toBeInTheDocument(); + expect(screen.queryByText("Policy strategy")).not.toBeInTheDocument(); + }); + + it("handles pathways without a dependencies list", () => { + render( + , + ); + + expect( + screen.getByText("This pathway has no dependencies recorded for Power."), + ).toBeInTheDocument(); + }); + + it("explains an empty section when there is no sector to scope by", () => { + render( + , + ); + + expect( + screen.getByText( + "No dependencies are recorded for the sectors these pathways share.", + ), + ).toBeInTheDocument(); + }); + + it("renders nothing without pathways", () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/ComparisonDependencies.tsx b/src/components/ComparisonDependencies.tsx new file mode 100644 index 00000000..237eab88 --- /dev/null +++ b/src/components/ComparisonDependencies.tsx @@ -0,0 +1,153 @@ +import React from "react"; +import { PathwayMetadataType } from "../types"; +import Badge from "./Badge"; +import { DependencyRow, EvidenceBadge } from "./DependenciesTable"; + +/** + * Every dependency category, in schema order. Listed here rather than derived + * from the data so each row renders for every comparison, keeping the columns + * aligned; the test pins the rendered rows to the schema's enum. + */ +const DEPENDENCY_NAMES: readonly DependencyRow["dependency_name"][] = [ + "Policy strategy", + "Regulatory framework", + "Market and economics", + "Public acceptance", + "Consumer and client behavior", + "Infrastructure and logistics", + "Technology", + "Resource availability", + "Environmental impacts and ecosystem services", + "Labor availability", +]; + +interface ComparisonDependenciesProps { + pathways: PathwayMetadataType[]; + /** + * The sector from the scope header. Dependencies are recorded per sector, so + * this is what scopes them; null means no shared sector has anything to show. + * There is no geography prop: dependencies are not scoped by geography. + */ + sector: string | null; +} + +const EmptyState: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +
+

{children}

+
+); + +/** + * The comparison page's "Dependencies" section: one row per dependency + * category, labelled once across the columns like `ComparisonKeyFeatures`, with + * each pathway's evidence and description underneath. + * + * Every category renders, recorded or not, so the rows line up across columns. + * A pathway that records nothing for the selected sector gets one note at the + * top of its column instead of ten "No information" cells, which would read as + * ten separate gaps rather than one. + */ +const ComparisonDependencies: React.FC = ({ + pathways, + sector, +}) => { + const n = pathways.length; + if (n === 0) return null; + + if (sector === null) { + return ( + + No dependencies are recorded for the sectors these pathways share. + + ); + } + + const rowsByPathway = pathways.map((pathway) => + (pathway.dependencies ?? []).filter((d) => d.sector === sector), + ); + + if (rowsByPathway.every((rows) => rows.length === 0)) { + return ( + + {`None of these pathways record dependencies for ${sector}.`} + + ); + } + + const hasEmptyColumn = rowsByPathway.some((rows) => rows.length === 0); + + return ( +
+ {hasEmptyColumn && + pathways.map((pathway, idx) => ( +
+ {rowsByPathway[idx].length === 0 && ( +

+ {`This pathway has no dependencies recorded for ${sector}.`} +

+ )} +
+ ))} + + {DEPENDENCY_NAMES.map((name, rowIdx) => ( + + {/* Single label above all pathway cells */} +
+

+ {name} +

+
+ + {pathways.map((pathway, idx) => { + const rows = rowsByPathway[idx]; + const matches = rows.filter((d) => d.dependency_name === name); + return ( +
+ {/* Blank under the column note: the note already explains it. */} + {rows.length === 0 ? null : matches.length === 0 ? ( + No information + ) : ( + // The schema forbids only exact duplicates, so a pathway can + // record one category more than once; each one stacks. + matches.map((d, i) => ( +
0 ? "mt-3" : undefined} + > + +

+ {d.dependency_description} +

+
+ )) + )} +
+ ); + })} + + {/* Separator between rows, not after the last one */} + {rowIdx < DEPENDENCY_NAMES.length - 1 && ( +
+ )} + + ))} +
+ ); +}; + +export default ComparisonDependencies; diff --git a/src/components/DependenciesTable.tsx b/src/components/DependenciesTable.tsx index e5abb612..8309b6f8 100644 --- a/src/components/DependenciesTable.tsx +++ b/src/components/DependenciesTable.tsx @@ -5,7 +5,7 @@ import ScopeFilterNotice from "./ScopeFilterNotice"; // One conditions-of-outcome row. Derived from the schema type so it tracks the // metadata contract. Dependencies are descriptive and deliberately not part of // the #869 inheritance chain, so they are not scoped by geography. -type DependencyRow = PathwayMetadataType["dependencies"][number]; +export type DependencyRow = PathwayMetadataType["dependencies"][number]; interface DependenciesTableProps { dependencies: PathwayMetadataType["dependencies"]; @@ -29,9 +29,11 @@ const EVIDENCE_STYLES: Record = { "No evidence": "bg-neutral-100 text-rmigray-600 border-neutral-300", }; -const EvidenceBadge: React.FC<{ value: DependencyRow["evidence_type"] }> = ({ - value, -}) => ( +// Exported for the comparison page's dependencies section, so both views share +// one evidence colour scale. +export const EvidenceBadge: React.FC<{ + value: DependencyRow["evidence_type"]; +}> = ({ value }) => ( diff --git a/src/pages/ComparisonPage.test.tsx b/src/pages/ComparisonPage.test.tsx index 3494ed2e..a83b9b9c 100644 --- a/src/pages/ComparisonPage.test.tsx +++ b/src/pages/ComparisonPage.test.tsx @@ -102,6 +102,37 @@ const fixtures = [ geography: { global: true, regions: {}, country: [] }, keyFeatures: {}, }, + { + // Records dependencies but has no benchmark data, and shares Steel with B + // where neither has data: Steel is selectable for its dependencies alone. + id: "cmp-e", + name: { full: "Comparison Pathway E", short: "E" }, + description: "Pathway E description", + pathwayType: "Net Zero", + publication: { + publisher: { full: "Publisher E", short: "PubE" }, + title: { full: "Publication E", short: "PubTitleE" }, + year: 2024, + }, + sectors: [{ name: "Power" }, { name: "Steel" }], + metric: ["Capacity"], + geography: { global: true, regions: {}, country: [] }, + keyFeatures: {}, + dependencies: [ + { + dependency_name: "Policy strategy", + dependency_description: "Relies on a national carbon price.", + sector: "Power", + evidence_type: "Qualitative", + }, + { + dependency_name: "Technology", + dependency_description: "Needs green hydrogen.", + sector: "Steel", + evidence_type: "Anecdotal", + }, + ], + }, ] as const; /* @@ -377,6 +408,66 @@ describe("ComparisonPage — shared scope", () => { }); }); +describe("ComparisonPage — dependencies", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("places the section between Key Features and Geographies", async () => { + await mountWithFixtures("cmp-b,cmp-e"); + await screen.findByText("PubE: Comparison Pathway E", undefined, WAIT); + + const keyFeatures = screen.getByRole("heading", { name: "Policies" }); + const dependencies = screen.getByRole("heading", { name: /^Dependencies/ }); + const geographies = screen.getByRole("heading", { name: /Geographies/ }); + + expect( + keyFeatures.compareDocumentPosition(dependencies) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + dependencies.compareDocumentPosition(geographies) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it("shows the selected sector's dependencies, with a note for a column that has none", async () => { + await mountWithFixtures("cmp-b,cmp-e§or=Power"); + await screen.findByText("PubE: Comparison Pathway E", undefined, WAIT); + + expect( + screen.getByText("Relies on a national carbon price."), + ).toBeInTheDocument(); + expect(screen.queryByText("Needs green hydrogen.")).not.toBeInTheDocument(); + expect( + screen.getByText("This pathway has no dependencies recorded for Power."), + ).toBeInTheDocument(); + }); + + it("follows the sector selection, including a sector with dependencies but no plot data", async () => { + await mountWithFixtures("cmp-b,cmp-e§or=Power"); + await screen.findByText("PubE: Comparison Pathway E", undefined, WAIT); + + // Steel has no benchmark data in either column; E's dependencies alone + // make it a selectable sector rather than an outlined one. + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Steel" })); + + expect(screen.getByText("Needs green hydrogen.")).toBeInTheDocument(); + expect( + screen.queryByText("Relies on a national carbon price."), + ).not.toBeInTheDocument(); + expect( + screen.getByText("This pathway has no dependencies recorded for Steel."), + ).toBeInTheDocument(); + }); +}); + describe("ComparisonPage — region geography tooltips", () => { beforeEach(() => { sessionStorage.clear(); diff --git a/src/pages/ComparisonPage.tsx b/src/pages/ComparisonPage.tsx index 67c05f1d..17572e70 100644 --- a/src/pages/ComparisonPage.tsx +++ b/src/pages/ComparisonPage.tsx @@ -32,6 +32,7 @@ import BadgeArray, { BadgeVariant } from "../components/BadgeArray"; import getTemperatureColor from "../utils/getTemperatureColor"; import { getSectorTooltip, getMetricTooltip } from "../utils/tooltipUtils"; import ComparisonKeyFeatures from "../components/ComparisonKeyFeatures"; +import ComparisonDependencies from "../components/ComparisonDependencies"; import ComparisonPlots, { ComparisonPlotsEntry, } from "../components/ComparisonPlots"; @@ -44,6 +45,7 @@ import { SECTOR_AVAILABILITY_TOOLTIP, METRIC_AVAILABILITY_TOOLTIP, } from "../utils/timeseriesAvailability"; +import { scopeAvailability } from "../utils/scopeAvailability"; import TextWithTooltip from "../components/TextWithTooltip"; // ── Pathway summary card (compact) ────────────────────────────────────────── @@ -272,11 +274,22 @@ const ComparisonPage: React.FC = () => { */ const sectorOptions = useMemo(() => sharedSectors(pathways), [pathways]); - // A shared sector is worth selecting when any column has data for it: the - // plots are the only thing it scopes here (#1017). + /* + A shared sector is worth selecting when any column has something to show + for it: benchmark data for the plots, or recorded dependencies (#1017, + #1030). The same rule the detail page's ribbon uses. The coverage sections + below keep the timeseries-only `availabilities`, since their outlined + badges speak about data in this tool. + */ + const sectorAvailabilities = useMemo( + () => + pathways.map((p) => scopeAvailability(p, index.byPathway[p.id] ?? [])), + [pathways], + ); + const isSectorAvailable = useCallback( - (sector: string) => availabilities.some((a) => a.hasSector(sector)), - [availabilities], + (sector: string) => sectorAvailabilities.some((a) => a.hasSector(sector)), + [sectorAvailabilities], ); const [sector, setSector] = useUrlParamState({ @@ -472,7 +485,7 @@ const ComparisonPage: React.FC = () => { } /* - The five comparison sections, as nodes rather than inline JSX, so their + The six comparison sections, as nodes rather than inline JSX, so their order can be a data decision below. */ const sectionNodes: Record = { @@ -496,6 +509,38 @@ const ComparisonPage: React.FC = () => {
), + dependencies: ( + <> + {/* ── Dependencies ── */} +
+ + + Dependencies + + } + tooltip="Shown for the sector selected above. Dependencies are not specific to a geography." + ariaLabel="Dependencies information" + position="right" + /> + + +
+ +
+
+ + ), geographies: ( <> {/* ── Geographies ── */} @@ -642,6 +687,7 @@ const ComparisonPage: React.FC = () => { const sectionOrder = [ "plots", "keyFeatures", + "dependencies", "geographies", "sectors", "metrics",