diff --git a/nextjs-app/__tests__/lib/destinations.test.ts b/nextjs-app/__tests__/lib/destinations.test.ts new file mode 100644 index 0000000..81d1ef0 --- /dev/null +++ b/nextjs-app/__tests__/lib/destinations.test.ts @@ -0,0 +1,108 @@ +import { + canAggregate, aggregateCells, canRenderPublishedAggregate, + canRenderBar, toBarSegments, CARD_GROUPS, + type DestinationCell, type DestinationGroup, type DestinationCategory, +} from '@/lib/destinations'; + +const pub = (category: DestinationCategory, pupils: number, cohort: number): DestinationCell => ({ + category, pupils, percentage: (pupils / cohort) * 100, status: 'published', +}); +const sup = (category: DestinationCategory): DestinationCell => ({ + category, pupils: null, percentage: null, status: 'suppressed', +}); + +const fullGroup = (): DestinationGroup => ({ + cohort: 180, + cells: [ + pub('school_sixth_form', 75, 180), pub('sixth_form_college', 21, 180), + pub('further_education', 55, 180), pub('other_education', 6, 180), + pub('apprenticeship', 8, 180), pub('employment', 6, 180), + pub('not_sustained', 5, 180), pub('not_captured', 4, 180), + ], + aggregates: {}, +}); + +describe('canAggregate — R2, computing from components', () => { + it('allows a sum when every component is published', () => { + expect(canAggregate([pub('apprenticeship', 8, 180), pub('employment', 6, 180)])).toBe(true); + }); + + it('refuses a sum when any component is suppressed', () => { + expect(canAggregate([pub('apprenticeship', 8, 180), sup('employment')])).toBe(false); + }); + + it('refuses a sum when every component is suppressed', () => { + expect(canAggregate([sup('apprenticeship'), sup('employment')])).toBe(false); + }); +}); + +describe('aggregateCells', () => { + it('sums published cells and derives a percentage from the cohort', () => { + expect(aggregateCells([pub('apprenticeship', 8, 180), pub('employment', 6, 180)], 180)) + .toEqual({ pupils: 14, percentage: (14 / 180) * 100 }); + }); + + it('returns null rather than a partial sum when a component is suppressed', () => { + expect(aggregateCells([pub('apprenticeship', 8, 180), sup('employment')], 180)).toBeNull(); + }); +}); + +describe('canRenderPublishedAggregate — R2, a total DfE published itself', () => { + it('allows it when no component is suppressed', () => { + expect(canRenderPublishedAggregate([ + pub('school_sixth_form', 75, 180), pub('sixth_form_college', 21, 180), + ])).toBe(true); + }); + + it('REFUSES it when exactly one component is suppressed — the aggregate identifies it', () => { + expect(canRenderPublishedAggregate([ + pub('school_sixth_form', 75, 180), sup('sixth_form_college'), + ])).toBe(false); + }); + + it('allows it when two or more components are suppressed', () => { + expect(canRenderPublishedAggregate([ + sup('school_sixth_form'), sup('sixth_form_college'), + ])).toBe(true); + }); +}); + +describe('canRenderBar — R1', () => { + it('allows a bar when the whole group is published', () => { + expect(canRenderBar(fullGroup())).toBe(true); + }); + + it('refuses a bar when a single category is suppressed', () => { + const g = fullGroup(); + g.cells[1] = sup('sixth_form_college'); + expect(canRenderBar(g)).toBe(false); + }); +}); + +describe('toBarSegments', () => { + it('derives widths from counts, not from rounded percentages', () => { + const segs = toBarSegments(fullGroup()); + expect(segs).toHaveLength(8); + expect(segs[0].widthPct).toBeCloseTo((75 / 180) * 100, 10); + expect(segs.reduce((a, s) => a + s.widthPct, 0)).toBeCloseTo(100, 6); + }); + + it('throws rather than silently leaving a gap when the group is suppressed', () => { + const g = fullGroup(); + g.cells[1] = sup('sixth_form_college'); + expect(() => toBarSegments(g)).toThrow(/suppressed/i); + }); +}); + +describe('CARD_GROUPS', () => { + it('partitions every destination category exactly once, plus the absence', () => { + const grouped = Object.values(CARD_GROUPS).flat(); + expect(new Set(grouped).size).toBe(grouped.length); + expect(grouped).toEqual(expect.arrayContaining([ + 'school_sixth_form', 'sixth_form_college', 'further_education', + 'other_education', 'apprenticeship', 'employment', + ])); + expect(grouped).not.toContain('not_sustained'); + expect(grouped).not.toContain('not_captured'); + }); +}); diff --git a/nextjs-app/lib/destinations.ts b/nextjs-app/lib/destinations.ts new file mode 100644 index 0000000..aca8996 --- /dev/null +++ b/nextjs-app/lib/destinations.ts @@ -0,0 +1,155 @@ +/** + * Destination measures — categories, the card grouping, and the disclosure + * guards. + * + * DfE suppresses individual cells with `c`, and the destination categories sum + * to the cohort. So subtracting the published cells from the cohort total + * recovers a lone suppressed cell exactly — which is the case on 22% of + * mainstream secondaries. The guards below are what stop this module's + * consumers doing that by accident, and they are why a percentage is never + * reconstructed from a partial sum. + * + * See docs/superpowers/specs/2026-08-28-destination-measures-design.md. + */ + +export type DestinationCategory = + | 'school_sixth_form' + | 'sixth_form_college' + | 'further_education' + | 'other_education' + | 'apprenticeship' + | 'employment' + | 'not_sustained' + | 'not_captured' + // 16-18 only. + | 'higher_education'; + +export type PupilGroup = 'all' | 'disadvantaged' | 'other'; + +export type DestinationStatus = 'published' | 'suppressed' | 'not_applicable'; + +export type CardGroup = 'academic' | 'college' | 'work'; + +export interface DestinationCell { + category: DestinationCategory; + pupils: number | null; + percentage: number | null; + status: DestinationStatus; +} + +export interface DestinationGroup { + cohort: number; + cells: DestinationCell[]; + /** Aggregates DfE published itself, keyed by slug. */ + aggregates: Partial>; +} + +/** Display order, which is also bar order: education, then work, then absence. */ +export const CATEGORY_ORDER: DestinationCategory[] = [ + 'higher_education', + 'school_sixth_form', 'sixth_form_college', 'further_education', 'other_education', + 'apprenticeship', 'employment', 'not_sustained', 'not_captured', +]; + +/** + * Our grouping, not DfE's — the single most arguable thing on the page, which + * is why it lives in exactly one place. `not_sustained` and `not_captured` are + * deliberately absent: they are the absence of a destination, not a route, and + * "activity not captured" includes independent schools and moving abroad. + */ +export const CARD_GROUPS: Record = { + academic: ['higher_education', 'school_sixth_form', 'sixth_form_college'], + college: ['further_education', 'other_education'], + work: ['apprenticeship', 'employment'], +}; + +export function suppressedCount(cells: DestinationCell[]): number { + return cells.filter(c => c.status === 'suppressed').length; +} + +/** R2: a sum computed from components is safe only if every component is published. */ +export function canAggregate(cells: DestinationCell[]): boolean { + return cells.length > 0 && cells.every(c => c.status === 'published'); +} + +export function aggregateCells( + cells: DestinationCell[], cohort: number, +): { pupils: number; percentage: number } | null { + if (!canAggregate(cells) || cohort <= 0) return null; + const pupils = cells.reduce((sum, c) => sum + (c.pupils ?? 0), 0); + return { pupils, percentage: (pupils / cohort) * 100 }; +} + +/** + * R2, the other direction: DfE published this total itself. Showing it beside + * the components is safe only when it spans no suppressed component, or two or + * more. Exactly one, and the total names the withheld figure. + */ +export function canRenderPublishedAggregate(components: DestinationCell[]): boolean { + return suppressedCount(components) !== 1; +} + +/** R1: a bar is drawable only when nothing in the group is withheld. */ +export function canRenderBar(group: DestinationGroup): boolean { + return group.cohort > 0 && group.cells.every(c => c.status === 'published'); +} + +export interface BarSegment { + category: DestinationCategory; + pupils: number; + /** Exact width from the count — never the rounded percentage. */ + widthPct: number; + /** Rounded value for the segment label. */ + labelPct: number; +} + +export function toBarSegments(group: DestinationGroup): BarSegment[] { + if (!canRenderBar(group)) { + throw new Error( + 'toBarSegments: refusing to draw a bar for a group with suppressed categories — ' + + 'the gap left behind would disclose the withheld figure (R1).', + ); + } + const byCategory = new Map(group.cells.map(c => [c.category, c])); + return CATEGORY_ORDER.flatMap(category => { + const cell = byCategory.get(category); + if (!cell || cell.pupils === null) return []; + const widthPct = (cell.pupils / group.cohort) * 100; + return [{ category, pupils: cell.pupils, widthPct, labelPct: Math.round(widthPct) }]; + }); +} + +export const CATEGORY_LABELS: Record = { + higher_education: 'UK higher education', + school_sixth_form: 'State-funded school sixth form', + sixth_form_college: 'Sixth-form college', + further_education: 'FE and other colleges', + other_education: 'Other education destination', + apprenticeship: 'Apprenticeship', + employment: 'Employment', + not_sustained: 'Not recorded as a sustained destination', + not_captured: 'Activity not captured', +}; + +export const CARD_QUESTIONS: Record = { + academic: { + question: 'Do leavers stay on an academic route?', + hint: 'a school sixth form or a sixth-form college', + }, + college: { + question: 'Or move to a college?', + hint: 'an FE or other college', + }, + work: { + question: 'Or straight into work?', + hint: 'an apprenticeship or a job', + }, +}; + +/** Which card a category belongs to, or null for the two absence categories. */ +export function cardGroupFor(category: DestinationCategory): CardGroup | null { + for (const [group, categories] of Object.entries(CARD_GROUPS) as [CardGroup, DestinationCategory[]][]) { + if (categories.includes(category)) return group; + } + return null; +}