Files
school_compare/nextjs-app/__tests__/lib/lastDistanceOffered.test.ts
TudorandClaude Opus 5.5 d1688ac150
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m20s
copy(web): replace em dashes in public copy
Rewrites every visitor-facing string that used an em dash, choosing the
punctuation for what the dash was doing: a colon before a list or
explanation, a comma for an aside, a full stop between two thoughts,
parentheses for an aside mid-sentence. Covers page titles and meta
descriptions, the home and admissions guide copy, school page headings
and notes, the compare page, metric labels and tooltips.

Two rewrites also fix the sentence around them: the closure banner no
longer repeats "proposed for closure", and the cut-off caveat's list of
priorities now parses.

A lone dash marking a missing value in a table cell stays: it is a data
convention, not prose. A Jest guard walks the source with the TypeScript
parser and fails on any other em dash in a string or JSX text node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:04:27 +01:00

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');
});
});