mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-08-02 21:08:21 +00:00
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>
475 lines
14 KiB
TypeScript
475 lines
14 KiB
TypeScript
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");
|
||
});
|
||
});
|