fix(compare): render all-secondary comparisons — re-run phase detection after basket hydration

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
This commit is contained in:
Tudor
2026-07-16 14:42:09 +01:00
co-authored by Claude Fable 5
parent a86a2be96c
commit 026a7ab6aa
3 changed files with 111 additions and 1 deletions
+30
View File
@@ -40,6 +40,19 @@ async function twoPrimaryUrns(page: Page): Promise<[string, string]> {
return [urns[0], urns[1]]; return [urns[0], urns[1]];
} }
async function twoSecondaryUrns(page: Page): Promise<[string, string]> {
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));
expect(urns.length).toBeGreaterThanOrEqual(2);
return [urns[0], urns[1]];
}
test('home page loads with hero search', async ({ page }) => { test('home page loads with hero search', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await expect(page.locator('h1').first()).toBeVisible(); await expect(page.locator('h1').first()).toBeVisible();
@@ -199,6 +212,23 @@ test('comparing two schools shows the parent-first sections side by side', async
} }
}); });
test('comparing two secondary schools renders the secondary sections', async ({ page }) => {
const [urn0, urn1] = await twoSecondaryUrns(page);
await page.goto(`/compare?urns=${urn0},${urn1}`);
await expect(page.locator(`a[href*="${urn0}"]`).first()).toBeVisible({ timeout: 15_000 });
// The parent-first sections must render — this page was completely blank
// for all-secondary baskets (expert review must-fix #1).
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
timeout: 15_000,
});
await expect(page.getByRole('heading', { name: 'Ofsted inspection' }).first()).toBeVisible();
// A KS4 measure proves the secondary academics variant rendered.
await expect(page.getByText(/Attainment 8/i).first()).toBeVisible();
await expect(page.getByText(/No primary schools in your comparison/)).toHaveCount(0);
});
test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => { test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
@@ -0,0 +1,76 @@
/**
* Regression: an all-secondary comparison must render the secondary sections.
*
* The basket hydrates from the URL a beat after mount, so the auto-phase
* effect must re-run once selectedSchools arrives — with deps of only
* [comparisonData] it fired once against an empty basket, bailed, and the
* page stayed on an empty "primary" tab ("No primary schools in your
* comparison") even though all schools were secondary.
*/
import { render, screen, waitFor } from '@testing-library/react';
import { ComparisonView } from '@/components/ComparisonView';
import { ComparisonProvider } from '@/context/ComparisonProvider';
import type { ComparisonData, School } from '@/lib/types';
const fetchComparison = jest.fn();
jest.mock('@/lib/api', () => ({
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
}));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
function secondarySchool(urn: number, name: string): School {
return {
urn,
school_name: name,
local_authority: 'Testshire',
school_type: 'Academy converter',
attainment_8_score: 55,
phase: 'Secondary',
} as School;
}
function data(urn: number, name: string): ComparisonData {
return {
school_info: secondarySchool(urn, name),
yearly_data: [{ year: 202425, attainment_8_score: 55 }] as ComparisonData['yearly_data'],
ofsted: null,
census: null,
admissions: null,
admissions_history: [],
deprivation: null,
};
}
const INITIAL_DATA = {
'300': data(300, 'Gamma High'),
'400': data(400, 'Delta Academy'),
};
test('an all-secondary comparison renders the sections, not an empty primary tab', async () => {
render(
<ComparisonProvider>
<ComparisonView
initialData={INITIAL_DATA}
initialNationalAverages={{
year: 202425,
primary: {},
secondary: { attainment_8_score: 46 },
by_year: [],
}}
initialBenchmarks={undefined}
initialUrns={[300, 400]}
metrics={[]}
selectedMetric="attainment_8_score"
/>
</ComparisonProvider>,
);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
});
expect(screen.getAllByText('Gamma High').length).toBeGreaterThan(0);
expect(screen.queryByText(/No primary schools in your comparison/)).toBeNull();
expect(fetchComparison).not.toHaveBeenCalled();
});
+5 -1
View File
@@ -173,7 +173,11 @@ export function ComparisonView({
if (!metricFitsPhase) { if (!metricFitsPhase) {
setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct'); setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
} }
}, [comparisonData]); // eslint-disable-line react-hooks/exhaustive-deps // selectedSchools is a dep because the basket hydrates after mount: the
// first run sees an empty basket and bails, so it must re-fire when the
// schools arrive. primarySchools/secondarySchools/metrics/selectedMetric
// are intentionally omitted (derived or would cause loops).
}, [comparisonData, selectedSchools]); // eslint-disable-line react-hooks/exhaustive-deps
const handlePhaseChange = (phase: 'primary' | 'secondary') => { const handlePhaseChange = (phase: 'primary' | 'secondary') => {
phaseLockedByUser.current = true; phaseLockedByUser.current = true;