feat: similar schools nearby on the detail page #150
No files matched your search
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user