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.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { formatCutoffDistance, formatEntryYear } from '@/lib/utils';
|
2026-08-16 13:42:06 +01:00
|
|
|
import {
|
|
|
|
|
describeCutoff, cutoffYearRows, cutoffTrendSummary, cutoffCoverageNote,
|
|
|
|
|
describeCutoffAbsence, compareToCutoffs, entryYearOf, CUTOFF_UNCERTAINTY_M,
|
|
|
|
|
} 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');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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('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
|
|
|
|
|
|
|
|
// ── Year-by-year history ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const d = (year: number, distance_m: number | null, route_count = 1): SchoolAdmissionDistance => ({
|
|
|
|
|
year, distance_m, route_count, la_name: 'Camden', distance_unit_raw: 'miles',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('entryYearOf', () => {
|
|
|
|
|
it('reduces EES academic codes and plain entry years to one key', () => {
|
|
|
|
|
// Without this the two histories never join and every year looks unpublished.
|
|
|
|
|
expect(entryYearOf(202425)).toBe(2024);
|
|
|
|
|
expect(entryYearOf(2024)).toBe(2024);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('cutoffYearRows', () => {
|
|
|
|
|
it('emits a row for every year in the span, including the empty ones', () => {
|
|
|
|
|
const rows = cutoffYearRows([d(2021, 900), d(2024, 700)]);
|
|
|
|
|
expect(rows.map((r) => r.year)).toEqual([2024, 2023, 2022, 2021]);
|
|
|
|
|
expect(rows.map((r) => r.status)).toEqual([
|
|
|
|
|
'published', 'not-published', 'not-published', 'published',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('separates "nothing published" from "was not oversubscribed"', () => {
|
|
|
|
|
// The two look identical in the distance data and mean opposite things.
|
|
|
|
|
const rows = cutoffYearRows(
|
|
|
|
|
[d(2021, 900), d(2023, 700)],
|
|
|
|
|
[{ year: 2022, oversubscribed: false }],
|
|
|
|
|
);
|
|
|
|
|
expect(rows.find((r) => r.year === 2022)!.status).toBe('not-oversubscribed');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('matches EES six-digit years against plain cut-off years', () => {
|
|
|
|
|
const rows = cutoffYearRows(
|
|
|
|
|
[d(2023, 700)],
|
|
|
|
|
[{ year: 202324, places_offered: 60, oversubscribed: true }],
|
|
|
|
|
);
|
|
|
|
|
expect(rows.find((r) => r.year === 2023)!.placesOffered).toBe(60);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does not stretch the span back over admissions years with no cut-offs', () => {
|
|
|
|
|
// EES reaches back further than councils publish; padding the chart with a
|
|
|
|
|
// decade of blanks would bury the years that carry a figure.
|
|
|
|
|
const rows = cutoffYearRows(
|
|
|
|
|
[d(2024, 700)],
|
|
|
|
|
[{ year: 2015, oversubscribed: true }, { year: 2024, oversubscribed: true }],
|
|
|
|
|
);
|
|
|
|
|
expect(rows.map((r) => r.year)).toEqual([2024]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('is safe when the backend does not send the field at all', () => {
|
|
|
|
|
expect(cutoffYearRows(undefined as never)).toEqual([]);
|
|
|
|
|
expect(cutoffYearRows([], [])).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('cutoffTrendSummary', () => {
|
|
|
|
|
const rowsFor = (pts: [number, number][]) => cutoffYearRows(pts.map(([y, m]) => d(y, m)));
|
|
|
|
|
|
|
|
|
|
it('says nothing below four published points', () => {
|
|
|
|
|
expect(cutoffTrendSummary(rowsFor([[2022, 900], [2023, 800], [2024, 700]]))).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('names both endpoints and their years rather than passing a verdict', () => {
|
|
|
|
|
const s = cutoffTrendSummary(rowsFor([
|
|
|
|
|
[2021, 1000], [2022, 900], [2023, 800], [2024, 500],
|
|
|
|
|
]));
|
|
|
|
|
expect(s).toContain('2021');
|
|
|
|
|
expect(s).toContain('2024');
|
|
|
|
|
expect(s).toContain('tightened');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does not call a small wobble a direction', () => {
|
|
|
|
|
const s = cutoffTrendSummary(rowsFor([
|
|
|
|
|
[2021, 1000], [2022, 1010], [2023, 990], [2024, 1030],
|
|
|
|
|
]));
|
|
|
|
|
expect(s).toContain('stayed broadly the same');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('cutoffCoverageNote', () => {
|
|
|
|
|
it('warns while the record is too thin to read as a trend', () => {
|
|
|
|
|
expect(cutoffCoverageNote(cutoffYearRows([d(2024, 700)]))).toContain('Only 1 year');
|
|
|
|
|
expect(cutoffCoverageNote(cutoffYearRows([
|
|
|
|
|
d(2021, 1000), d(2022, 900), d(2023, 800), d(2024, 700),
|
|
|
|
|
]))).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('describeCutoffAbsence', () => {
|
|
|
|
|
it('explains a selective school by how it admits, not by 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');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('compareToCutoffs', () => {
|
|
|
|
|
const rows = cutoffYearRows(
|
|
|
|
|
[d(2022, 1000), d(2024, 800)],
|
|
|
|
|
[{ year: 2023, oversubscribed: false }],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
it('marks a clearly nearer home as inside every comparable year', () => {
|
|
|
|
|
const r = compareToCutoffs(300, rows);
|
|
|
|
|
expect(r.insideCount).toBe(2);
|
|
|
|
|
expect(r.comparableCount).toBe(2);
|
|
|
|
|
expect(r.headline).toContain('inside the cut-off in all 2 years');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('marks a clearly further home as outside', () => {
|
|
|
|
|
const r = compareToCutoffs(4000, rows);
|
|
|
|
|
expect(r.insideCount).toBe(0);
|
|
|
|
|
expect(r.headline).toContain('outside the cut-off in every year');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('refuses to call a result inside the measurement error', () => {
|
|
|
|
|
// A postcode centroid covers several addresses; claiming a place on a 20 m
|
|
|
|
|
// margin would be inventing precision the inputs do not have.
|
|
|
|
|
const r = compareToCutoffs(800 - CUTOFF_UNCERTAINTY_M / 2, rows);
|
|
|
|
|
expect(r.years.find((y) => y.year === 2024)!.verdict).toBe('too-close');
|
|
|
|
|
expect(r.detail).toContain('too close to call');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('never counts an unpublished year as a pass or a fail', () => {
|
|
|
|
|
const r = compareToCutoffs(300, rows);
|
|
|
|
|
expect(r.years.find((y) => y.year === 2023)!.verdict).toBe('unknown');
|
|
|
|
|
expect(r.detail).toContain('no published figure');
|
|
|
|
|
});
|
|
|
|
|
});
|