fix: don’t portray special schools as failing the mainstream benchmark #70

Merged
tudor merged 3 commits from fix/special-schools-not-failing into main 2026-07-20 21:14:57 +00:00
9 changed files with 134 additions and 79 deletions
Showing only changes of commit 0b36eace9d - Show all commits
@@ -0,0 +1,33 @@
/**
* SpecialSchoolNote Component Tests
*/
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { SpecialSchoolNote } from '@/components/SpecialSchoolNote';
describe('SpecialSchoolNote', () => {
it('renders nothing for a mainstream school', () => {
const { container } = render(<SpecialSchoolNote school={{ school_type: 'Academy converter' }} />);
expect(container).toBeEmptyDOMElement();
});
it('describes a special school as teaching pupils with SEND', () => {
render(<SpecialSchoolNote school={{ school_type: 'Community special school' }} />);
expect(screen.getByText(/This is a special school/i)).toBeInTheDocument();
expect(screen.getByText(/special educational needs/i)).toBeInTheDocument();
});
it('does NOT call PRU pupils SEND — they are educated outside a mainstream school', () => {
render(<SpecialSchoolNote school={{ school_type: 'Pupil referral unit' }} />);
expect(screen.getByText(/This is a pupil referral unit/i)).toBeInTheDocument();
expect(screen.getByText(/educated outside a mainstream school/i)).toBeInTheDocument();
expect(screen.queryByText(/special educational needs/i)).not.toBeInTheDocument();
});
it('does NOT call alternative-provision pupils SEND', () => {
render(<SpecialSchoolNote school={{ school_type: 'Academy alternative provision converter' }} />);
expect(screen.getByText(/This is an alternative provision setting/i)).toBeInTheDocument();
expect(screen.queryByText(/special educational needs/i)).not.toBeInTheDocument();
});
});
@@ -1558,21 +1558,3 @@
.closingStrip strong {
color: #8a6200;
}
/* Special-school context note: soft, informational (teal), not a warning. It
explains why the mainstream attainment measures and England comparison are
dropped for special schools / PRUs / AP. */
.specialNote {
background: var(--bg-secondary, #f3ede4);
border-left: 4px solid var(--accent-teal, #2d7d7d);
border-radius: 0 6px 6px 0;
padding: 0.7rem 1rem;
margin: 0 0 1rem;
font-size: 0.9rem;
line-height: 1.5;
color: var(--text-secondary, #5c564d);
max-width: 72ch;
}
.specialNote strong {
color: var(--text-primary, #1a1612);
}
+5 -14
View File
@@ -21,6 +21,7 @@ import {
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas, isSpecialSchool,
} from '@/lib/utils';
import { DeltaChip } from './DeltaChip';
import { SpecialSchoolNote } from './SpecialSchoolNote';
import { summariseAdmissions } from '@/lib/compareLogic';
const PerformanceChart = dynamic(
@@ -656,20 +657,10 @@ export function SchoolDetailView({
: 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'}
</p>
{/* Special schools sit the same national assessments but teach pupils
with SEND, so very few reach the mainstream "expected standard".
Explain that up front and drop the England comparison below, so a
0% headline never reads as a failing grade against a benchmark
that doesn't fit the school. */}
{isSpecial && (
<div className={styles.specialNote} role="note">
<strong>This is a special school.</strong> Its pupils have special educational
needs and work towards individual targets. They sit the same national tests, but
very few reach the mainstream &ldquo;expected standard&rdquo; these measures report,
so a comparison with the England average isn&rsquo;t a meaningful guide to the
school. Where available, the progress pupils make (below) is a fairer measure.
</div>
)}
{/* Explains up front why the England comparison is dropped below, so
a 0% headline never reads as a failing grade against a benchmark
that doesn't fit. Type-aware copy (special vs PRU vs AP). */}
<SpecialSchoolNote school={schoolInfo} />
{/* ── Primary / KS2 content ── */}
{hasKS2Results && (
+12 -14
View File
@@ -38,24 +38,22 @@ export function SchoolRow({
school.religious_denomination &&
school.religious_denomination !== 'Does not apply';
// Special schools / PRUs / AP: the mainstream RWM measure and its England
// comparison aren't a fair judgement (their pupils have SEND), so a "0% ·
// 62 vs national" row misrepresents them. Also guard a placeholder all-zero
// row (every subject 0 — a special/suppressed signature), matching
// SchoolDetailView's ks2Placeholder. A genuine 0% combined (some pupils met
// individual subjects but not all three) is NOT all-zero, so it stays
// comparable and shows its real figure.
// A placeholder all-zero row (every subject 0 — a special/suppressed
// signature, matching SchoolDetailView's ks2Placeholder) isn't a real score,
// so its figure is hidden. A genuine 0% combined (some pupils met individual
// subjects but not all three) is NOT all-zero and stays shown.
const rwmPlaceholder =
school.rwm_expected_pct === 0 &&
(school.reading_expected_pct ?? 0) === 0 &&
(school.writing_expected_pct ?? 0) === 0 &&
(school.maths_expected_pct ?? 0) === 0;
const rwmComparable =
school.rwm_expected_pct != null && !rwmPlaceholder && !isSpecialSchool(school);
// vs-national delta
// The school's OWN figure and its year-over-year trend are same-school
// measures — shown whenever there's a real value, special schools included.
const showRwmValue = school.rwm_expected_pct != null && !rwmPlaceholder;
// The vs-England delta is a mainstream benchmark: additionally dropped for
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
const rwmDelta =
rwmComparable && nationalAvgRwm != null
showRwmValue && !isSpecialSchool(school) && nationalAvgRwm != null
? Math.round((school.rwm_expected_pct as number) - nationalAvgRwm)
: null;
@@ -102,9 +100,9 @@ export function SchoolRow({
<div className={styles.line3}>
<span className={styles.stat}>
<strong className={styles.statValue}>
{rwmComparable ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
{showRwmValue ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
</strong>
{rwmComparable && school.prev_rwm_expected_pct != null && (
{showRwmValue && school.prev_rwm_expected_pct != null && (
<span
className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`}
title={`Previous year: ${formatPercentage(school.prev_rwm_expected_pct)}`}
@@ -395,22 +395,6 @@
line-height: 1.5;
}
/* Special-school context note: soft, informational (teal), not a warning. */
.specialNote {
background: var(--bg-secondary, #f3ede4);
border-left: 4px solid var(--accent-teal, #2d7d7d);
border-radius: 0 6px 6px 0;
padding: 0.7rem 1rem;
margin: 0 0 1rem;
font-size: 0.9rem;
line-height: 1.5;
color: var(--text-secondary, #5c564d);
max-width: 72ch;
}
.specialNote strong {
color: var(--text-primary, #1a1612);
}
/* ── Metrics Grid & Cards ────────────────────────────── */
.metricsGrid {
display: grid;
@@ -25,6 +25,7 @@ import type {
} from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils';
import { DeltaChip } from './DeltaChip';
import { SpecialSchoolNote } from './SpecialSchoolNote';
import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SecondarySchoolDetailView.module.css';
@@ -465,14 +466,7 @@ export function SecondarySchoolDetailView({
GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}
</p>
{isSpecial && (
<div className={styles.specialNote} role="note">
<strong>This is a special school.</strong> Its pupils have special educational
needs and work towards individual targets. They sit the same GCSEs, but their
headline attainment is far below the mainstream average by design, so a comparison
with the England average isn&rsquo;t a meaningful guide to the school.
</div>
)}
<SpecialSchoolNote school={schoolInfo} />
{p8Suspended && (
<div className={styles.p8Banner}>
+8 -9
View File
@@ -55,15 +55,14 @@ export function SecondarySchoolRow({
const ofstedBadge = buildOfstedListBadge(school);
const phase = getPhaseStyle(school.phase);
const att8 = school.attainment_8_score;
// Special schools / PRUs / AP: Attainment 8 vs the LA average isn't a fair
// comparison (their pupils have SEND), so drop the delta and the number
// rather than show them trailing "the average" by design. Attainment 8 is a
// single 080 score with no subject breakdown to test for a placeholder, so
// this keys off establishment type only — a genuine (if extreme) 0.0 at a
// mainstream school still shows its real value.
const att8Comparable = att8 != null && !isSpecialSchool(school);
// The school's own Attainment 8 is a same-school figure — shown whenever it
// exists (special schools included; their type tag on line 2 gives context).
// Only the vs-LA-average delta, a benchmark comparison, is dropped for
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
const laDelta =
att8Comparable && laAvgAttainment8 != null ? (att8 as number) - laAvgAttainment8 : null;
att8 != null && !isSpecialSchool(school) && laAvgAttainment8 != null
? att8 - laAvgAttainment8
: null;
const admissionsTag = detectAdmissionsTag(school);
const sixthForm = hasSixthForm(school);
@@ -113,7 +112,7 @@ export function SecondarySchoolRow({
<div className={styles.line3}>
<span className={styles.stat}>
<strong className={styles.statValueLarge}>
{att8Comparable ? (att8 as number).toFixed(1) : '—'}
{att8 != null ? att8.toFixed(1) : '—'}
</strong>
<span className={styles.statLabel}>Attainment 8</span>
</span>
@@ -0,0 +1,16 @@
/* Special-school / PRU / AP context note: soft, informational (teal), not a
warning. Shared by both detail views so the styling can't drift. */
.note {
background: var(--bg-secondary, #f3ede4);
border-left: 4px solid var(--accent-teal, #2d7d7d);
border-radius: 0 6px 6px 0;
padding: 0.7rem 1rem;
margin: 0 0 1rem;
font-size: 0.9rem;
line-height: 1.5;
color: var(--text-secondary, #5c564d);
max-width: 72ch;
}
.note strong {
color: var(--text-primary, #1a1612);
}
@@ -0,0 +1,58 @@
/**
* SpecialSchoolNote — the context note shown on special-school / PRU / AP
* detail pages explaining why the mainstream England-average comparison is
* dropped. Renders nothing for mainstream schools.
*
* The copy is type-aware: only genuine special schools have pupils with special
* educational needs. Pupil referral units and alternative provision teach
* pupils educated outside a mainstream setting (e.g. after exclusion, or for
* medical reasons) who are not necessarily SEND — so their note says so rather
* than mischaracterising them. Suppressing the England comparison is reasonable
* for all three.
*/
import { isSpecialSchool } from '@/lib/utils';
import styles from './SpecialSchoolNote.module.css';
type SpecialKind = 'special' | 'pru' | 'ap';
function specialKind(schoolType: string | null | undefined): SpecialKind {
const t = (schoolType ?? '').toLowerCase();
if (/pupil referral/.test(t)) return 'pru';
if (/alternative provision/.test(t)) return 'ap';
return 'special';
}
export function SpecialSchoolNote({ school }: { school: { school_type?: string | null } }) {
if (!isSpecialSchool(school)) return null;
const kind = specialKind(school.school_type);
return (
<div className={styles.note} role="note">
{kind === 'special' && (
<>
<strong>This is a special school.</strong> Its pupils have special educational needs and
work towards individual targets. They sit the same national assessments, but very few
reach the mainstream &ldquo;expected standard&rdquo; these measures report so a
comparison with the England average isn&rsquo;t a meaningful guide to the school.
</>
)}
{kind === 'pru' && (
<>
<strong>This is a pupil referral unit.</strong> It teaches pupils educated outside a
mainstream school for example after exclusion, or for medical or behavioural reasons.
The mainstream &ldquo;expected standard&rdquo; and the England-average comparison
aren&rsquo;t a meaningful guide to the school.
</>
)}
{kind === 'ap' && (
<>
<strong>This is an alternative provision setting.</strong> It teaches pupils educated
outside a mainstream school. The mainstream &ldquo;expected standard&rdquo; and the
England-average comparison aren&rsquo;t a meaningful guide to the school.
</>
)}{' '}
Where available, the progress its pupils make is a fairer measure.
</div>
);
}