PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
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) Canceled after 1m6s
PR Checks / Build Pipeline (no push) (pull_request) Canceled after 0s
PR Checks / AI Code Review (Claude) (pull_request) Canceled after 0s
Review of the per-phase limit found entries reaching the basket with no phase, and a phase-less entry holds a slot in both groups: - Nearby-school cards added without one. The API now returns each peer's own phase (its pool is a phase group, so an all-through school can sit beside a primary); the button passes it through, and an older API simply leaves the conservative both-groups count in place. - Baskets saved before this change were never migrated. The compare page now backfills missing phases from the data it already fetches, and never overwrites a phase the basket has. - "16 plus" counted against both groups; it is secondary, as the API's PHASE_GROUPS files it. Also rewraps the HomeView doc comment the previous commit left awkward. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
365 lines
13 KiB
TypeScript
365 lines
13 KiB
TypeScript
/**
|
||
* 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 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');
|
||
});
|
||
|
||
it('identifies transitional inspections without overall grades', () => {
|
||
const transitional = ofstedDisplay(
|
||
ofsted({ overall_effectiveness: null, inspection_date: '2024-11-05' }),
|
||
);
|
||
expect(transitional.kind).toBe('transitional');
|
||
});
|
||
|
||
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]);
|
||
});
|
||
});
|