From bd2a6c385bae8c99009d5b560f65b24cbc96658c Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 21 Sep 2026 22:43:38 +0100 Subject: [PATCH] test(e2e): cover the similar-schools section, compare hand-off and mobile widths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things here cannot be covered anywhere else. jsdom has no layout, so scrollWidth and clientWidth are both 0 and the arrows' disabled state can only be measured by a real engine. And the scroll position surviving a selection is DOM state rather than React state, so only a real browser can prove the row does not jump back when the footer re-renders. MOBILE.md asks for a Playwright width check and records that it was not written because Playwright was not in the project. It is — this suite — so the check exists now, scoped to the page this feature touches. Co-Authored-By: Claude Opus 5 --- docs/ARCHITECTURE.md | 7 ++- e2e/tests/journeys.spec.ts | 108 +++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a94087d..e5ca4cc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -73,7 +73,12 @@ There is no SWR dependency. Leaflet maps are loaded through dynamic wrappers; Chart.js renders performance and comparison charts. `components/school/` contains detail sections, with section decisions and data -preparation in `lib/schoolSections.ts`. `lib/types.ts` contains manually maintained +preparation in `lib/schoolSections.ts`. The similar-schools section is selected +in `backend/similar_schools.py` — hard filters that never relax (phase, +provision, selectivity, gender) and soft preferences that do (religious +character, then gender exactness) — and served on `/api/schools/{urn}`. Its +rules are presentation logic, deliberately kept out of `marts.*` so they can be +tuned by deploy rather than by pipeline run. `lib/types.ts` contains manually maintained API types. `payload-types.ts` and the Payload import map are generated artifacts. ## Publication and caching today diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index cf77edb..23ac1bb 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -2734,3 +2734,111 @@ test('the content sitemap lists the about page and is advertised in robots', asy expect(body).toContain('/sitemap.xml'); expect(body).toContain('/content-sitemap.xml'); }); + +/** + * Similar schools nearby. + * + * The section is absent by design where fewer than two schools qualify, and the + * arrows are absent where three cards fit, so this asserts each part of the + * contract only where it applies. + * + * Two things here cannot be tested anywhere else: the arrows' disabled state, + * which jsdom cannot measure because it has no layout, and the scroll position + * surviving a selection, which is DOM state rather than React state. + */ +test('similar schools link on to other schools and into compare', async ({ page }) => { + await searchByName(page, 'Primary'); + await schoolLinks(page).first().click(); + await page.waitForURL(/\/school\//); + + const section = page.locator('#similar'); + if ((await section.count()) === 0) { + test.skip(true, 'No qualifying similar schools for this school'); + } + + // Every card is a real link to another school page — including the ones + // behind the arrows, which is the whole reason this is a scroller and not a + // paginated widget. + const links = section.locator('a[href^="/school/"]'); + const linkCount = await links.count(); + expect(linkCount).toBeGreaterThanOrEqual(2); + expect(linkCount).toBeLessThanOrEqual(6); + expect(await links.first().getAttribute('href')).toMatch(/^\/school\/\d{6}-/); + + await expect(section.getByText(/miles away/).first()).toBeVisible(); + + const scroller = section.locator('ul').first(); + + // The carousel, where this school had more than three matches. + const forward = section.getByRole('button', { name: 'More schools' }); + if (await forward.count()) { + const back = section.getByRole('button', { name: 'Previous schools' }); + await expect(back).toBeDisabled(); + + await forward.click(); + await expect + .poll(() => scroller.evaluate((node: HTMLElement) => node.scrollLeft)) + .toBeGreaterThan(8); + await expect(back).toBeEnabled(); + } + + // The compare hand-off, and the row must not jump back to the start when the + // footer re-renders underneath it. + const offsetBefore = await scroller.evaluate((node: HTMLElement) => node.scrollLeft); + await section.getByRole('button', { name: /Add to compare/ }).first().click(); + await expect( + section.getByRole('button', { name: /Added to compare/ }).first(), + ).toBeVisible(); + expect(await scroller.evaluate((node: HTMLElement) => node.scrollLeft)).toBe(offsetBefore); +}); + +/** + * The section at MOBILE.md's three reference widths. + * + * MOBILE.md asks for exactly this check and records that it was not written + * because "Playwright isn't currently in the project dependency set". That is + * no longer true — this suite is Playwright — so the check exists now, scoped + * to the page this feature touches. + */ +for (const width of [360, 390, 430]) { + test(`similar schools survives a ${width}px viewport`, async ({ page }) => { + await page.setViewportSize({ width, height: 800 }); + await searchByName(page, 'Primary'); + await schoolLinks(page).first().click(); + await page.waitForURL(/\/school\//); + + const section = page.locator('#similar'); + if ((await section.count()) === 0) { + test.skip(true, 'No qualifying similar schools for this school'); + } + + // 1. Nothing bleeds past the right edge. + expect( + await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth), + ).toBe(0); + + // 2. No arrows on touch widths — swiping does the job, and they would take + // 96px from a 328px card. + await expect(section.getByRole('button', { name: 'More schools' })).toHaveCount(0); + + // 3. Every tap target in the section clears 44px. A card title's own box is + // shorter, but its hit area is the whole card via ::after. + const failing = await section.evaluate((root: HTMLElement) => + Array.from(root.querySelectorAll('a, button')) + .filter((el) => (el as HTMLElement).offsetParent) + .map((el) => { + const card = el.closest('li'); + const box = el.matches('h3 a') && card + ? card.getBoundingClientRect() + : el.getBoundingClientRect(); + return { + text: (el as HTMLElement).innerText.trim().slice(0, 24), + w: box.width, + h: box.height, + }; + }) + .filter((o) => o.w < 44 || o.h < 44), + ); + expect(failing).toEqual([]); + }); +}