Files
school_compare/nextjs-app/__tests__/lib/utils.test.ts
TudorandClaude Opus 5 9a1f56c431
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m44s
feat(places): say what each school is, not only how it scored
The location tables carried one column: a percentage. A parent
shortlisting from a town page is asking a different question first —
does it take my child's age, is it a faith school, does it have a
nursery — and the page could not answer any of it.

Primary tables gain Ages, Religious character, Nursery and
Constituency; secondary tables the same minus Nursery, which is a
question about a different intake. An all-through school renders in
both groups, so its nursery shows under primary alone.

The measure moves to the second column rather than the last. Six
columns overflow a phone and .tableWrap turns that into a horizontal
swipe; with the measure last, the one number the page exists for is
the one scrolled off the screen.

Cell rules are the ones the school page already uses, so the two
surfaces cannot disagree about the same school: "Does not apply",
"None" and "Not applicable" all read as no religious character, and
the en-dash age normalisation moves into formatAgeSpan, which
formatAgeRange now delegates to.

Backend: nursery_provision and parliamentary_constituency were not in
the place response. Both are optional GIAS mart columns that
data_loader degrades to NULL, and the `in rows.columns` guard keeps a
mart the pipeline has not rebuilt working.

Also fixes a live bug on the same line: SCHOOL_COLUMNS already ends
with latitude and longitude, and the endpoint concatenated them again,
so pandas dropped one of every duplicated pair and warned "columns are
not unique" on each request. Ordered de-duplication removes the
warning and the silent drop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FuPUioHpxtaiDNagQvjxyM
2026-08-27 08:49:14 +01:00

