assessment-model/src/lib/services/descriptorResolution.test.ts
Daniel Roth 23c7b1dcdf fix(filters): bucket the oil boiler heating archetypes as Oil, not Unknown
#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.
2026-07-23 15:19:28 +00:00

535 lines
18 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 {
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("19001929");
});
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("19301949");
});
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("19831990");
});
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("19001929");
});
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,
});
}