PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 58s
The postcode check read "69 m away — inside the September 2026 cut-off of 0.17 miles". Both numbers are right and the sentence is still useless: the reader has to convert one of them to check a comparison we had already made for them. The cause was a readability rule of mine in formatCutoffDistance, which swapped to metres below 100m on the grounds that "0.04 miles" carries less than "69 m". Taken one figure at a time that holds. Taken in a sentence containing two figures it guarantees a mismatch whenever they fall either side of the threshold — and a 270m cut-off with a nearby home does exactly that. Miles now lead everywhere. It is the unit UK school admissions runs on: councils publish cut-offs in miles (90% of the collected source rows), and it is what a parent has already been quoted in their booklet and offer letter. The metric figure survives only as support beside the miles figure on the Admissions tile, where it converts the same value rather than presenting a second one to compare. Below 0.01 miles the decimal places run out rather than the unit being wrong, so a very short distance is described — "under 0.01 miles" — instead of rounding to a flat "0.00 miles", which would read as no distance at all. Both figures in the verdict now go through one formatter with no fallback that could reach for another unit. The old `?? "N m"` fallbacks on that line were a second route to the same defect and are gone. Covered by a sweep over sixty home/cut-off combinations spanning the old switch point, asserting no verdict contains a metric reading and that exactly two miles figures appear; plus the reported case pinned verbatim, and an e2e guard on the rendered verdict. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
183 lines
7.7 KiB
TypeScript
183 lines
7.7 KiB
TypeScript
/**
|
|
* Last distance offered — formatting and the caveats attached to the figure.
|
|
*
|
|
* The assertions about the route note and the year are not cosmetic. A cut-off
|
|
* shown without its year, or a banded school's widest cut-off shown as if it
|
|
* were the only one, tells a parent something false about their chances of a
|
|
* place — so both are pinned here rather than left to the component.
|
|
*/
|
|
|
|
import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils';
|
|
import {
|
|
describeCutoff, describeCutoffAbsence, compareToCutoff, CUTOFF_UNCERTAINTY_M,
|
|
} from '@/components/school/lastDistanceOffered';
|
|
import type { SchoolAdmissionDistance } from '@/lib/types';
|
|
|
|
const distance = (over: Partial<SchoolAdmissionDistance> = {}): SchoolAdmissionDistance => ({
|
|
year: 2025,
|
|
distance_m: 500,
|
|
route_count: 1,
|
|
la_name: 'Camden',
|
|
distance_unit_raw: 'miles',
|
|
...over,
|
|
});
|
|
|
|
describe('formatCutoffDistance', () => {
|
|
it('leads with miles, the unit councils publish in', () => {
|
|
expect(formatCutoffDistance(500)).toEqual({ primary: '0.31 miles', secondary: '500 m' });
|
|
expect(formatCutoffDistance(1609.344)).toEqual({ primary: '1.00 miles', secondary: '1.6 km' });
|
|
});
|
|
|
|
it('switches to kilometres for the support figure above a kilometre', () => {
|
|
expect(formatCutoffDistance(3472.96)!.secondary).toBe('3.5 km');
|
|
});
|
|
|
|
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', () => {
|
|
// 0.0 miles appears in the source where a school filled on a higher
|
|
// criterion. Rendered as "0.00 miles" it would read as the opposite.
|
|
expect(formatCutoffDistance(0)).toBeNull();
|
|
expect(formatCutoffDistance(null)).toBeNull();
|
|
expect(formatCutoffDistance(undefined)).toBeNull();
|
|
expect(formatCutoffDistance(Number.NaN)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('formatEntryYear', () => {
|
|
it('names the intake, not the academic year', () => {
|
|
// formatAcademicYear would render 2025 as "2025/26", which reads as a
|
|
// school year rather than the September a child started.
|
|
expect(formatEntryYear(2025)).toBe('September 2025');
|
|
expect(formatEntryYear(null)).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('describeCutoff', () => {
|
|
it('always carries the entry year alongside the figure', () => {
|
|
const d = describeCutoff(distance({ distance_m: 772.49, year: 2024 }));
|
|
expect(d).not.toBeNull();
|
|
expect(d!.primary).toBe('0.48 miles');
|
|
expect(d!.entryYear).toBe('September 2024');
|
|
});
|
|
|
|
it('says nothing about routes for a school with one', () => {
|
|
expect(describeCutoff(distance({ route_count: 1 }))!.routeNote).toBeNull();
|
|
expect(describeCutoff(distance({ route_count: null }))!.routeNote).toBeNull();
|
|
});
|
|
|
|
it('warns that a banded school\'s figure is the widest of several', () => {
|
|
const note = describeCutoff(distance({ route_count: 4 }))!.routeNote;
|
|
expect(note).toContain('4 admission routes');
|
|
expect(note).toContain('shorter cut-off');
|
|
});
|
|
|
|
it('is null when there is nothing publishable', () => {
|
|
expect(describeCutoff(null)).toBeNull();
|
|
expect(describeCutoff(undefined)).toBeNull();
|
|
expect(describeCutoff(distance({ distance_m: null }))).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── "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');
|
|
expect(r.headline).toContain('September 2026');
|
|
});
|
|
|
|
it('calls a clearly further home beyond', () => {
|
|
expect(compareToCutoff(4000, 800, 2026).verdict).toBe('outside');
|
|
});
|
|
|
|
it('refuses to call a result inside the measurement error, either way', () => {
|
|
// A postcode centroid covers several addresses, so a margin this fine is
|
|
// noise. With one published year there is no other year to fall back on,
|
|
// which makes this band the only thing standing between a parent and a
|
|
// place they do not have.
|
|
expect(compareToCutoff(800 - CUTOFF_UNCERTAINTY_M / 2, 800, 2026).verdict).toBe('too-close');
|
|
expect(compareToCutoff(800 + CUTOFF_UNCERTAINTY_M / 2, 800, 2026).verdict).toBe('too-close');
|
|
expect(compareToCutoff(800, 800, 2026).detail).toContain('measurement error');
|
|
// The explanation is not welded to the headline, so it does not run at
|
|
// headline weight in the result block.
|
|
expect(compareToCutoff(800, 800, 2026).headline).not.toContain('measurement error');
|
|
expect(compareToCutoff(300, 800, 2026).detail).toBeNull();
|
|
});
|
|
|
|
it('treats the band as exclusive at its edge', () => {
|
|
// Exactly on the boundary is still too close; one metre past it is not.
|
|
expect(compareToCutoff(800 - CUTOFF_UNCERTAINTY_M, 800, 2026).verdict).toBe('too-close');
|
|
expect(compareToCutoff(800 - CUTOFF_UNCERTAINTY_M - 1, 800, 2026).verdict).toBe('inside');
|
|
});
|
|
});
|
|
|
|
describe('describeCutoffAbsence', () => {
|
|
it('explains a selective school by how it admits, not as missing data', () => {
|
|
const s = describeCutoffAbsence({ localAuthority: 'Kent', admissionsPolicy: 'Selective' });
|
|
expect(s).toContain('entrance test');
|
|
expect(s).not.toContain('has not published');
|
|
});
|
|
|
|
it('reads a consistently undersubscribed school as good news', () => {
|
|
const s = describeCutoffAbsence({
|
|
localAuthority: 'Camden',
|
|
admissionsHistory: [
|
|
{ year: 2022, oversubscribed: false },
|
|
{ year: 2023, oversubscribed: false },
|
|
{ year: 2024, oversubscribed: false },
|
|
],
|
|
});
|
|
expect(s).toContain('has not needed a distance cut-off');
|
|
});
|
|
|
|
it('otherwise names the authority that would hold the figure', () => {
|
|
expect(describeCutoffAbsence({ localAuthority: 'Camden' }))
|
|
.toContain('Camden has not published');
|
|
});
|
|
});
|