feat(compare): comprehension logic (report cards, admissions, verdicts, strips)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* 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,
|
||||
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('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('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('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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user