349 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Utility Functions Tests
*/
import {
formatPercentage,
formatProgress,
calculateTrend,
isValidPostcode,
debounce,
buildOfstedListBadge,
ofstedLegacyAreas,
metricKind,
shortName,
computeYBounds,
formatAgeRange,
formatAgeSpan,
} from '@/lib/utils';
describe('formatPercentage', () => {
it('formats percentages correctly', () => {
expect(formatPercentage(75.5)).toBe('75.5%');
expect(formatPercentage(100)).toBe('100.0%');
expect(formatPercentage(0)).toBe('0.0%');
});
it('handles null values', () => {
expect(formatPercentage(null)).toBe('N/A');
});
});
describe('formatProgress', () => {
it('formats progress scores correctly', () => {
expect(formatProgress(2.5)).toBe('+2.5');
expect(formatProgress(-1.3)).toBe('-1.3');
expect(formatProgress(0)).toBe('0.0');
});
it('handles null values', () => {
expect(formatProgress(null)).toBe('N/A');
});
});
describe('calculateTrend', () => {
it('calculates upward trend', () => {
expect(calculateTrend(75, 70)).toBe('up');
});
it('calculates downward trend', () => {
expect(calculateTrend(70, 75)).toBe('down');
});
it('calculates stable trend', () => {
expect(calculateTrend(75, 75)).toBe('stable');
});
it('handles null previous value', () => {
expect(calculateTrend(75, null)).toBe('stable');
});
it('handles null current value', () => {
expect(calculateTrend(null, 75)).toBe('stable');
});
});
describe('isValidPostcode', () => {
it('validates correct UK postcodes', () => {
expect(isValidPostcode('SW1A 1AA')).toBe(true);
expect(isValidPostcode('M1 1AE')).toBe(true);
expect(isValidPostcode('B33 8TH')).toBe(true);
});
it('rejects invalid postcodes', () => {
expect(isValidPostcode('INVALID')).toBe(false);
expect(isValidPostcode('12345')).toBe(false);
expect(isValidPostcode('')).toBe(false);
});
});
describe('debounce', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('delays function execution', () => {
const mockFn = jest.fn();
const debouncedFn = debounce(mockFn, 300);
debouncedFn('test');
expect(mockFn).not.toHaveBeenCalled();
jest.advanceTimersByTime(300);
expect(mockFn).toHaveBeenCalledWith('test');
expect(mockFn).toHaveBeenCalledTimes(1);
});
it('cancels previous calls', () => {
const mockFn = jest.fn();
const debouncedFn = debounce(mockFn, 300);
debouncedFn('first');
jest.advanceTimersByTime(150);
debouncedFn('second');
jest.advanceTimersByTime(150);
debouncedFn('third');
jest.advanceTimersByTime(300);
expect(mockFn).toHaveBeenCalledWith('third');
expect(mockFn).toHaveBeenCalledTimes(1);
});
});
describe('ofstedLegacyAreas', () => {
it('returns only real grades (1-4) and drops sentinel/null values', () => {
const areas = ofstedLegacyAreas({
quality_of_education: 1,
behaviour_attitudes: 2,
personal_development: null,
leadership_management: 9, // sentinel "not applicable" — must be dropped
early_years_provision: 9, // secondary school — not applicable
sixth_form_provision: 2,
});
expect(areas.map((a) => a.label)).toEqual([
'Quality of Teaching',
'Behaviour in School',
'Sixth Form',
]);
expect(areas.every((a) => a.value >= 1 && a.value <= 4)).toBe(true);
});
it('includes the sixth-form judgement where present (was previously dropped)', () => {
const areas = ofstedLegacyAreas({ sixth_form_provision: 1 });
expect(areas).toEqual([{ label: 'Sixth Form', value: 1 }]);
});
it('includes Early Years for a primary school', () => {
const areas = ofstedLegacyAreas({ quality_of_education: 2, early_years_provision: 1 });
expect(areas.map((a) => a.label)).toContain('Early Years (Reception)');
});
});
describe('buildOfstedListBadge', () => {
it('returns grade word + year for OEIF Outstanding', () => {
const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' });
expect(badge.label).toBe('Outstanding · 2023');
expect(badge.cssClass).toBe('ofsted1');
});
it('returns grade word for each OEIF grade', () => {
expect(buildOfstedListBadge({ ofsted_grade: 2, ofsted_date: '2022-05-01' }).label).toBe('Good · 2022');
expect(buildOfstedListBadge({ ofsted_grade: 3, ofsted_date: '2021-01-01' }).label).toBe('Req. Improvement · 2021');
expect(buildOfstedListBadge({ ofsted_grade: 4, ofsted_date: '2020-03-01' }).label).toBe('Inadequate · 2020');
});
it('returns grade word without year when date is missing', () => {
const badge = buildOfstedListBadge({ ofsted_grade: 2, ofsted_date: null });
expect(badge.label).toBe('Good');
expect(badge.cssClass).toBe('ofsted2');
});
it('returns a Report Card badge when ofsted_rc_date is present', () => {
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_rc_date: '2026-02-03' });
expect(badge.label).toBe('Report Card · 2026');
expect(badge.cssClass).toBe('ofstedRc');
});
it('a report card wins over a carried-forward legacy grade', () => {
// The production bug: a report-card school (e.g. Barclay) also carries a
// carried-forward legacy grade (ofsted_grade), which used to win and label
// the pin "Outstanding · 2021" instead of "Report Card · 2026".
const badge = buildOfstedListBadge({
ofsted_grade: 1,
ofsted_date: '2021-10-07',
ofsted_framework: 'Schools - S5',
ofsted_rc_date: '2026-02-03',
});
expect(badge.label).toBe('Report Card · 2026');
expect(badge.cssClass).toBe('ofstedRc');
});
it('returns an "Inspected" badge for an OEIF inspection with no overall grade (post-Sept-2024)', () => {
// Inspected after Sept 2024: inspection on record (date + framework) but
// Ofsted no longer issues an overall grade. Must NOT read as "Not yet inspected".
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: '2024-11-01', ofsted_framework: 'OEIF' });
expect(badge.label).toBe('Inspected · 2024');
expect(badge.cssClass).toBe('ofstedInspected');
});
it('returns an "Inspected" badge without a year when the grade is missing and date is absent but a record exists', () => {
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: null, ofsted_framework: 'OEIF' });
expect(badge.label).toBe('Inspected');
expect(badge.cssClass).toBe('ofstedInspected');
});
it('returns pending badge when no grade and no inspection on record', () => {
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: null, ofsted_framework: null });
expect(badge.label).toBe('Not yet inspected');
expect(badge.cssClass).toBe('ofstedPending');
});
it('returns pending badge when all fields are undefined', () => {
const badge = buildOfstedListBadge({});
expect(badge.label).toBe('Not yet inspected');
expect(badge.cssClass).toBe('ofstedPending');
});
});
describe('metricKind', () => {
it('classifies metrics by key', () => {
expect(metricKind('rwm_expected_pct')).toBe('percentage');
expect(metricKind('absence_rate')).toBe('percentage');
expect(metricKind('reading_progress')).toBe('progress');
expect(metricKind('progress_8_score')).toBe('progress');
expect(metricKind('attainment_8_score')).toBe('score');
expect(metricKind('reading_avg_score')).toBe('score');
});
});
describe('computeYBounds', () => {
it('tightens clustered percentages instead of framing 0-100', () => {
const b = computeYBounds([86, 86, 86, 80, 96], 'percentage');
expect(b.min).toBeGreaterThanOrEqual(0);
expect(b.max).toBeLessThanOrEqual(100);
expect(b.min).toBeGreaterThan(50);
expect(b.max! - b.min!).toBeGreaterThanOrEqual(10);
});
it('never widens percentages beyond 0-100 for non-negative data', () => {
const b = computeYBounds([2, 5, 98], 'percentage');
expect(b.min).toBe(0);
expect(b.max).toBe(100);
});
it('does not clamp to zero when pct-named trend data is negative', () => {
const b = computeYBounds([-12, -3, 4], 'percentage');
expect(b.min).toBeLessThan(-12);
});
it('keeps progress bounds symmetric around zero', () => {
const b = computeYBounds([-1.2, 0.4, 2.1], 'progress');
expect(b.min).toBe(-b.max!);
expect(b.min).toBeLessThanOrEqual(-1.2);
expect(b.max).toBeGreaterThanOrEqual(2.1);
});
it('fits score metrics without a fixed frame', () => {
const b = computeYBounds([42.3, 48.9, 51.2], 'score');
expect(b.min).toBeGreaterThanOrEqual(0);
expect(b.min).toBeLessThanOrEqual(42.3);
expect(b.max).toBeGreaterThanOrEqual(51.2);
});
it('returns empty bounds when there is no numeric data', () => {
expect(computeYBounds([null, undefined, NaN], 'percentage')).toEqual({});
expect(computeYBounds([], 'progress')).toEqual({});
});
});
describe('isProposedToClose', () => {
const { isProposedToClose } = require('@/lib/utils');
it('is true only for the exact GIAS proposed-to-close status', () => {
expect(isProposedToClose({ status: 'Open, but proposed to close' })).toBe(true);
expect(isProposedToClose({ status: 'Open' })).toBe(false);
expect(isProposedToClose({ status: null })).toBe(false);
expect(isProposedToClose({})).toBe(false);
});
});
describe('isSpecialSchool', () => {
const { isSpecialSchool } = require('@/lib/utils');
it('matches every DfE special-school establishment type', () => {
for (const t of [
'Community special school',
'Foundation special school',
'Academy special converter',
'Academy special sponsor led',
'Non-maintained special school',
'Free schools special',
'Other independent special school',
]) {
expect(isSpecialSchool({ school_type: t })).toBe(true);
}
});
it('matches pupil referral units and alternative provision', () => {
expect(isSpecialSchool({ school_type: 'Pupil referral unit' })).toBe(true);
expect(isSpecialSchool({ school_type: 'Academy alternative provision converter' })).toBe(true);
});
it('does not match mainstream types (no false positives)', () => {
for (const t of [
'Academy converter',
'Community school',
'Voluntary aided school',
'Free schools',
'University technical college',
'Other independent school',
null,
undefined,
]) {
expect(isSpecialSchool({ school_type: t })).toBe(false);
}
});
});
describe('shortName', () => {
it('drops the trailing establishment-type words', () => {
expect(shortName('Barclay Primary School')).toBe('Barclay');
expect(shortName('Elmhurst Primary School')).toBe('Elmhurst');
expect(shortName("St Mary's Catholic Primary School")).toBe("St Mary's");
expect(shortName('Riverside Community Junior School')).toBe('Riverside');
});
it('keeps a name that carries no type suffix, capping very long ones', () => {
expect(shortName('Beaver Road')).toBe('Beaver Road');
expect(shortName('A'.repeat(30), 10)).toBe('AAAAAAAAA…');
});
});
describe('formatAgeSpan', () => {
it('normalises a hyphenated range to an en dash, without a label', () => {
// The place table carries "Ages" in the column heading, so repeating it
// in every cell is noise. formatAgeRange keeps the label for the contexts
// that have no heading to hang it on.
expect(formatAgeSpan('4-11')).toBe('4–11');
});
it('leaves a range it does not recognise alone rather than mangling it', () => {
expect(formatAgeSpan('3-19 (SEN)')).toBe('3-19 (SEN)');
});
it('returns an empty string for a missing range', () => {
expect(formatAgeSpan(null)).toBe('');
expect(formatAgeSpan(undefined)).toBe('');
});
});
describe('formatAgeRange', () => {
it('keeps its label, so the two helpers stay distinguishable', () => {
expect(formatAgeRange('4-11')).toBe('Ages 4–11');
});
});