mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
#455 added three main_heating_system values — "Oil boiler, regular", "Oil boiler, combi" and "Solid fuel boiler" — without a matching needle. The two oil ones fell through every bucket to Unknown, which the vocabulary-coverage test caught: an overridden component that displays but cannot be filtered (ADR-0012). The gap is specific to these archetypes. Every other oil value carries its fuel *after* a comma ("Boiler and radiators, oil"), which the ", oil" needle matches; these lead with it. Bare "oil" is unusable as a needle because it is a substring of "b(oil)er", so the fix is the narrower "oil boiler" — which cannot collide with "solid fuel boiler", and Solid Fuel is checked first regardless. "Solid fuel boiler" already bucketed correctly. One edit fixes both halves: the portfolio query's SQL bucketing reads the same DESCRIPTOR_FILTER_CONFIG rather than restating the needles, so the classifier and the SQL cannot drift here. Also asserts these three by *bucket* rather than relying on the coverage test's "not Unknown" — the failure that matters is a wrong bucket, and a reordered needle list would silently make "Oil boiler, combi" a Gas Boiler while the coverage test kept passing.
535 lines
18 KiB
TypeScript
535 lines
18 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
resolveBuiltForm,
|
||
resolveConstructionAge,
|
||
resolveMainFuel,
|
||
resolveOverrideDescriptor,
|
||
resolvePropertyType,
|
||
} from "./descriptorResolution";
|
||
import {
|
||
MAINFUEL_OPTIONS,
|
||
RDSAP_MAIN_FUEL_CODE_LABELS,
|
||
EPR_MAIN_FUEL_STRING_LABELS,
|
||
WALL_TYPE_OPTIONS,
|
||
ROOF_TYPE_OPTIONS,
|
||
HEATING_SYSTEM_OPTIONS,
|
||
DESCRIPTOR_FILTER_CONFIG,
|
||
classifyDescriptor,
|
||
} from "@/app/utils/propertyFilters";
|
||
import {
|
||
MainFuelValues,
|
||
WallTypeValues,
|
||
RoofTypeValues,
|
||
MainHeatingSystemValues,
|
||
} from "@/app/db/schema/landlord_overrides";
|
||
|
||
describe("resolvePropertyType", () => {
|
||
it("prefers a landlord override over the EPC-derived value", () => {
|
||
expect(
|
||
resolvePropertyType({
|
||
override: "Maisonette",
|
||
isNewApproach: true,
|
||
lodgedPropertyType: "2", // EPC code for Flat
|
||
lodgedDwellingType: null,
|
||
predictedPropertyType: null,
|
||
predictedDwellingType: null,
|
||
legacyPropertyType: null,
|
||
}),
|
||
).toBe("Maisonette");
|
||
});
|
||
|
||
it("maps an RdSAP property-type code to its label when there is no override", () => {
|
||
expect(
|
||
resolvePropertyType({
|
||
override: null,
|
||
isNewApproach: true,
|
||
lodgedPropertyType: "0", // RdSAP code for House
|
||
lodgedDwellingType: null,
|
||
predictedPropertyType: null,
|
||
predictedDwellingType: null,
|
||
legacyPropertyType: null,
|
||
}),
|
||
).toBe("House");
|
||
});
|
||
|
||
it("falls back to the predicted EPC when there is no lodged value", () => {
|
||
expect(
|
||
resolvePropertyType({
|
||
override: null,
|
||
isNewApproach: true,
|
||
lodgedPropertyType: null,
|
||
lodgedDwellingType: null,
|
||
predictedPropertyType: "2", // RdSAP code for Flat
|
||
predictedDwellingType: null,
|
||
legacyPropertyType: null,
|
||
}),
|
||
).toBe("Flat");
|
||
});
|
||
|
||
it("prefers the lodged EPC over the predicted EPC when both exist", () => {
|
||
expect(
|
||
resolvePropertyType({
|
||
override: null,
|
||
isNewApproach: true,
|
||
lodgedPropertyType: "0", // House
|
||
lodgedDwellingType: null,
|
||
predictedPropertyType: "2", // Flat
|
||
predictedDwellingType: null,
|
||
legacyPropertyType: null,
|
||
}),
|
||
).toBe("House");
|
||
});
|
||
|
||
it("falls back to dwelling_type when the lodged property_type is absent", () => {
|
||
expect(
|
||
resolvePropertyType({
|
||
override: null,
|
||
isNewApproach: true,
|
||
lodgedPropertyType: null,
|
||
lodgedDwellingType: "Flat",
|
||
predictedPropertyType: null,
|
||
predictedDwellingType: null,
|
||
legacyPropertyType: null,
|
||
}),
|
||
).toBe("Flat");
|
||
});
|
||
|
||
it("reads the legacy property-row value for a legacy property", () => {
|
||
expect(
|
||
resolvePropertyType({
|
||
override: null,
|
||
isNewApproach: false,
|
||
lodgedPropertyType: "0", // must be ignored for legacy
|
||
lodgedDwellingType: null,
|
||
predictedPropertyType: null,
|
||
predictedDwellingType: null,
|
||
legacyPropertyType: "Bungalow",
|
||
}),
|
||
).toBe("Bungalow");
|
||
});
|
||
|
||
it("never reads the EPC graph nor the legacy row across the cutoff (new-approach ignores the legacy row)", () => {
|
||
expect(
|
||
resolvePropertyType({
|
||
override: null,
|
||
isNewApproach: true,
|
||
lodgedPropertyType: null,
|
||
lodgedDwellingType: null,
|
||
predictedPropertyType: null,
|
||
predictedDwellingType: null,
|
||
legacyPropertyType: "Bungalow", // legacy row must NOT surface for a new-approach property
|
||
}),
|
||
).toBe("Unknown");
|
||
});
|
||
|
||
// Spec locks for decisions taken in ADR-0012 — an override always wins (even an
|
||
// explicit "Unknown"), and an unrecognised RdSAP code is passed through as-is.
|
||
it("lets an explicit 'Unknown' override win over a known EPC value", () => {
|
||
expect(
|
||
resolvePropertyType({
|
||
override: "Unknown",
|
||
isNewApproach: true,
|
||
lodgedPropertyType: "0", // EPC knows "House" — the override still wins
|
||
lodgedDwellingType: null,
|
||
predictedPropertyType: null,
|
||
predictedDwellingType: null,
|
||
legacyPropertyType: null,
|
||
}),
|
||
).toBe("Unknown");
|
||
});
|
||
|
||
it("passes an unrecognised RdSAP code through unchanged", () => {
|
||
expect(
|
||
resolvePropertyType({
|
||
override: null,
|
||
isNewApproach: true,
|
||
lodgedPropertyType: "9", // not in PROPERTY_TYPE_LABELS
|
||
lodgedDwellingType: null,
|
||
predictedPropertyType: null,
|
||
predictedDwellingType: null,
|
||
legacyPropertyType: null,
|
||
}),
|
||
).toBe("9");
|
||
});
|
||
});
|
||
|
||
describe("resolveBuiltForm", () => {
|
||
it("prefers a landlord override over the EPC-derived value", () => {
|
||
expect(
|
||
resolveBuiltForm({
|
||
override: "Detached",
|
||
isNewApproach: true,
|
||
lodgedBuiltForm: "4", // EPC code for Mid-Terrace
|
||
predictedBuiltForm: null,
|
||
legacyBuiltForm: null,
|
||
}),
|
||
).toBe("Detached");
|
||
});
|
||
|
||
it("maps an RdSAP built-form code to its label when there is no override", () => {
|
||
expect(
|
||
resolveBuiltForm({
|
||
override: null,
|
||
isNewApproach: true,
|
||
lodgedBuiltForm: "2", // RdSAP code for Semi-Detached
|
||
predictedBuiltForm: null,
|
||
legacyBuiltForm: null,
|
||
}),
|
||
).toBe("Semi-Detached");
|
||
});
|
||
|
||
it("falls back to the predicted EPC when there is no lodged value", () => {
|
||
expect(
|
||
resolveBuiltForm({
|
||
override: null,
|
||
isNewApproach: true,
|
||
lodgedBuiltForm: null,
|
||
predictedBuiltForm: "1", // Detached
|
||
legacyBuiltForm: null,
|
||
}),
|
||
).toBe("Detached");
|
||
});
|
||
|
||
it("reads the legacy property-row value for a legacy property", () => {
|
||
expect(
|
||
resolveBuiltForm({
|
||
override: null,
|
||
isNewApproach: false,
|
||
lodgedBuiltForm: "1", // must be ignored for legacy
|
||
predictedBuiltForm: null,
|
||
legacyBuiltForm: "End-Terrace",
|
||
}),
|
||
).toBe("End-Terrace");
|
||
});
|
||
|
||
it("resolves to 'Unknown' when nothing is available (never null)", () => {
|
||
expect(
|
||
resolveBuiltForm({
|
||
override: null,
|
||
isNewApproach: true,
|
||
lodgedBuiltForm: null,
|
||
predictedBuiltForm: null,
|
||
legacyBuiltForm: "Detached", // legacy row must NOT surface for new-approach
|
||
}),
|
||
).toBe("Unknown");
|
||
});
|
||
});
|
||
|
||
describe("resolveConstructionAge", () => {
|
||
it("maps a landlord override band letter to its band label", () => {
|
||
expect(
|
||
resolveConstructionAge({
|
||
override: "B",
|
||
isNewApproach: true,
|
||
lodgedAgeBand: "F", // EPC says a different band — override still wins
|
||
predictedAgeBand: null,
|
||
legacyYearBuilt: null,
|
||
}),
|
||
).toBe("1900–1929");
|
||
});
|
||
|
||
it("maps the lodged EPC band letter to its label when there is no override", () => {
|
||
expect(
|
||
resolveConstructionAge({
|
||
override: null,
|
||
isNewApproach: true,
|
||
lodgedAgeBand: "C",
|
||
predictedAgeBand: null,
|
||
legacyYearBuilt: null,
|
||
}),
|
||
).toBe("1930–1949");
|
||
});
|
||
|
||
it("falls back to the predicted EPC band when there is no lodged band", () => {
|
||
expect(
|
||
resolveConstructionAge({
|
||
override: null,
|
||
isNewApproach: true,
|
||
lodgedAgeBand: null,
|
||
predictedAgeBand: "M",
|
||
legacyYearBuilt: null,
|
||
}),
|
||
).toBe("2023 onwards");
|
||
});
|
||
|
||
it("buckets a legacy year_built into its band (mid-range)", () => {
|
||
expect(legacyAge("1985")).toBe("1983–1990");
|
||
});
|
||
|
||
it("buckets a legacy year before 1900 into band A", () => {
|
||
expect(legacyAge("1899")).toBe("before 1900");
|
||
});
|
||
|
||
it("buckets the first year of a band correctly (boundary)", () => {
|
||
expect(legacyAge("1900")).toBe("1900–1929");
|
||
});
|
||
|
||
it("buckets a recent legacy year into the open-ended final band", () => {
|
||
expect(legacyAge("2025")).toBe("2023 onwards");
|
||
});
|
||
|
||
it("resolves a non-numeric legacy year_built to 'Unknown'", () => {
|
||
expect(legacyAge("N/A")).toBe("Unknown");
|
||
});
|
||
|
||
it("resolves to 'Unknown' when nothing is available; ignores the legacy row for new-approach", () => {
|
||
expect(
|
||
resolveConstructionAge({
|
||
override: null,
|
||
isNewApproach: true,
|
||
lodgedAgeBand: null,
|
||
predictedAgeBand: null,
|
||
legacyYearBuilt: "1985", // must NOT surface for a new-approach property
|
||
}),
|
||
).toBe("Unknown");
|
||
});
|
||
|
||
it("lets an explicit 'Unknown' override win", () => {
|
||
expect(
|
||
resolveConstructionAge({
|
||
override: "Unknown",
|
||
isNewApproach: true,
|
||
lodgedAgeBand: "C",
|
||
predictedAgeBand: null,
|
||
legacyYearBuilt: null,
|
||
}),
|
||
).toBe("Unknown");
|
||
});
|
||
});
|
||
|
||
describe("resolveMainFuel", () => {
|
||
const base = {
|
||
override: null,
|
||
isNewApproach: true,
|
||
lodgedMainFuel: null,
|
||
predictedMainFuel: null,
|
||
legacyMainFuel: null,
|
||
};
|
||
|
||
it("prefers a landlord override over the EPC-derived value", () => {
|
||
expect(
|
||
resolveMainFuel({ ...base, override: "mains gas", lodgedMainFuel: "26" }),
|
||
).toBe("mains gas");
|
||
});
|
||
|
||
it("reads the new-approach EPC main-fuel code (lodged) as a raw value", () => {
|
||
expect(resolveMainFuel({ ...base, lodgedMainFuel: "26" })).toBe("26");
|
||
});
|
||
|
||
it("falls back to the predicted EPC main fuel when there is no lodged one", () => {
|
||
expect(resolveMainFuel({ ...base, predictedMainFuel: "29" })).toBe("29");
|
||
});
|
||
|
||
it("prefers the lodged EPC main fuel over the predicted one", () => {
|
||
expect(
|
||
resolveMainFuel({ ...base, lodgedMainFuel: "26", predictedMainFuel: "29" }),
|
||
).toBe("26");
|
||
});
|
||
|
||
it("passes an EPR string main fuel through unchanged", () => {
|
||
expect(resolveMainFuel({ ...base, lodgedMainFuel: "Mains gas" })).toBe("Mains gas");
|
||
});
|
||
|
||
it("resolves to 'Unknown' for a new-approach property with no EPC main fuel; ignores the legacy row", () => {
|
||
expect(
|
||
resolveMainFuel({ ...base, legacyMainFuel: "Gas mains gas" }),
|
||
).toBe("Unknown");
|
||
});
|
||
|
||
it("reads the legacy EPC main fuel for a legacy property", () => {
|
||
expect(
|
||
resolveMainFuel({
|
||
...base,
|
||
isNewApproach: false,
|
||
lodgedMainFuel: "26", // must be ignored for legacy
|
||
legacyMainFuel: "Gas mains gas",
|
||
}),
|
||
).toBe("Gas mains gas");
|
||
});
|
||
|
||
it("resolves to 'Unknown' when nothing is available (never null)", () => {
|
||
expect(resolveMainFuel({ ...base, isNewApproach: false })).toBe("Unknown");
|
||
});
|
||
});
|
||
|
||
describe("MAINFUEL_OPTIONS coverage", () => {
|
||
const covered = new Set(MAINFUEL_OPTIONS.flatMap((o) => o.dbValues));
|
||
const labels = new Set(MAINFUEL_OPTIONS.map((o) => o.label));
|
||
|
||
// Every landlord main-fuel override value must fold into a filter option, or an
|
||
// overridden fuel would display but be unfilterable (ADR-0012).
|
||
it("covers every landlord main-fuel override value", () => {
|
||
for (const value of MainFuelValues) {
|
||
expect(covered).toContain(value);
|
||
}
|
||
});
|
||
|
||
// Every RdSAP main_fuel code the new EPC graph can store must fold into a
|
||
// filter option under a real label (a typo'd label would be silently dropped
|
||
// by the MAINFUEL_OPTIONS merge — assert both the code AND its label).
|
||
it("folds every RdSAP main_fuel code under a real label", () => {
|
||
for (const [code, label] of Object.entries(RDSAP_MAIN_FUEL_CODE_LABELS)) {
|
||
expect(labels).toContain(label);
|
||
expect(covered).toContain(code);
|
||
}
|
||
});
|
||
|
||
// Every EPR free-text main_fuel string must fold in too.
|
||
it("folds every EPR main-fuel string under a real label", () => {
|
||
for (const [str, label] of Object.entries(EPR_MAIN_FUEL_STRING_LABELS)) {
|
||
expect(labels).toContain(label);
|
||
expect(covered).toContain(str);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("resolveOverrideDescriptor (wall / roof / heating free-text)", () => {
|
||
const base = {
|
||
override: null,
|
||
isNewApproach: true,
|
||
lodgedDescription: null,
|
||
predictedDescription: null,
|
||
legacyDescription: null,
|
||
};
|
||
|
||
it("prefers a landlord override over the EPC description", () => {
|
||
expect(
|
||
resolveOverrideDescriptor({
|
||
...base,
|
||
override: "Cavity wall, filled cavity",
|
||
lodgedDescription: "Solid brick, as built, no insulation (assumed)",
|
||
}),
|
||
).toBe("Cavity wall, filled cavity");
|
||
});
|
||
|
||
it("uses the lodged EPC description when there is no override", () => {
|
||
expect(
|
||
resolveOverrideDescriptor({
|
||
...base,
|
||
lodgedDescription: "Pitched, 250 mm loft insulation",
|
||
}),
|
||
).toBe("Pitched, 250 mm loft insulation");
|
||
});
|
||
|
||
it("falls back to the predicted EPC description when there is no lodged one", () => {
|
||
expect(
|
||
resolveOverrideDescriptor({ ...base, predictedDescription: "Flat, insulated (assumed)" }),
|
||
).toBe("Flat, insulated (assumed)");
|
||
});
|
||
|
||
it("reads the legacy description for a legacy property", () => {
|
||
expect(
|
||
resolveOverrideDescriptor({
|
||
...base,
|
||
isNewApproach: false,
|
||
lodgedDescription: "ignored for legacy",
|
||
legacyDescription: "Boiler and radiators, mains gas",
|
||
}),
|
||
).toBe("Boiler and radiators, mains gas");
|
||
});
|
||
|
||
it("resolves to 'Unknown' when nothing is available; ignores legacy for new-approach", () => {
|
||
expect(
|
||
resolveOverrideDescriptor({ ...base, legacyDescription: "must not surface" }),
|
||
).toBe("Unknown");
|
||
});
|
||
});
|
||
|
||
describe("classifyDescriptor — wall / roof / heating buckets", () => {
|
||
const wall = (v: string) =>
|
||
classifyDescriptor(v, WALL_TYPE_OPTIONS, DESCRIPTOR_FILTER_CONFIG.wallType.match);
|
||
const roof = (v: string) =>
|
||
classifyDescriptor(v, ROOF_TYPE_OPTIONS, DESCRIPTOR_FILTER_CONFIG.roofType.match);
|
||
const heat = (v: string) =>
|
||
classifyDescriptor(v, HEATING_SYSTEM_OPTIONS, DESCRIPTOR_FILTER_CONFIG.heatingSystem.match);
|
||
|
||
// Every landlord override value must fall into a real (non-Unknown) bucket, or
|
||
// an overridden component would display but be unfilterable (ADR-0012). The
|
||
// literal "Unknown" is the sole exception — it maps to the Unknown bucket.
|
||
const assertVocabCovered = (
|
||
values: readonly string[],
|
||
classify: (v: string) => string,
|
||
labels: Set<string>,
|
||
) => {
|
||
for (const value of values) {
|
||
const bucket = classify(value);
|
||
expect(labels).toContain(bucket);
|
||
if (value !== "Unknown") expect(bucket).not.toBe("Unknown");
|
||
}
|
||
};
|
||
|
||
it("buckets every WallType override value", () => {
|
||
assertVocabCovered(WallTypeValues, wall, new Set(WALL_TYPE_OPTIONS.map((b) => b.label)));
|
||
});
|
||
it("buckets every RoofType override value", () => {
|
||
assertVocabCovered(RoofTypeValues, roof, new Set(ROOF_TYPE_OPTIONS.map((b) => b.label)));
|
||
});
|
||
it("buckets every MainHeatingSystem override value", () => {
|
||
assertVocabCovered(
|
||
MainHeatingSystemValues,
|
||
heat,
|
||
new Set(HEATING_SYSTEM_OPTIONS.map((b) => b.label)),
|
||
);
|
||
});
|
||
|
||
// Representative EPC / legacy descriptions (from production) bucket as expected.
|
||
it("buckets representative EPC/legacy WALL descriptions", () => {
|
||
expect(wall("Cavity wall, filled cavity")).toBe("Cavity");
|
||
expect(wall("Solid brick, as built, no insulation (assumed)")).toBe("Solid Brick");
|
||
expect(wall("Timber frame, as built, insulated (assumed)")).toBe("Timber Frame");
|
||
expect(wall("Sandstone or limestone, as built, no insulation (assumed)")).toBe("Stone");
|
||
expect(wall("Granite or whinstone, as built, no insulation (assumed)")).toBe("Stone");
|
||
expect(wall("System built, with external insulation")).toBe("System Built");
|
||
// A bare U-value is not a construction type → Unknown.
|
||
expect(wall("Average thermal transmittance 0.21 W/m²K")).toBe("Unknown");
|
||
});
|
||
|
||
it("buckets representative EPC/legacy ROOF descriptions", () => {
|
||
expect(roof("Pitched, 200 mm loft insulation")).toBe("Pitched");
|
||
expect(roof("Flat, no insulation (assumed)")).toBe("Flat");
|
||
expect(roof("Roof room(s), insulated (assumed)")).toBe("Room-in-Roof");
|
||
expect(roof("(another dwelling above)")).toBe("Premises Above");
|
||
expect(roof("(other premises above)")).toBe("Premises Above");
|
||
expect(roof("Another Premises Above")).toBe("Premises Above");
|
||
expect(roof("Average thermal transmittance 0.11 W/m²K")).toBe("Unknown");
|
||
});
|
||
|
||
it("buckets representative EPC/legacy HEATING descriptions", () => {
|
||
expect(heat("Boiler and radiators, mains gas")).toBe("Gas Boiler");
|
||
expect(heat("Room heaters, mains gas")).toBe("Gas Room Heater");
|
||
expect(heat("Community scheme")).toBe("Community");
|
||
expect(heat("Community scheme, biomass")).toBe("Community");
|
||
expect(heat("Electric storage heaters")).toBe("Electric Storage");
|
||
expect(heat("Room heaters, electric")).toBe("Direct Electric");
|
||
expect(heat("Air source heat pump, radiators, electric")).toBe("Heat Pump");
|
||
// "oil" is a substring of "boiler" — a gas boiler must NOT bucket as Oil.
|
||
expect(heat("Boiler and radiators, oil")).toBe("Oil");
|
||
expect(heat("Boiler and radiators, dual fuel (mineral and wood)")).toBe("Solid Fuel");
|
||
expect(heat("Boiler and radiators, lpg")).toBe("LPG");
|
||
});
|
||
|
||
// The non-gas wet-boiler archetypes: their fuel leads the string rather than
|
||
// following a comma, so they are the case the ", oil" needle cannot catch.
|
||
// Asserted by bucket rather than just "not Unknown", because the failure mode
|
||
// that matters is a *wrong* bucket — reorder the needles and "Oil boiler,
|
||
// combi" silently becomes a Gas Boiler, which the coverage test would pass.
|
||
it("buckets the non-gas wet-boiler override archetypes by their fuel", () => {
|
||
expect(heat("Oil boiler, regular")).toBe("Oil");
|
||
expect(heat("Oil boiler, combi")).toBe("Oil");
|
||
expect(heat("Solid fuel boiler")).toBe("Solid Fuel");
|
||
// Guard the collision the "oil boiler" needle could have caused: Solid Fuel
|
||
// is checked first, and a gas boiler still has no "oil" fuel token.
|
||
expect(heat("Gas boiler, combi")).toBe("Gas Boiler");
|
||
});
|
||
});
|
||
|
||
/** Legacy-property helper: only year_built is set, cutoff is false. */
|
||
function legacyAge(year: string): string {
|
||
return resolveConstructionAge({
|
||
override: null,
|
||
isNewApproach: false,
|
||
lodgedAgeBand: null,
|
||
predictedAgeBand: null,
|
||
legacyYearBuilt: year,
|
||
});
|
||
}
|