diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 70d48d2..9c18dc8 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -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. await expect(result).toContainText(new RegExp(`September ${found!.distance.year}`)); 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); } }); diff --git a/nextjs-app/__tests__/lib/lastDistanceOffered.test.ts b/nextjs-app/__tests__/lib/lastDistanceOffered.test.ts index 09278ae..2f7e640 100644 --- a/nextjs-app/__tests__/lib/lastDistanceOffered.test.ts +++ b/nextjs-app/__tests__/lib/lastDistanceOffered.test.ts @@ -7,7 +7,7 @@ * 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 { describeCutoff, describeCutoffAbsence, compareToCutoff, CUTOFF_UNCERTAINTY_M, } from '@/components/school/lastDistanceOffered'; @@ -32,8 +32,19 @@ describe('formatCutoffDistance', () => { expect(formatCutoffDistance(3472.96)!.secondary).toBe('3.5 km'); }); - it('leads with metres below 100 m, where a miles figure carries nothing', () => { - expect(formatCutoffDistance(27)).toEqual({ primary: '27 m', secondary: '0.02 miles' }); + it('stays in miles at short range, where it used to swap to metres', () => { + // 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', () => { @@ -84,6 +95,36 @@ describe('describeCutoff', () => { // ── "Would we have got in?" ──────────────────────────────────────────── 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', () => { const r = compareToCutoff(300, 800, 2026); expect(r.verdict).toBe('inside'); diff --git a/nextjs-app/components/school/lastDistanceOffered.ts b/nextjs-app/components/school/lastDistanceOffered.ts index a3cb6e4..cf90a39 100644 --- a/nextjs-app/components/school/lastDistanceOffered.ts +++ b/nextjs-app/components/school/lastDistanceOffered.ts @@ -16,7 +16,7 @@ */ import type { SchoolAdmissionDistance } from '@/lib/types'; -import { formatCutoffDistance, formatEntryYear } from '@/lib/utils'; +import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils'; export interface CutoffDisplay { /** Headline figure, e.g. "0.31 miles". */ @@ -108,8 +108,13 @@ export function compareToCutoff( const verdict: CutoffVerdict = Math.abs(margin) <= CUTOFF_UNCERTAINTY_M ? 'too-close' : margin > 0 ? 'inside' : 'outside'; - const label = formatCutoffDistance(distanceM)?.primary ?? `${Math.round(distanceM)} m`; - const cutoffLabel = formatCutoffDistance(cutoffM)?.primary ?? `${Math.round(cutoffM)} m`; + // Both sides through the same formatter, with no fallback that could reach + // 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 // cut-off for September 2026": the figure carries its own unit word, which diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts index 45f3e78..2d7e931 100644 --- a/nextjs-app/lib/utils.ts +++ b/nextjs-app/lib/utils.ts @@ -505,32 +505,46 @@ export function getPhaseStyle(phase?: string | null): { key: string; label: stri 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 - * rows are miles — and because it is the unit a parent has already been quoted - * if they have read their council's booklet. The metric figure follows as - * support rather than replacing it. + * Councils publish cut-offs in miles — 90% of the collected source rows — and + * it is the unit a parent has already been quoted in their council's booklet + * and offer letter. Everything a reader is asked to compare therefore renders + * 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 - * the two swap round and metres lead instead. + * That was not previously true. This formatter used to swap to metres below + * 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( metres: number | null | undefined ): { primary: string; secondary: string } | 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 { - primary: `${miles.toFixed(2)} miles`, + primary: formatMiles(metres), secondary: metres < 1000 ? `${Math.round(metres / 10) * 10} m` : `${(metres / 1000).toFixed(1)} km`,