2026-07-13 19:17:15 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* 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 {
|
|
|
|
|
|
OFSTED_LEGACY_GRADES,
|
2026-07-17 12:40:42 +01:00
|
|
|
|
admissionsForPhase,
|
2026-07-13 19:17:15 +01:00
|
|
|
|
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 graded from carried-forward grades', () => {
|
|
|
|
|
|
const graded = ofstedDisplay(
|
|
|
|
|
|
ofsted({ overall_effectiveness: 1, grade_source: 'graded' }),
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(graded).toMatchObject({ kind: 'graded', gradeLabel: 'Outstanding', carriedForward: false });
|
|
|
|
|
|
|
|
|
|
|
|
const carried = ofstedDisplay(
|
|
|
|
|
|
ofsted({ overall_effectiveness: 2, grade_source: 'ungraded_carried_forward' }),
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(carried).toMatchObject({ kind: 'carried_forward', gradeLabel: 'Good', carriedForward: true });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('handles missing data', () => {
|
|
|
|
|
|
expect(ofstedDisplay(null).kind).toBe('none');
|
|
|
|
|
|
expect(ofstedDisplay(ofsted({})).kind).toBe('none');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-15 17:23:40 +01:00
|
|
|
|
it('identifies transitional inspections without overall grades', () => {
|
|
|
|
|
|
const transitional = ofstedDisplay(
|
|
|
|
|
|
ofsted({ overall_effectiveness: null, inspection_date: '2024-11-05' }),
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(transitional.kind).toBe('transitional');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-13 19:17:15 +01:00
|
|
|
|
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' });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-17 17:41:12 +01:00
|
|
|
|
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' });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-13 19:17:15 +01:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-17 12:40:42 +01:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-13 19:17:15 +01:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-07-13 23:50:29 +01:00
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|