PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
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) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m14s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m49s
Code review found the disclosure the whole design was meant to prevent.
R1 was written as a rendering rule and implemented as one: canRenderBar
stopped the bar being drawn, but GET /api/schools/{urn} still carried the
cohort and every published category. cohort - sum(published) returned
Whitley Bay's withheld further-education figure exactly — 18 pupils — to
any caller, and the RSC payload put it in the browser too.
app.py already stated the principle for admission_distance: this endpoint
is public and unauthenticated, so a field left in the payload is a
published field. The same reasoning applies here and did not get applied.
_mask_for_disclosure now closes both identities before serialisation —
categories sum to the cohort, and disadvantaged + other = all — by adding
secondary suppression until every row and column hides none or at least
two. My first attempt picked the smallest published cell as the companion
and a new test caught it choosing a zero, which protects nothing: the
residual still resolved to 18. The companion must carry pupils.
DfE's own aggregates are no longer served. Nothing rendered them, and one
spanning a single suppressed component names it.
Cost, measured over 262 mainstream secondaries: the all-pupils bar
survives on 94% rather than 100%. Zero lone-suppressed groups remain.
The e2e helper now tells a missing feature apart from missing data: it
fails if the API serves no destinations key at all, and skips if the key
is served but the annual DAG has not populated the marts. Failing on the
second would redden the staging gate for unrelated commits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
92 lines
3.6 KiB
TypeScript
92 lines
3.6 KiB
TypeScript
import { render, screen } from '@testing-library/react';
|
|
import { DestinationsSection } from '@/components/school/DestinationsSection';
|
|
import type { DestinationPhase } from '@/lib/types';
|
|
import type { DestinationCategory, DestinationStatus } from '@/lib/destinations';
|
|
|
|
const cell = (
|
|
category: DestinationCategory,
|
|
pupils: number | null,
|
|
status: DestinationStatus = 'published',
|
|
) => ({
|
|
category, pupils,
|
|
percentage: pupils === null ? null : (pupils / 180) * 100,
|
|
status,
|
|
});
|
|
|
|
const ALL_PUBLISHED = [
|
|
cell('school_sixth_form', 75), cell('sixth_form_college', 21),
|
|
cell('further_education', 55), cell('other_education', 6),
|
|
cell('apprenticeship', 8), cell('employment', 6),
|
|
cell('not_sustained', 5), cell('not_captured', 4),
|
|
];
|
|
|
|
const fullPhase: DestinationPhase = {
|
|
cohort_year: '2022/23',
|
|
groups: { all: { cohort: 180, categories: ALL_PUBLISHED } },
|
|
};
|
|
|
|
const suppressedPhase: DestinationPhase = {
|
|
cohort_year: '2022/23',
|
|
groups: {
|
|
all: {
|
|
cohort: 180,
|
|
categories: [
|
|
cell('school_sixth_form', 75), cell('sixth_form_college', null, 'suppressed'),
|
|
cell('further_education', 55), cell('other_education', 6),
|
|
cell('apprenticeship', 8), cell('employment', 6),
|
|
cell('not_sustained', 5), cell('not_captured', 4),
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
describe('DestinationsSection', () => {
|
|
it('dates its own cohort so it is not read as stale next to the GCSE section', () => {
|
|
render(<DestinationsSection destinations={fullPhase} />);
|
|
expect(screen.getByText(/2022\/23/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders one bar segment per published category', () => {
|
|
const { container } = render(<DestinationsSection destinations={fullPhase} />);
|
|
expect(container.querySelectorAll('[data-destination-segment]')).toHaveLength(8);
|
|
});
|
|
|
|
it('renders NO bar at all when a category is withheld', () => {
|
|
const { container } = render(<DestinationsSection destinations={suppressedPhase} />);
|
|
// R1: a bar with a gap in it publishes the withheld figure by its width.
|
|
expect(container.querySelectorAll('[data-destination-segment]')).toHaveLength(0);
|
|
expect(screen.getAllByText(/withheld/i).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('never states the remainder for a partially suppressed group', () => {
|
|
const { container } = render(<DestinationsSection destinations={suppressedPhase} />);
|
|
// 180 cohort - 159 published = 21, the withheld figure. It must appear nowhere.
|
|
expect(container.textContent).not.toMatch(/\b21\b/);
|
|
});
|
|
|
|
it('shows a card value for a group whose components are all published', () => {
|
|
render(<DestinationsSection destinations={fullPhase} />);
|
|
// academic route = 75 + 21 = 96 of 180 = 53%
|
|
expect(screen.getByText('53%')).toBeInTheDocument();
|
|
});
|
|
|
|
it('refuses a card value when one of its components is withheld', () => {
|
|
render(<DestinationsSection destinations={suppressedPhase} />);
|
|
// academic route needs sixth_form_college, which is suppressed.
|
|
expect(screen.getByText(/not published/i)).toBeInTheDocument();
|
|
expect(screen.queryByText('53%')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('never claims a pupil stayed at this school', () => {
|
|
const { container } = render(<DestinationsSection destinations={fullPhase} />);
|
|
// The published file reports destination TYPE, never destination institution.
|
|
expect(container.textContent).not.toMatch(/stayed on (here|at this school)/i);
|
|
});
|
|
|
|
it('renders nothing when no group carries categories', () => {
|
|
const empty: DestinationPhase = { cohort_year: '2022/23', groups: {} };
|
|
const { container } = render(<DestinationsSection destinations={empty} />);
|
|
expect(container.firstChild).toBeNull();
|
|
});
|
|
});
|