Merge remote-tracking branch 'origin/main' into feat/ks4-destinations
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m12s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 4m4s
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m12s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 4m4s
# Conflicts: # nextjs-app/__tests__/components/darkThemeSafety.test.ts
This commit is contained in:
commit
68a192e430
14 files changed
+421
-21
No files matched your search
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* The trail has to be written by something, and it has to be written on every
|
||||
* route — not only the ones that happen to track an event.
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
const recordVisitedPath = jest.fn();
|
||||
let pathname = '/schools/brentwood';
|
||||
|
||||
jest.mock('next/navigation', () => ({ usePathname: () => pathname }));
|
||||
jest.mock('@/lib/analytics', () => ({
|
||||
recordVisitedPath: (p: string) => recordVisitedPath(p),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { RouteTrail } = require('@/components/RouteTrail');
|
||||
|
||||
describe('RouteTrail', () => {
|
||||
beforeEach(() => recordVisitedPath.mockClear());
|
||||
|
||||
it('records the page it is mounted on', () => {
|
||||
render(<RouteTrail />);
|
||||
expect(recordVisitedPath).toHaveBeenCalledWith('/schools/brentwood');
|
||||
});
|
||||
|
||||
it('records each new route as the user moves through the app', () => {
|
||||
const { rerender } = render(<RouteTrail />);
|
||||
pathname = '/school/115429-brentwood-school';
|
||||
rerender(<RouteTrail />);
|
||||
expect(recordVisitedPath).toHaveBeenLastCalledWith(
|
||||
'/school/115429-brentwood-school');
|
||||
});
|
||||
|
||||
it('renders nothing, so it can sit anywhere in the layout', () => {
|
||||
const { container } = render(<RouteTrail />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -32,10 +32,17 @@ function stylesheets(dir: string): string[] {
|
||||
}
|
||||
|
||||
/** Innermost `selector { body }` pairs. Nested at-rules never match as rules,
|
||||
* because their body contains braces. */
|
||||
* because their body contains braces.
|
||||
*
|
||||
* Comments are stripped before matching rather than after, so that the whole
|
||||
* selector survives. Taking only its last line — which is what stripping a
|
||||
* leading comment used to require — silently discarded every selector in a
|
||||
* grouped rule but the final one, and a safety guard that cannot see half its
|
||||
* input fails open. */
|
||||
function rules(css: string): Array<{ selector: string; body: string }> {
|
||||
return Array.from(css.matchAll(/([^{}]+)\{([^{}]*)\}/g), (m) => ({
|
||||
selector: m[1].trim().split('\n').pop()!.trim(),
|
||||
const bare = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
return Array.from(bare.matchAll(/([^{}]+)\{([^{}]*)\}/g), (m) => ({
|
||||
selector: m[1].trim().replace(/\s*\n\s*/g, ' '),
|
||||
body: m[2],
|
||||
}));
|
||||
}
|
||||
@@ -45,6 +52,15 @@ const THEMED_COLOR = /(?:^|[^-])color:\s*var\(--/;
|
||||
|
||||
const files = stylesheets(COMPONENTS);
|
||||
|
||||
/** Component sources, for the third-party-surface rule below. */
|
||||
function sources(dir: string): string[] {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) return sources(full);
|
||||
return entry.name.endsWith('.tsx') ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
describe('dark-theme safety', () => {
|
||||
it('finds stylesheets to check', () => {
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
@@ -77,6 +93,76 @@ describe('dark-theme safety', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The same defect one stylesheet further out.
|
||||
*
|
||||
* The rules above scan our own CSS modules. They cannot see a surface painted
|
||||
* by a third-party sheet: leaflet.css hardcodes `background: white` on
|
||||
* `.leaflet-popup-content-wrapper` and `.leaflet-popup-tip`, and
|
||||
* LeafletMapInner builds its popup as an HTML string with inline
|
||||
* `color: var(--text-primary)`. Neither half lives in a .module.css, so the
|
||||
* module scan passed while dark mode rendered #E9EEF0 on #FFFFFF — 1.17:1,
|
||||
* with the school name and the headline figure effectively invisible.
|
||||
*
|
||||
* globals.css already pulls the rest of Leaflet's chrome onto the tokens (the
|
||||
* attribution bar, the zoom controls) for exactly this reason. The popup was
|
||||
* simply missed.
|
||||
*/
|
||||
describe('third-party surfaces under themed text', () => {
|
||||
const GLOBALS = path.join(__dirname, '..', '..', 'app', 'globals.css');
|
||||
|
||||
/** Leaflet surfaces our own code writes token-coloured text onto. */
|
||||
const LEAFLET_POPUP_SURFACES = [
|
||||
'.leaflet-popup-content-wrapper',
|
||||
'.leaflet-popup-tip',
|
||||
];
|
||||
|
||||
it('still finds a component painting themed text into a Leaflet popup', () => {
|
||||
// Guards the rule below against passing vacuously if the popups are ever
|
||||
// rewritten as React components rather than HTML strings.
|
||||
const themed = sources(COMPONENTS).filter((file) => {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
return /bindPopup\(/.test(src) && /color:var\(--|color: var\(--/.test(src);
|
||||
});
|
||||
|
||||
expect(themed.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('themes the Leaflet popup surface, because the text on it is themed', () => {
|
||||
const globals = rules(fs.readFileSync(GLOBALS, 'utf8'));
|
||||
|
||||
const unthemed = LEAFLET_POPUP_SURFACES.filter((surface) => {
|
||||
const rule = globals.find((r) => r.selector.includes(surface));
|
||||
return !rule || !/background[^;]*var\(--/.test(rule.body);
|
||||
});
|
||||
|
||||
// Leaflet's white is not a colour this site owns. Either the surface
|
||||
// follows the theme or the text on it must be literal — and the text is
|
||||
// already themed.
|
||||
expect(unthemed).toEqual([]);
|
||||
});
|
||||
|
||||
it('never puts a literal white label on a themed fill', () => {
|
||||
/*
|
||||
* The mirror image of the module-CSS rule above, and the half of the popup
|
||||
* that theming the card does not reach. "View Details" is
|
||||
* `background:var(--status-above);color:white`; --status-above is #36743F
|
||||
* in light but #7FCB8A in dark, so the label went from 5.63:1 to 1.94:1.
|
||||
*
|
||||
* --text-inverse is the token for ink on a saturated fill — #FFFFFF in
|
||||
* light, #111A20 in dark — and the popup's Ofsted badge already uses it.
|
||||
*/
|
||||
const offenders = sources(COMPONENTS).flatMap((file) => {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
return Array.from(
|
||||
src.matchAll(/background:\s*var\(--[^;"']*;[^"']*?color:\s*(white|#fff\b|#ffffff\b)/gi),
|
||||
() => path.relative(COMPONENTS, file));
|
||||
});
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Destination measures add the first new colour family since the palette was
|
||||
* set. The tokens have to exist in both blocks or the section renders one
|
||||
|
||||
@@ -98,6 +98,17 @@ describe('secondary detail page', () => {
|
||||
|
||||
expect(screen.getByText(/has not published a cut-off distance/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('makes no claim about publication when the feature is switched off', () => {
|
||||
// Absent, not null. The API omits the key entirely while the
|
||||
// admission_distance flag is off, and "Islington has not published a
|
||||
// cut-off distance" is then a statement about us, not about Islington —
|
||||
// false wherever the authority does publish one.
|
||||
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: undefined });
|
||||
|
||||
expect(screen.queryByText(/has not published a cut-off distance/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Contact the admissions authority/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── The Distance section ───────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user