Files
school_compare/nextjs-app/__tests__/lib/compareLogic.test.ts
T
TudorandClaude Opus 5.5 a42c586cc8 fix(ofsted): badge and compare read the grade still in force
Search badges date a grade by the inspection that awarded or confirmed it (ofsted_grade_date) and say 'Inspected · year' when the latest inspection gave no grade. ofstedDisplay's kinds become graded / confirmed / no_overall_grade, read from current_grade rather than a carried-forward overall_effectiveness. lib/ofstedStatus.ts holds the two sentences the school and compare pages share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:29:54 +01:00

362 lines
13 KiB
TypeScript
Raw 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.
/**
* compareLogic encodes the expert-reviewed comprehension rules for the
* compare screen: report-card summarisation (safeguarding never counted),
* three-regime Ofsted display, one consistent admissions chip metric,
* CI-based progress banding, verdict chips and dot-strip geometry.
*/
import {
MAX_PER_GROUP,
OFSTED_LEGACY_GRADES,
admissionsForPhase,
compareGroups,
fitToGroupLimits,
fullGroupFor,
ofstedDisplay,
progressBand,
rcAreaLabel,
stripPositions,
summariseAdmissions,
summariseReportCard,
verdict,
} from '@/lib/compareLogic';
import type { OfstedInspection, SchoolAdmissions } from '@/lib/types';
function ofsted(partial: Partial<OfstedInspection>): OfstedInspection {
return {
framework: null,
inspection_date: null,
inspection_type: null,
overall_effectiveness: null,
quality_of_education: null,
behaviour_attitudes: null,
personal_development: null,
leadership_management: null,
early_years_provision: null,
previous_overall: null,
rc_safeguarding_met: null,
rc_inclusion: null,
rc_curriculum_teaching: null,
rc_achievement: null,
rc_attendance_behaviour: null,
rc_personal_development: null,
rc_leadership_governance: null,
rc_early_years: null,
rc_sixth_form: null,
...partial,
};
}
const REPORT_CARD = {
rc_achievement: { code: 2, label: 'Strong standard' },
rc_curriculum_teaching: { code: 2, label: 'Strong standard' },
rc_personal_development: { code: 2, label: 'Strong standard' },
rc_leadership_governance: { code: 2, label: 'Strong standard' },
rc_inclusion: { code: 3, label: 'Expected standard' },
rc_early_years: { code: 3, label: 'Expected standard' },
rc_attendance_behaviour: { code: 4, label: 'Needs attention' },
};
describe('summariseReportCard', () => {
it('counts graded areas best-first and NAMES problem areas', () => {
const s = summariseReportCard(
ofsted({ report_card: REPORT_CARD, rc_safeguarding_met: true }),
);
expect(s.counts).toEqual([
{ label: 'Strong standard', count: 4 },
{ label: 'Expected standard', count: 2 },
]);
expect(s.problems).toEqual([
{ areaLabel: 'Attendance & behaviour', label: 'Needs attention' },
]);
expect(s.safeguarding).toBe('met');
expect(s.allClear).toBe(false);
});
it('never counts safeguarding as a graded area', () => {
const s = summariseReportCard(
ofsted({
report_card: { rc_achievement: { code: 3, label: 'Expected standard' } },
rc_safeguarding_met: true,
}),
);
const total = s.counts.reduce((n, c) => n + c.count, 0);
expect(total).toBe(1);
});
it('is allClear when everything is Expected standard or better and safeguarding met', () => {
const s = summariseReportCard(
ofsted({
report_card: {
rc_achievement: { code: 3, label: 'Expected standard' },
rc_inclusion: { code: 1, label: 'Exceptional' },
},
rc_safeguarding_met: true,
}),
);
expect(s.allClear).toBe(true);
expect(s.counts[0]).toEqual({ label: 'Exceptional', count: 1 });
});
it('passes labels through from the API — never invents wording', () => {
const s = summariseReportCard(
ofsted({ report_card: { rc_inclusion: { code: 4, label: 'Needs attention' } } }),
);
expect(JSON.stringify(s)).not.toContain('Attention needed');
});
});
describe('ofstedDisplay', () => {
it('prefers the report card over any legacy grade', () => {
const d = ofstedDisplay(
ofsted({ overall_effectiveness: 2, report_card: REPORT_CARD }),
);
expect(d.kind).toBe('report_card');
});
it('distinguishes a graded grade from a confirmed one', () => {
expect(ofstedDisplay(ofsted({ current_grade: { grade: 1, date: '2019-10-09', basis: 'graded' },
latest_visit: { date: '2025-02-05', kind: 'ungraded', outcome: 'Some aspects not as strong' } })))
.toMatchObject({ kind: 'graded', gradeLabel: 'Outstanding', gradeDate: '2019-10-09' });
expect(ofstedDisplay(ofsted({ current_grade: { grade: 2, date: '2024-07-18', basis: 'confirmed' },
latest_visit: { date: '2024-07-18', kind: 'ungraded', outcome: 'School remains Good' } })))
.toMatchObject({ kind: 'confirmed', gradeLabel: 'Good' });
});
it('handles missing data', () => {
expect(ofstedDisplay(null).kind).toBe('none');
expect(ofstedDisplay(ofsted({})).kind).toBe('none');
});
it('has no overall grade when the latest inspection gave none', () => {
// Rabbsfarm: the graded inspection's own overall is null, so no grade is
// in force even though an older ungraded visit said "remains Good".
expect(ofstedDisplay(ofsted({ current_grade: null, overall_effectiveness: null,
latest_visit: { date: '2025-06-17', kind: 'graded', outcome: null } })).kind).toBe('no_overall_grade');
});
it('uses the four legacy grade words', () => {
expect(OFSTED_LEGACY_GRADES).toEqual({
1: 'Outstanding',
2: 'Good',
3: 'Requires improvement',
4: 'Inadequate',
});
});
});
describe('rcAreaLabel', () => {
it('maps rc keys to the mockups’ area labels', () => {
expect(rcAreaLabel('rc_attendance_behaviour')).toBe('Attendance & behaviour');
expect(rcAreaLabel('rc_curriculum_teaching')).toBe('Curriculum & teaching');
expect(rcAreaLabel('rc_leadership_governance')).toBe('Leadership & governance');
});
});
describe('summariseAdmissions', () => {
function admissions(partial: Partial<SchoolAdmissions>): SchoolAdmissions {
return {
year: 202627,
places_offered: null,
total_applications: null,
first_preference_offer_pct: null,
oversubscribed: null,
...partial,
};
}
it('97% → good chip with the mockup wording', () => {
const s = summariseAdmissions(
admissions({ first_preference_offer_pct: 96.98, total_applications: 457, places_offered: 180 }),
);
expect(s.chip).toEqual({ tone: 'good', text: '97% of first choices offered' });
expect(s.interest).toBe('Named on 457 forms · 180 places');
});
it('73% → warn chip "Over 1 in 4 first choices missed out"', () => {
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 73.4 }));
expect(s.chip).toEqual({ tone: 'warn', text: 'Over 1 in 4 first choices missed out' });
});
it('60% → "About 1 in 3 first choices missed out"', () => {
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 60 }));
expect(s.chip).toEqual({ tone: 'warn', text: 'About 1 in 3 first choices missed out' });
});
it('44% (selective-scale demand) → "More than half of first choices missed out"', () => {
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 43.69 }));
expect(s.chip).toEqual({ tone: 'warn', text: 'More than half of first choices missed out' });
});
it('100% → "All first choices offered"', () => {
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 100 }));
expect(s.chip).toEqual({ tone: 'good', text: 'All first choices offered' });
});
it('no data → null chip and interest', () => {
const s = summariseAdmissions(null);
expect(s.chip).toBeNull();
expect(s.interest).toBeNull();
});
});
describe('admissionsForPhase', () => {
const row = (year: number, school_phase: string | null): SchoolAdmissions =>
({ year, school_phase, places_offered: 100, total_applications: 200, first_preference_offer_pct: 80 }) as SchoolAdmissions;
it('returns the latest round matching the active phase', () => {
const data = {
admissions: row(202627, 'Secondary'),
admissions_history: [row(202526, 'Secondary'), row(202526, 'Primary'), row(202425, 'Primary')],
};
expect(admissionsForPhase(data, true)?.year).toBe(202627);
expect(admissionsForPhase(data, false)?.year).toBe(202526);
expect(admissionsForPhase(data, false)?.school_phase).toBe('Primary');
});
it("never substitutes the other phase's round (all-through with Year 7 data only)", () => {
const data = {
admissions: row(202627, 'Secondary'),
admissions_history: [row(202526, 'Secondary')],
};
expect(admissionsForPhase(data, false)).toBeNull();
expect(admissionsForPhase(data, true)?.year).toBe(202627);
});
it('uses untagged legacy rows only when no row carries a phase', () => {
const untagged = { admissions: row(202627, null), admissions_history: [row(202526, null)] };
expect(admissionsForPhase(untagged, false)?.year).toBe(202627);
expect(admissionsForPhase(untagged, true)?.year).toBe(202627);
const mixed = { admissions: row(202627, 'Secondary'), admissions_history: [row(202526, null)] };
expect(admissionsForPhase(mixed, false)).toBeNull();
});
it('handles missing data', () => {
expect(admissionsForPhase(null, false)).toBeNull();
expect(admissionsForPhase({ admissions: null, admissions_history: [] }, true)).toBeNull();
});
});
describe('progressBand', () => {
it('CI entirely above zero → above', () => {
expect(progressBand(1.2, 0.4, 2.0)).toBe('above');
});
it('CI entirely below zero → below', () => {
expect(progressBand(-1.2, -2.0, -0.4)).toBe('below');
});
it('CI straddling zero → average', () => {
expect(progressBand(0.3, -0.5, 1.1)).toBe('average');
});
it('missing CI → null (no naive thresholding)', () => {
expect(progressBand(1.2, null, null)).toBeNull();
expect(progressBand(null, null, null)).toBeNull();
});
});
describe('verdict', () => {
it('above / close / below with a 2pp tolerance', () => {
expect(verdict(87, 62)).toBe('above');
expect(verdict(61, 62)).toBe('close');
expect(verdict(40, 62)).toBe('below');
});
});
describe('stripPositions', () => {
it('maps a custom domain', () => {
const pts = stripPositions([106], 100, 120);
expect(pts[0].pos).toBe(30);
});
it('flips a colliding label above', () => {
const pts = stripPositions([91, 92], 0, 100);
const sorted = [...pts].sort((a, b) => a.value - b.value);
expect(sorted[0].labelAbove).toBe(false);
expect(sorted[1].labelAbove).toBe(true);
});
it('skips nulls and keeps school indices', () => {
const pts = stripPositions([50, null, 70], 0, 100);
expect(pts).toHaveLength(2);
expect(pts.map((p) => p.schoolIndex)).toEqual([0, 2]);
});
it('clamps out-of-domain values', () => {
const pts = stripPositions([95], 100, 120);
expect(pts[0].pos).toBe(0);
});
});
describe('latestValues', () => {
const data = {
'1': {
yearly_data: [
{ year: 202324, rwm_expected_pct: 75 },
{ year: 202425, rwm_expected_pct: 87 },
],
},
'2': {
yearly_data: [
{ year: 202324, rwm_expected_pct: 82 },
{ year: 202425, rwm_expected_pct: null },
],
},
};
it('takes the latest non-null value per school in urn order', async () => {
const { latestValues } = await import('@/lib/compareLogic');
expect(latestValues(data, [1, 2], 'rwm_expected_pct')).toEqual([87, 82]);
});
it('returns null for unknown schools and metrics', async () => {
const { latestValues } = await import('@/lib/compareLogic');
expect(latestValues(data, [3], 'rwm_expected_pct')).toEqual([null]);
expect(latestValues(data, [1], 'nope')).toEqual([null]);
});
});
describe('basket limits per comparison group', () => {
const school = (phase: string | null, urn = 0) => ({ urn, phase });
const many = (phase: string | null, n: number, from = 0) =>
Array.from({ length: n }, (_, i) => school(phase, from + i));
it('files each phase under its compare tab', () => {
expect(compareGroups('Primary')).toEqual(['primary']);
expect(compareGroups('Middle deemed primary')).toEqual(['primary']);
expect(compareGroups('Secondary')).toEqual(['secondary']);
expect(compareGroups('Middle deemed secondary')).toEqual(['secondary']);
expect(compareGroups('16 plus')).toEqual(['secondary']);
});
it('counts schools that could land in either tab against both', () => {
expect(compareGroups('All-through')).toEqual(['primary', 'secondary']);
expect(compareGroups('Not applicable')).toEqual(['primary', 'secondary']);
expect(compareGroups(null)).toEqual(['primary', 'secondary']);
});
it('a full primary group still admits secondary schools', () => {
const basket = many('Primary', MAX_PER_GROUP);
expect(fullGroupFor(basket, school('Primary'))).toBe('primary');
expect(fullGroupFor(basket, school('Secondary'))).toBeNull();
expect(fullGroupFor(basket, school('All-through'))).toBe('primary');
});
it('allows ten schools in total, five per group', () => {
const basket = [...many('Primary', 5), ...many('Secondary', 5, 5)];
expect(fullGroupFor(basket, school('Primary'))).toBe('primary');
expect(fullGroupFor(basket, school('Secondary'))).toBe('secondary');
});
it('an all-through school uses a slot in both groups', () => {
const basket = [school('All-through'), ...many('Primary', 4, 1)];
expect(fullGroupFor(basket, school('Primary'))).toBe('primary');
expect(fullGroupFor(basket, school('Secondary'))).toBeNull();
});
it('trims an oversized shared link per group, keeping order', () => {
const urls = [...many('Primary', 7), ...many('Secondary', 3, 7)];
const kept = fitToGroupLimits(urls).map((s) => s.urn);
expect(kept).toEqual([0, 1, 2, 3, 4, 7, 8, 9]);
});
});