Merge pull request 'fix(admissions): state distance in miles throughout, never mixed' (#105) from fix/standardise-distance-units into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m28s

Reviewed-on: #105
This commit was merged in pull request #105.
This commit is contained in:
tudor committed 2026-08-20 19:39:18 +00:00
commit 228eb214f5
4 files changed
+94 -23

No files matched your search

+11
View File
@@ -1364,6 +1364,17 @@ test('the postcode check answers for the published year, and names it', async ({
// A verdict without its year is a number a parent cannot place. // A verdict without its year is a number a parent cannot place.
await expect(result).toContainText(new RegExp(`September ${found!.distance.year}`)); await expect(result).toContainText(new RegExp(`September ${found!.distance.year}`));
await expect(result).toContainText(/inside|beyond|too close/); await expect(result).toContainText(/inside|beyond|too close/);
// Both figures in the sentence must be in the same unit. The verdict used
// to render the home distance in metres and the cut-off in miles — "69 m
// away — inside the September 2026 cut-off of 0.17 miles" — which asks the
// reader to convert between units to check a comparison already made for
// them. Miles throughout: the unit councils publish and quote.
const headline = (await result.textContent()) ?? '';
const verdictLine = headline.split('.')[0];
expect(verdictLine, `verdict mixes units: ${verdictLine}`).not.toMatch(/\d\s?m\b/);
expect((verdictLine.match(/miles/g) ?? []).length,
`verdict should quote two figures in miles: ${verdictLine}`).toBe(2);
} }
}); });
@@ -7,7 +7,7 @@
* place — so both are pinned here rather than left to the component. * place — so both are pinned here rather than left to the component.
*/ */
import { formatCutoffDistance, formatEntryYear } from '@/lib/utils'; import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils';
import { import {
describeCutoff, describeCutoffAbsence, compareToCutoff, CUTOFF_UNCERTAINTY_M, describeCutoff, describeCutoffAbsence, compareToCutoff, CUTOFF_UNCERTAINTY_M,
} from '@/components/school/lastDistanceOffered'; } from '@/components/school/lastDistanceOffered';
@@ -32,8 +32,19 @@ describe('formatCutoffDistance', () => {
expect(formatCutoffDistance(3472.96)!.secondary).toBe('3.5 km'); expect(formatCutoffDistance(3472.96)!.secondary).toBe('3.5 km');
}); });
it('leads with metres below 100 m, where a miles figure carries nothing', () => { it('stays in miles at short range, where it used to swap to metres', () => {
expect(formatCutoffDistance(27)).toEqual({ primary: '27 m', secondary: '0.02 miles' }); // The swap made a single number easier to read and a comparison harder:
// "69 m away — inside the cut-off of 0.17 miles" asked the reader to
// convert between units to check a claim we had already made for them.
expect(formatCutoffDistance(27)).toEqual({ primary: '0.02 miles', secondary: '30 m' });
expect(formatCutoffDistance(69)!.primary).toMatch(/miles$/);
});
it('describes a distance too short for two decimal places', () => {
// Rather than a flat "0.00 miles", which reads as no distance at all.
expect(formatMiles(5)).toBe('under 0.01 miles');
expect(formatMiles(0)).toBe('under 0.01 miles');
expect(formatMiles(20)).toBe('0.01 miles');
}); });
it('returns null rather than a zero cut-off', () => { it('returns null rather than a zero cut-off', () => {
@@ -84,6 +95,36 @@ describe('describeCutoff', () => {
// ── "Would we have got in?" ──────────────────────────────────────────── // ── "Would we have got in?" ────────────────────────────────────────────
describe('compareToCutoff', () => { describe('compareToCutoff', () => {
it('never states the two figures in different units', () => {
/*
* The reported defect: "69 m away — inside the September 2026 cut-off of
* 0.17 miles". Both numbers are correct and the sentence is still useless,
* because checking it means converting one of them.
*
* Swept across the range where the old formatter switched units, so a
* future readability tweak to one figure cannot reintroduce the mismatch
* in the other.
*/
const mixed: string[] = [];
for (const homeM of [0, 5, 27, 69, 99, 100, 260, 800, 1609, 5000]) {
for (const cutoffM of [30, 69, 100, 270, 1000, 3500]) {
const { headline } = compareToCutoff(homeM, cutoffM, 2026);
const hasMetres = /\d\s?m\b/.test(headline);
const milesCount = (headline.match(/miles/g) ?? []).length;
// Two figures, both in miles, and no metric reading anywhere near them.
if (hasMetres || milesCount !== 2) {
mixed.push(`home=${homeM}m cutoff=${cutoffM}m -> ${headline}`);
}
}
}
expect(mixed).toEqual([]);
});
it('reads back the reported case in one unit', () => {
expect(compareToCutoff(69, 270, 2026).headline)
.toBe('0.04 miles away — inside the September 2026 cut-off of 0.17 miles.');
});
it('calls a clearly nearer home inside, and names the year', () => { it('calls a clearly nearer home inside, and names the year', () => {
const r = compareToCutoff(300, 800, 2026); const r = compareToCutoff(300, 800, 2026);
expect(r.verdict).toBe('inside'); expect(r.verdict).toBe('inside');
@@ -16,7 +16,7 @@
*/ */
import type { SchoolAdmissionDistance } from '@/lib/types'; import type { SchoolAdmissionDistance } from '@/lib/types';
import { formatCutoffDistance, formatEntryYear } from '@/lib/utils'; import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils';
export interface CutoffDisplay { export interface CutoffDisplay {
/** Headline figure, e.g. "0.31 miles". */ /** Headline figure, e.g. "0.31 miles". */
@@ -108,8 +108,13 @@ export function compareToCutoff(
const verdict: CutoffVerdict = const verdict: CutoffVerdict =
Math.abs(margin) <= CUTOFF_UNCERTAINTY_M ? 'too-close' : margin > 0 ? 'inside' : 'outside'; Math.abs(margin) <= CUTOFF_UNCERTAINTY_M ? 'too-close' : margin > 0 ? 'inside' : 'outside';
const label = formatCutoffDistance(distanceM)?.primary ?? `${Math.round(distanceM)} m`; // Both sides through the same formatter, with no fallback that could reach
const cutoffLabel = formatCutoffDistance(cutoffM)?.primary ?? `${Math.round(cutoffM)} m`; // for a different unit: the sentence compares these two numbers directly, so
// they have to be in the same one. formatCutoffDistance returns null for a
// zero or negative figure, and its old "N m" fallback here was one of the
// ways a metres reading used to appear next to a miles one.
const label = formatMiles(distanceM);
const cutoffLabel = formatMiles(cutoffM);
// "the September 2026 cut-off of 0.17 miles" rather than "the 0.17 miles // "the September 2026 cut-off of 0.17 miles" rather than "the 0.17 miles
// cut-off for September 2026": the figure carries its own unit word, which // cut-off for September 2026": the figure carries its own unit word, which
+31 -17
View File
@@ -505,32 +505,46 @@ export function getPhaseStyle(phase?: string | null): { key: string; label: stri
const METRES_PER_MILE = 1609.344; const METRES_PER_MILE = 1609.344;
/** /**
* Format an admission cut-off distance. * A distance in miles, the unit UK school admissions is conducted in.
* *
* Miles lead because that is the unit councils publish in — 90% of the source * Councils publish cut-offs in miles — 90% of the collected source rows — and
* rows are miles — and because it is the unit a parent has already been quoted * it is the unit a parent has already been quoted in their council's booklet
* if they have read their council's booklet. The metric figure follows as * and offer letter. Everything a reader is asked to compare therefore renders
* support rather than replacing it. * through this one function, so two figures in the same sentence can never
* arrive in different units.
* *
* Below 100 m the miles figure stops carrying information ("0.03 miles"), so * That was not previously true. This formatter used to swap to metres below
* the two swap round and metres lead instead. * 100m, on the reasoning that "0.04 miles" carries less for a reader than
* "69 m". Taken one figure at a time that holds; taken in a sentence it
* produced "69 m away — inside the September 2026 cut-off of 0.17 miles",
* which asks the reader to convert between units to understand a comparison we
* had already made for them. Legibility of a single number lost to coherence
* of the pair.
*
* Below 0.01 miles the two decimal places run out rather than the unit being
* wrong, so the figure is described instead of rounded to a flat "0.00 miles".
*/
export function formatMiles(metres: number): string {
const miles = metres / METRES_PER_MILE;
if (miles < 0.01) return 'under 0.01 miles';
return `${miles.toFixed(2)} miles`;
}
/**
* Format an admission cut-off distance: the headline figure in miles, and a
* metric equivalent to support it.
*
* The metric figure is support, not an alternative — it appears beside the
* miles figure, never instead of it, so nothing a reader compares is ever in
* two units at once.
*/ */
export function formatCutoffDistance( export function formatCutoffDistance(
metres: number | null | undefined metres: number | null | undefined
): { primary: string; secondary: string } | null { ): { primary: string; secondary: string } | null {
if (metres == null || !Number.isFinite(metres) || metres <= 0) return null; if (metres == null || !Number.isFinite(metres) || metres <= 0) return null;
const miles = metres / METRES_PER_MILE;
if (metres < 100) {
return {
primary: `${Math.round(metres)} m`,
secondary: `${miles.toFixed(2)} miles`,
};
}
return { return {
primary: `${miles.toFixed(2)} miles`, primary: formatMiles(metres),
secondary: metres < 1000 secondary: metres < 1000
? `${Math.round(metres / 10) * 10} m` ? `${Math.round(metres / 10) * 10} m`
: `${(metres / 1000).toFixed(1)} km`, : `${(metres / 1000).toFixed(1)} km`,