Merge pull request 'fix(admissions): state distance in miles throughout, never mixed' (#105) from fix/standardise-distance-units into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m28s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m28s
Reviewed-on: #105
This commit was merged in pull request #105.
This commit is contained in:
commit
228eb214f5
4 files changed
+94
-23
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
+31
-17
@@ -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`,
|
||||
|
||||
Reference in new issue
Block a user