2026-08-15 22:48:30 +01:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-08-20 20:35:05 +01:00
|
|
|
import { formatCutoffDistance, formatMiles, formatEntryYear } from '@/lib/utils';
|
2026-08-16 13:42:06 +01:00
|
|
|
import {
|
2026-08-20 18:44:57 +01:00
|
|
|
describeCutoff, describeCutoffAbsence, compareToCutoff, CUTOFF_UNCERTAINTY_M,
|
2026-08-16 13:42:06 +01:00
|
|
|
} from '@/components/school/lastDistanceOffered';
|
2026-08-15 22:48:30 +01:00
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-20 20:35:05 +01:00
|
|
|
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');
|
2026-08-15 22:48:30 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-16 13:42:06 +01:00
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
// ── "Would we have got in?" ────────────────────────────────────────────
|
2026-08-16 13:42:06 +01:00
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
describe('compareToCutoff', () => {
|
2026-08-20 20:35:05 +01:00
|
|
|
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)
|
2026-09-24 22:04:27 +01:00
|
|
|
.toBe('0.04 miles away, inside the September 2026 cut-off of 0.17 miles.');
|
2026-08-20 20:35:05 +01:00
|
|
|
});
|
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
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');
|
2026-08-16 13:42:06 +01:00
|
|
|
});
|
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
it('calls a clearly further home beyond', () => {
|
|
|
|
|
expect(compareToCutoff(4000, 800, 2026).verdict).toBe('outside');
|
2026-08-16 13:42:06 +01:00
|
|
|
});
|
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
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();
|
2026-08-16 13:42:06 +01:00
|
|
|
});
|
|
|
|
|
|
2026-08-20 18:44:57 +01:00
|
|
|
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');
|
2026-08-16 13:42:06 +01:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('describeCutoffAbsence', () => {
|
2026-08-20 18:44:57 +01:00
|
|
|
it('explains a selective school by how it admits, not as missing data', () => {
|
2026-08-16 13:42:06 +01:00
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
});
|