PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m57s
The grid sections (At a glance, Ofsted, Getting a place, Who goes there) collapsed generically on mobile — grey label pills, full names wrapping to 3 lines, no dots — making the page ~2x the mockup's height and 'significantly different' from the mobile design. Each measure is now wrapped in a <Measure> that is display:contents on desktop (so the label + cells still flow into the shared aligned grid, unchanged) and a white card on mobile with compact [dot][short name] [value] rows — matching the mobile mockup. The sticky school bar becomes scrollable short-name pills on mobile too. Adds a shortName() util. Desktop layout is unchanged (display:contents dissolves the wrapper). Validated the card mechanism and real content shapes (report-card cell, badges, %+chip rows) via static previews at both widths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
241 lines
8.1 KiB
TypeScript
241 lines
8.1 KiB
TypeScript
/**
|
|
* Utility Functions Tests
|
|
*/
|
|
|
|
import {
|
|
formatPercentage,
|
|
formatProgress,
|
|
calculateTrend,
|
|
isValidPostcode,
|
|
debounce,
|
|
buildOfstedListBadge,
|
|
metricKind,
|
|
shortName,
|
|
computeYBounds,
|
|
} 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('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 Report Card badge when framework is ReportCard', () => {
|
|
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: '2025-11-01', ofsted_framework: 'ReportCard' });
|
|
expect(badge.label).toBe('Report Card · 2025');
|
|
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('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…');
|
|
});
|
|
});
|