assessment-model/src/lib/reporting/model.test.ts
Khalim Conn-Kowlessar 202683cd3f fix(reporting): address retrofit report PDF feedback
Six fixes from feedback on the report PDF (portfolio 849):

- Pluralise the "cars off the road" analogy ("1 car", not "1 cars").
- Keep the analogy in the headline only — drop it from the ledger's
  Carbon-saved row and the "Homes below EPC C" tile, where a carbon
  proxy sat on a homes-count figure.
- Derive the SAP delta from the rounded endpoints shown, so 67 -> 71
  reads +4 not +3 (fixed on the PDF and the on-screen KPI band).
- Format carbon to one decimal everywhere via a shared formatTonnes
  helper, so one saved figure stops rendering as 2 t / 2.2 t / 2 t
  across headline, card and ledger.
- Label the EPC-distribution count column "Homes".
- Print a one-line methodology for the value-for-money ratios (the
  on-screen InfoDot buttons are hidden in print).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 13:43:45 +00:00

475 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from "vitest";
import {
amountSaved,
buildHeadline,
capitalOutlay,
carsOffTheRoad,
classifyBandMovement,
computeLedger,
costPerCarbonSaved,
costPerSapPoint,
deriveLedgerView,
formatTonnes,
isCompliantBeyondWindow,
pickBestIndex,
selectGoalCallout,
shapeKpiDelta,
toBandCounts,
TONNES_CO2_PER_CAR_PER_YEAR,
type ScenarioOverlay,
} from "./model";
describe("computeLedger", () => {
it("computes all-in gross and net from scenario aggregates", () => {
// CONTEXT.md: gross = construction + project delivery + contingency;
// net = gross funding. Delivery is the disclosed 30% assumption.
expect(
computeLedger({
constructionCost: 1_000_000,
contingency: 100_000,
funding: 200_000,
homesUpgraded: 100,
}),
).toEqual({
constructionCost: 1_000_000,
projectDelivery: 300_000,
contingency: 100_000,
funding: 200_000,
grossCost: 1_400_000,
netCost: 1_200_000,
grossPerHome: 14_000,
});
});
it("reports zero gross-per-home when nothing is upgraded, not a division blow-up", () => {
const ledger = computeLedger({
constructionCost: 0,
contingency: 0,
funding: 0,
homesUpgraded: 0,
});
expect(ledger.grossPerHome).toBe(0);
expect(ledger.grossCost).toBe(0);
expect(ledger.netCost).toBe(0);
});
it("lets funding exceed gross — a surplus reads as negative net, never clamped", () => {
const ledger = computeLedger({
constructionCost: 100_000,
contingency: 0,
funding: 200_000,
homesUpgraded: 10,
});
expect(ledger.grossCost).toBe(130_000);
expect(ledger.netCost).toBe(-70_000);
});
});
describe("value-for-money helpers", () => {
it("amountSaved treats nulls as zero", () => {
expect(amountSaved(100, 60)).toBe(40);
expect(amountSaved(null, 60)).toBe(-60);
expect(amountSaved(100, null)).toBe(100);
expect(amountSaved(null, null)).toBe(0);
});
it("capitalOutlay is construction + project delivery only", () => {
expect(capitalOutlay({ construction_cost: 1000, pc_cost: 300 })).toBe(1300);
});
it("costPerSapPoint is null when there was no positive uplift", () => {
expect(costPerSapPoint(1300, 10)).toBe(130);
expect(costPerSapPoint(1300, 0)).toBeNull();
expect(costPerSapPoint(1300, -5)).toBeNull();
});
it("costPerCarbonSaved is null when nothing was saved", () => {
expect(costPerCarbonSaved(1300, 13)).toBe(100);
expect(costPerCarbonSaved(1300, 0)).toBeNull();
expect(costPerCarbonSaved(1300, -2)).toBeNull();
});
});
describe("deriveLedgerView", () => {
const overlay: ScenarioOverlay = {
avg_sap: "72.0",
avg_carbon: 2,
avg_bills: 900,
total_carbon: 200,
total_bills: 90000,
n_units: 100,
n_units_upgraded: 50,
construction_cost: 100000,
contingency: 10000,
total_funding: 40000,
gross_cost: 140000,
net_cost: 100000,
total_sap_uplift: 500,
gross_per_unit: 2800,
scenario_epc_counts: { C: 100 },
pc_cost: 30000,
};
it("derives savings, per-home and value-for-money figures", () => {
const v = deriveLedgerView(overlay, {
totalCarbon: 260,
totalBills: 120000,
});
// capital = construction + project delivery = 130000
expect(v.carbonSaved).toBe(60); // 260 - 200
expect(v.billSavings).toBe(30000); // 120000 - 90000
expect(v.billSavingsPerHome).toBe(600); // 30000 / 50
expect(v.costPerSap).toBe(260); // 130000 / 500
expect(v.costPerCarbon).toBe(130000 / 60);
// passthrough ledger figures
expect(v.grossCost).toBe(140000);
expect(v.netCost).toBe(100000);
expect(v.grossPerHome).toBe(2800);
});
it("guards divide-by-zero: not-applicable ratios read as 0, no homes → /1", () => {
const v = deriveLedgerView(
{ ...overlay, n_units_upgraded: 0, total_sap_uplift: 0 },
{ totalCarbon: 200, totalBills: 90000 },
);
expect(v.billSavingsPerHome).toBe(0); // billSaved 0 / 1
expect(v.costPerSap).toBe(0); // no uplift
expect(v.costPerCarbon).toBe(0); // nothing saved
});
});
describe("isCompliantBeyondWindow", () => {
const window = { band: "C", date: new Date("2030-01-01") } as const;
it("skips a lodged home at the window band whose certificate outlives the window", () => {
expect(
isCompliantBeyondWindow(
{
provenance: "lodged",
lodgedBand: "C",
expiryDate: new Date("2031-06-15"),
},
window,
),
).toBe(true);
});
it("keeps a home below the window band even with a long-lived certificate", () => {
expect(
isCompliantBeyondWindow(
{
provenance: "lodged",
lodgedBand: "D",
expiryDate: new Date("2033-01-01"),
},
window,
),
).toBe(false);
});
it("never skips a predicted home — mirrored lodged estimates are not a certificate", () => {
// CONTEXT.md (EPC provenance): predicted homes carry lodged_* columns
// with mirrored estimates, so band + expiry alone must not qualify them.
expect(
isCompliantBeyondWindow(
{
provenance: "predicted",
lodgedBand: "B",
expiryDate: new Date("2035-01-01"),
},
window,
),
).toBe(false);
});
it("keeps a home whose certificate expires exactly on the window date", () => {
expect(
isCompliantBeyondWindow(
{
provenance: "lodged",
lodgedBand: "B",
expiryDate: new Date("2030-01-01"),
},
window,
),
).toBe(false);
});
it("keeps a lodged home with no expiry on record", () => {
expect(
isCompliantBeyondWindow(
{ provenance: "lodged", lodgedBand: "B", expiryDate: null },
window,
),
).toBe(false);
});
it("skips at better-than-window bands, using a custom window", () => {
expect(
isCompliantBeyondWindow(
{
provenance: "lodged",
lodgedBand: "A",
expiryDate: new Date("2032-07-01"),
},
{ band: "B", date: new Date("2031-12-31") },
),
).toBe(true);
});
});
describe("classifyBandMovement", () => {
// CONTEXT.md: Likely downgrade / Likely upgrade are band-movement signals
// between Lodged and Effective. SAP drift within a band never qualifies —
// structurally so: the classifier only sees bands.
it("flags a likely downgrade when the certificate flatters the home", () => {
expect(
classifyBandMovement({
provenance: "lodged",
lodgedBand: "C",
effectiveBand: "D",
}),
).toBe("likely-downgrade");
});
it("flags a likely upgrade when the modelled picture beats the certificate", () => {
expect(
classifyBandMovement({
provenance: "lodged",
lodgedBand: "D",
effectiveBand: "C",
}),
).toBe("likely-upgrade");
});
it("reports no movement when lodged and effective agree on the band", () => {
expect(
classifyBandMovement({
provenance: "lodged",
lodgedBand: "D",
effectiveBand: "D",
}),
).toBe("none");
});
it("never signals a predicted home — no certificate, nothing to re-survey against", () => {
expect(
classifyBandMovement({
provenance: "predicted",
lodgedBand: "D",
effectiveBand: "C",
}),
).toBe("none");
});
it("stays silent when either band is missing", () => {
expect(
classifyBandMovement({
provenance: "lodged",
lodgedBand: null,
effectiveBand: "C",
}),
).toBe("none");
expect(
classifyBandMovement({
provenance: "lodged",
lodgedBand: "C",
effectiveBand: null,
}),
).toBe("none");
});
});
describe("pickBestIndex", () => {
// Compare view (Screen D): mark the best value in each row without
// crowning an overall winner. Nulls (baseline / missing) never win.
it("picks the lowest for a lower-is-better row", () => {
expect(pickBestIndex([null, 50, 74, 103], "lower")).toBe(1);
});
it("picks the highest for a higher-is-better row", () => {
expect(pickBestIndex([null, 312, 368, 219], "higher")).toBe(2);
});
it("returns null when every comparable value is missing", () => {
expect(pickBestIndex([null, null], "lower")).toBeNull();
});
it("ignores nulls rather than treating them as zero", () => {
expect(pickBestIndex([null, null, 5], "lower")).toBe(2);
});
});
describe("toBandCounts", () => {
it("totals actual + estimated per band into a plain record", () => {
expect(
toBandCounts([
{ epc: "C", actual: 68, estimated: 12 },
{ epc: "D", actual: 104, estimated: 24 },
{ epc: "Unknown", actual: 6, estimated: 0 },
]),
).toEqual({ C: 80, D: 128, Unknown: 6 });
});
});
describe("selectGoalCallout", () => {
// Grilling decision: current stock follows portfolio.goal; the scenario
// view follows the Scenario's goal. EPC goals at portfolio level default
// to band C (the portfolio carries no band value).
const bandCounts = { B: 14, C: 92, D: 128, E: 64, F: 18, G: 8, Unknown: 6 };
it("gives an EPC-goal portfolio its homes-below-band count on current stock", () => {
expect(
selectGoalCallout({
view: "current-stock",
goal: "Increasing EPC",
bandCounts,
}),
).toEqual({ kind: "below-band", band: "C", count: 218 });
});
it("gives a CO2-goal portfolio its dimension with no compliance framing", () => {
expect(
selectGoalCallout({
view: "current-stock",
goal: "Reducing CO2 emissions",
bandCounts,
}),
).toEqual({ kind: "dimension-total", dimension: "carbon" });
});
it("falls back to the sector's below-C lens when the portfolio goal is None", () => {
expect(
selectGoalCallout({ view: "current-stock", goal: "None", bandCounts }),
).toEqual({ kind: "below-band", band: "C", count: 218 });
});
it("shows below-band movement for an EPC scenario, at the scenario's own target band", () => {
expect(
selectGoalCallout({
view: "scenario",
goal: "Increasing EPC",
goalValue: "B",
bandCounts,
scenarioBandCounts: { B: 120, C: 150, D: 30, E: 12, Unknown: 6 },
}),
).toEqual({
kind: "below-band-movement",
band: "B",
before: 310, // C..G below B
after: 192,
});
});
it("shows dimension movement for a value-less scenario goal", () => {
expect(
selectGoalCallout({
view: "scenario",
goal: "Energy Savings",
goalValue: null,
bandCounts,
scenarioBandCounts: {},
}),
).toEqual({ kind: "dimension-movement", dimension: "energy" });
});
});
describe("carsOffTheRoad", () => {
it("converts annual tonnes saved into whole cars via the named factor", () => {
expect(carsOffTheRoad(312)).toBe(
Math.round(312 / TONNES_CO2_PER_CAR_PER_YEAR),
);
expect(Number.isInteger(carsOffTheRoad(312))).toBe(true);
});
it("reports zero cars for zero or negative savings", () => {
expect(carsOffTheRoad(0)).toBe(0);
expect(carsOffTheRoad(-5)).toBe(0);
});
});
describe("shapeKpiDelta", () => {
it("treats a drop as an improvement for lower-is-better metrics", () => {
expect(
shapeKpiDelta({ current: 1284, after: 941, improvesWhenLower: true }),
).toEqual({ delta: -343, improved: true });
});
it("treats a rise as an improvement for higher-is-better metrics", () => {
expect(
shapeKpiDelta({ current: 58, after: 71, improvesWhenLower: false }),
).toEqual({ delta: 13, improved: true });
});
it("reports no improvement when nothing moved", () => {
expect(
shapeKpiDelta({ current: 100, after: 100, improvesWhenLower: true }),
).toEqual({ delta: 0, improved: false });
});
});
describe("buildHeadline", () => {
it("tells an EPC scenario's story in one board-quotable sentence", () => {
expect(
buildHeadline({
goal: "Increasing EPC",
goalValue: "C",
homesUpgraded: 218,
netCost: 2_477_000,
carbonSavedPerYear: 312,
}),
).toBe(
"Reaching EPC C across 218 homes costs £2.48m net and cuts carbon by 312 tonnes a year — like taking 173 cars off the road.",
);
});
it("leads with carbon for a CO2 scenario", () => {
expect(
buildHeadline({
goal: "Reducing CO2 emissions",
goalValue: null,
homesUpgraded: 246,
netCost: 1_948_000,
carbonSavedPerYear: 368,
}),
).toBe(
"Cutting carbon by 368 tonnes a year across 246 homes costs £1.95m net — like taking 204 cars off the road.",
);
});
it("drops the car clause when no carbon is saved", () => {
expect(
buildHeadline({
goal: "Valuation Improvement",
goalValue: null,
homesUpgraded: 187,
netCost: 850_000,
carbonSavedPerYear: 0,
}),
).toBe("Improving valuation across 187 homes costs £850k net.");
});
it("uses singular 'car' and one-decimal tonnes for a small saving", () => {
expect(
buildHeadline({
goal: "Increasing EPC",
goalValue: "C",
homesUpgraded: 7,
netCost: 42_916,
carbonSavedPerYear: 2.2,
}),
).toBe(
"Reaching EPC C across 7 homes costs £43k net and cuts carbon by 2.2 tonnes a year — like taking 1 car off the road.",
);
});
});
describe("formatTonnes", () => {
it("keeps one decimal but strips a trailing .0", () => {
expect(formatTonnes(2.2)).toBe("2.2");
expect(formatTonnes(2)).toBe("2");
expect(formatTonnes(312)).toBe("312");
});
});