feat(destinations): say what happened to a school's leavers, without republishing what DfE withheld #137

Merged
tudor merged 16 commits from feat/ks4-destinations into main 2026-08-30 20:49:15 +00:00
Showing only changes of commit ccd5074c90 - Show all commits
+122
View File
@@ -2366,3 +2366,125 @@ test('with autosuggest off, the search box is a plain input', async ({ page }) =
await page.getByRole('button', { name: /Search/i }).first().click();
await expect(page).toHaveURL(/search=abbey/);
});
// ── Destination measures ───────────────────────────────────────────────────
//
// These journeys need marts.fact_ks4_destinations to be populated, which only
// happens after the annual EES DAG runs. Until then the helper below fails the
// suite loudly rather than skipping: a silent skip here would let a genuine
// regression in the sections ride along unnoticed, which is exactly what the
// distance journeys were changed to avoid.
async function secondaryWithDestinations(page: Page): Promise<{
urn: string; destinations: any;
}> {
const res = await page.request.get('/api/schools?search=school&per_page=100');
expect(res.ok()).toBeTruthy();
const body = await res.json();
const urns: string[] = (body.schools ?? [])
.filter((s: { phase?: string; attainment_8_score?: number | null }) =>
s.phase === 'Secondary' && s.attainment_8_score != null)
.map((s: { urn: number }) => String(s.urn));
for (const urn of urns.slice(0, 25)) {
const detail = await page.request.get(`/api/schools/${urn}`);
if (!detail.ok()) continue;
const data = await detail.json();
if (data.destinations?.ks4) return { urn, destinations: data.destinations };
}
throw new Error(
'No secondary school returned a destinations block. Either the annual EES '
+ 'DAG has not run on this environment, or the destinations marts are empty.',
);
}
test('a secondary school page says where its Year 11 leavers went', async ({ page }) => {
const { urn } = await secondaryWithDestinations(page);
await page.goto(`/school/${urn}`);
const section = page.locator('#destinations');
await expect(section).toBeVisible({ timeout: 15_000 });
await expect(section.getByRole('heading', { name: 'After Year 11' })).toBeVisible();
// The section must date its own cohort: destinations run about two GCSE
// years behind the results above them, and an undated figure reads as stale.
await expect(section).toContainText(/20\d{2}\/\d{2}/);
});
test('the destinations bar is absent entirely whenever a figure is withheld', async ({ page }) => {
const { urn, destinations } = await secondaryWithDestinations(page);
await page.goto(`/school/${urn}`);
const section = page.locator('#destinations');
await expect(section).toBeVisible({ timeout: 15_000 });
const allGroup = destinations.ks4.groups.all;
const suppressed = (allGroup?.categories ?? [])
.filter((c: { status: string }) => c.status === 'suppressed');
if (suppressed.length > 0) {
// R1: a bar drawn from the published segments leaves a gap whose width is
// the withheld figure, readable straight off the axis.
await expect(section.locator('[data-destination-segment]')).toHaveCount(0);
await expect(section.getByText(/withheld/i).first()).toBeVisible();
} else {
const published = (allGroup?.categories ?? [])
.filter((c: { status: string }) => c.status === 'published');
await expect(section.locator('[data-destination-segment]'))
.toHaveCount(published.length);
}
});
test('switching to disadvantaged pupils never reveals a withheld figure', async ({ page }) => {
const { urn, destinations } = await secondaryWithDestinations(page);
const disadvantaged = destinations.ks4.groups.disadvantaged;
test.skip(!disadvantaged, 'this school publishes no disadvantaged breakdown');
await page.goto(`/school/${urn}`);
const section = page.locator('#destinations');
await expect(section).toBeVisible({ timeout: 15_000 });
const radio = section.getByRole('radio', { name: /disadvantaged/i });
await expect(radio).toBeVisible();
await radio.click();
const suppressed = (disadvantaged.categories ?? [])
.filter((c: { status: string }) => c.status === 'suppressed');
if (suppressed.length > 0) {
await expect(section.locator('[data-destination-segment]')).toHaveCount(0);
// The residual must appear nowhere on the page — it is the withheld figure.
const cohort: number = disadvantaged.cohort;
const publishedTotal = (disadvantaged.categories ?? [])
.filter((c: { status: string }) => c.status === 'published')
.reduce((sum: number, c: { pupils: number }) => sum + c.pupils, 0);
const residual = cohort - publishedTotal;
const text = (await section.textContent()) ?? '';
expect(text).not.toMatch(new RegExp(`\\b${residual}\\b`));
}
});
test('a school with no sixth form has no post-16 destinations section', async ({ page }) => {
const res = await page.request.get('/api/schools?search=school&per_page=100');
const body = await res.json();
const noSixthForm = (body.schools ?? [])
.filter((s: { phase?: string; has_sixth_form?: boolean }) =>
s.phase === 'Secondary' && s.has_sixth_form === false)
.map((s: { urn: number }) => String(s.urn));
test.skip(noSixthForm.length === 0, 'no sixth-form-less secondary in this dataset');
await page.goto(`/school/${noSixthForm[0]}`);
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
// Absence is the correct statement, so there must be no placeholder either.
await expect(page.locator('#post16-destinations')).toHaveCount(0);
await expect(page.getByText(/destination data coming soon/i)).toHaveCount(0);
});
test('the destinations section never claims a pupil stayed at this school', async ({ page }) => {
const { urn } = await secondaryWithDestinations(page);
await page.goto(`/school/${urn}`);
const section = page.locator('#destinations');
await expect(section).toBeVisible({ timeout: 15_000 });
// The published file records the TYPE of place a leaver went to, never which
// one, so the page can never say a pupil stayed on here.
const text = (await section.textContent()) ?? '';
expect(text).not.toMatch(/stayed on (here|at this school)/i);
});