wrolpi/app/src/components/ThemeProvider.test.js
Roland b36f7beeb8
test: act on review — observe the wiring, and stop the soft assertions
All five points were right.

* The gallery's Toasts copy still read "Bottom right" after the container
  moved.  Anyone using that page as a visual checklist would have looked in
  the wrong corner and concluded the change had not landed.

* Nothing observed ThemeProvider's own wiring.  Both suites mount their own
  `<Notifications position='top-right' limit={5}/>`, so moving the container
  back, or dropping it, would have failed neither -- while the comments
  claimed "container missing while call sites still fire" as the failure
  already shipped once.  Three tests in ThemeProvider.test.js now raise a
  real toast through the provider and assert it appears, and where.

  Asserted through a toast rather than by querying for a container: Mantine
  renders an empty container for every position it supports, so the obvious
  selector returns `top-center` no matter how the provider is configured.
  That is what the first version of this test did, and it failed for that
  reason rather than the intended one.

  The five-at-once test is honest about what it covers: Mantine's own
  default limit is also 5, so it does not prove the prop is there.  It
  catches the limit being raised.

* The default duration was untested.  Sleeping 150ms and finding the toast
  still up rules out "vanishes next tick" and nothing else -- it passes for
  a 200ms default, for `false`, and for Mantine's own 4000ms.  Read the
  deadline back off Mantine's store instead: 5000 when unset, the caller's
  number when given, `false` for 0.

* Space activates a followable toast in `toast.ts` and only Enter was
  covered, so dropping that branch stayed green.

* Colour assertions used `toContain('red')`, which any declaration
  containing those three letters satisfies.  They match the whole custom
  property now.

Planted four regressions to check the new tests: position, limit, the
5000ms default, and the Space branch.  Three went red; the limit one did
not, for the reason recorded above.
2026-08-08 18:05:45 -06:00

371 lines
14 KiB
JavaScript

import fs from 'fs';
import path from 'path';
import React, {useContext} from 'react';
import {act, render, screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {ThemeContext} from '../contexts/contexts';
import {
amberTheme,
darkTheme,
isDarkTheme,
lightTheme,
nightTheme,
resolveTheme,
systemTheme,
ThemeProvider,
themeSessionKey,
} from './Theme';
import {mediaFilterSessionKey, resolveMediaFilter, themeMediaFilter} from '../themes/names';
import {clearToasts, toast} from './ui';
// Lets a test drive `prefers-color-scheme` and assert on what the provider applied.
let prefersDark = false;
const mockMatchMedia = () => {
const listeners = new Set();
window.matchMedia = jest.fn().mockImplementation(query => ({
matches: query === '(prefers-color-scheme: dark)' ? prefersDark : false,
media: query,
addEventListener: (_event, listener) => listeners.add(listener),
removeEventListener: (_event, listener) => listeners.delete(listener),
addListener: (listener) => listeners.add(listener),
removeListener: (listener) => listeners.delete(listener),
dispatchEvent: () => false,
}));
return {
// Emulate the OS switching to dark/light while the app is open.
change: (value) => {
prefersDark = value;
act(() => listeners.forEach(listener => listener({matches: value})));
},
};
};
function ThemeProbe() {
const {
theme, savedTheme, isDark, setTheme, cycleSavedTheme,
mediaFilter, mediaFilterEnabled, setMediaFilterEnabled,
} = useContext(ThemeContext);
return <>
<span data-testid='theme'>{theme}</span>
<span data-testid='saved'>{String(savedTheme)}</span>
<span data-testid='dark'>{String(isDark)}</span>
<span data-testid='filter-offered'>{mediaFilter ? mediaFilter.id : 'none'}</span>
<span data-testid='filter-on'>{String(mediaFilterEnabled)}</span>
<button onClick={() => setTheme(nightTheme)}>night</button>
<button onClick={() => setTheme(amberTheme)}>amber</button>
<button onClick={() => setTheme(lightTheme)}>light</button>
<button onClick={() => setTheme(systemTheme)}>system</button>
<button onClick={cycleSavedTheme}>cycle</button>
<button onClick={() => setMediaFilterEnabled(false)}>filter off</button>
<button onClick={() => setMediaFilterEnabled(true)}>filter on</button>
</>
}
const renderProvider = () => render(<ThemeProvider><ThemeProbe/></ThemeProvider>);
const appliedTheme = () => document.documentElement.getAttribute('data-theme');
const appliedFilter = () => document.documentElement.getAttribute('data-media-filter');
describe('ThemeProvider', () => {
beforeEach(() => {
prefersDark = false;
localStorage.clear();
document.documentElement.removeAttribute('data-theme');
document.documentElement.removeAttribute('data-media-filter');
mockMatchMedia();
});
it('applies the saved theme and stamps it on <html>', () => {
localStorage.setItem(themeSessionKey, nightTheme);
renderProvider();
expect(screen.getByTestId('theme')).toHaveTextContent(nightTheme);
expect(appliedTheme()).toBe(nightTheme);
});
it('falls back to the OS preference when no theme is saved', () => {
prefersDark = true;
renderProvider();
expect(screen.getByTestId('theme')).toHaveTextContent(darkTheme);
expect(screen.getByTestId('saved')).toHaveTextContent('null');
});
it('persists a chosen theme so it survives a reload', async () => {
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'night'}));
expect(localStorage.getItem(themeSessionKey)).toBe(nightTheme);
expect(appliedTheme()).toBe(nightTheme);
});
it('follows the OS preference while `system` is chosen', async () => {
const media = mockMatchMedia();
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'system'}));
media.change(true);
expect(screen.getByTestId('theme')).toHaveTextContent(darkTheme);
expect(appliedTheme()).toBe(darkTheme);
});
it('ignores the OS preference once a specific theme is chosen', async () => {
const media = mockMatchMedia();
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'night'}));
media.change(true);
expect(screen.getByTestId('theme')).toHaveTextContent(nightTheme);
});
it('reports night and amber as dark themes', async () => {
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'night'}));
expect(screen.getByTestId('dark')).toHaveTextContent('true');
});
it('cycles through every theme and back to system', async () => {
renderProvider();
const cycle = screen.getByRole('button', {name: 'cycle'});
const seen = [];
for (let index = 0; index < 5; index++) {
await userEvent.click(cycle);
seen.push(screen.getByTestId('saved').textContent);
}
expect(seen).toEqual([lightTheme, darkTheme, nightTheme, amberTheme, systemTheme]);
});
it('recovers from a theme name written by an older version', () => {
localStorage.setItem(themeSessionKey, 'sepia');
renderProvider();
expect(screen.getByTestId('theme')).toHaveTextContent(lightTheme);
expect(appliedTheme()).toBe(lightTheme);
});
});
describe('ThemeProvider media filtering', () => {
beforeEach(() => {
prefersDark = false;
localStorage.clear();
document.documentElement.removeAttribute('data-theme');
document.documentElement.removeAttribute('data-media-filter');
mockMatchMedia();
});
it('filters media in night mode without being asked', async () => {
// An unfiltered thumbnail undoes dark adaptation, so night starts filtered.
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'night'}));
expect(appliedFilter()).toBe('night-red');
expect(screen.getByTestId('filter-on')).toHaveTextContent('true');
});
it('leaves amber media alone until the user asks for it', async () => {
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'amber'}));
expect(appliedFilter()).toBeNull();
expect(screen.getByTestId('filter-offered')).toHaveTextContent('amber-mono');
});
it('applies no filter, and offers none, in a theme without one', async () => {
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'light'}));
expect(appliedFilter()).toBeNull();
expect(screen.getByTestId('filter-offered')).toHaveTextContent('none');
});
it('turns the filter off when the user says so, and persists it', async () => {
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'night'}));
await userEvent.click(screen.getByRole('button', {name: 'filter off'}));
expect(appliedFilter()).toBeNull();
expect(JSON.parse(localStorage.getItem(mediaFilterSessionKey))).toEqual({night: false});
});
it('turns amber filtering on when the user asks', async () => {
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'amber'}));
await userEvent.click(screen.getByRole('button', {name: 'filter on'}));
expect(appliedFilter()).toBe('amber-mono');
});
it('keeps the choice separate for each theme', async () => {
// Wanting amber's tint says nothing about wanting night's, and turning night's off
// must not disarm a filter the user deliberately enabled elsewhere.
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'amber'}));
await userEvent.click(screen.getByRole('button', {name: 'filter on'}));
await userEvent.click(screen.getByRole('button', {name: 'night'}));
await userEvent.click(screen.getByRole('button', {name: 'filter off'}));
expect(appliedFilter()).toBeNull();
await userEvent.click(screen.getByRole('button', {name: 'amber'}));
expect(appliedFilter()).toBe('amber-mono');
});
it('restores the saved choice on the next load', () => {
localStorage.setItem(themeSessionKey, nightTheme);
localStorage.setItem(mediaFilterSessionKey, JSON.stringify({night: false}));
renderProvider();
expect(appliedFilter()).toBeNull();
});
it('falls back to the theme default when the stored settings are unusable', () => {
localStorage.setItem(themeSessionKey, nightTheme);
localStorage.setItem(mediaFilterSessionKey, 'not json');
renderProvider();
// Failing open would leave a user in the dark staring at a white thumbnail.
expect(appliedFilter()).toBe('night-red');
});
it('ignores stored entries for themes that no longer exist', () => {
localStorage.setItem(themeSessionKey, nightTheme);
localStorage.setItem(mediaFilterSessionKey, JSON.stringify({sepia: true, night: false}));
renderProvider();
expect(appliedFilter()).toBeNull();
});
});
describe('resolveMediaFilter', () => {
it('gives every filtering theme a filter defined in MediaFilterDefs', () => {
// A theme naming a filter that does not exist would silently filter nothing.
const defs = fs.readFileSync(
path.join(__dirname, '..', 'themes', 'MediaFilterDefs.tsx'), 'utf8');
[nightTheme, amberTheme].forEach(theme => {
const filter = themeMediaFilter(theme);
expect(defs).toContain(`id='wrolpi-${filter.id}'`);
});
});
it('honours an override in either direction', () => {
expect(resolveMediaFilter(nightTheme, {})).toBe('night-red');
expect(resolveMediaFilter(nightTheme, {night: false})).toBe('');
expect(resolveMediaFilter(amberTheme, {})).toBe('');
expect(resolveMediaFilter(amberTheme, {amber: true})).toBe('amber-mono');
expect(resolveMediaFilter(lightTheme, {light: true})).toBe('');
});
});
describe('theme helpers', () => {
beforeEach(() => {
prefersDark = false;
mockMatchMedia();
});
it('resolves explicit theme names as themselves', () => {
[lightTheme, darkTheme, nightTheme, amberTheme].forEach(theme => {
expect(resolveTheme(theme)).toBe(theme);
});
});
it('resolves `system` and null from the OS preference', () => {
expect(resolveTheme(systemTheme)).toBe(lightTheme);
expect(resolveTheme(null)).toBe(lightTheme);
prefersDark = true;
expect(resolveTheme(systemTheme)).toBe(darkTheme);
});
it('knows which themes are built on a dark background', () => {
expect(isDarkTheme(lightTheme)).toBe(false);
expect(isDarkTheme(darkTheme)).toBe(true);
expect(isDarkTheme(nightTheme)).toBe(true);
expect(isDarkTheme(amberTheme)).toBe(true);
});
});
describe('the toast container ThemeProvider mounts', () => {
/*
* Observing the wiring rather than re-stating it.
*
* `toast.test.js` and `ui-layout.cy.js` each mount their own `<Notifications>` with the
* position and limit they expect, so neither would notice if this file moved the container
* back, changed its limit, or dropped it altogether -- and a missing container while
* thirteen call sites keep firing is the failure that has already shipped once. Those
* suites test `toast()`; this tests that the app has somewhere to put what it produces.
*/
beforeEach(() => {
prefersDark = false;
localStorage.clear();
mockMatchMedia();
});
afterEach(() => act(() => clearToasts()));
const notifications = () =>
document.querySelectorAll('[class*="mantine-Notification-root"]');
it('shows a toast raised by a call site', async () => {
// The regression that already shipped: no container, thirteen call sites still firing,
// nothing thrown and nothing shown.
renderProvider();
act(() => toast({title: 'Refresh started'}));
expect(await screen.findByText('Refresh started')).toBeInTheDocument();
});
it('puts it in the top right', async () => {
/*
* Asserted through a real toast rather than by finding a container. Mantine renders an
* empty container for every position it supports, so querying for one returns whichever
* comes first in the DOM -- `top-center` -- whatever the provider was configured with.
*/
renderProvider();
act(() => toast({title: 'Somewhere'}));
const notification = await screen.findByText('Somewhere');
expect(notification.closest('[class*="mantine-Notifications-root"]'))
.toHaveAttribute('data-position', 'top-right');
});
it('shows five at once and queues the rest', async () => {
/*
* The user-visible behaviour, not the prop: Mantine's own default limit is also 5, so
* this stays green if `limit={5}` is deleted from the provider. It is here to catch a
* limit being *raised* -- a background task that reports twenty times should not bury
* the page -- rather than to prove the prop is present.
*/
renderProvider();
act(() => {
for (let i = 1; i <= 7; i += 1) {
toast({title: `Toast ${i}`});
}
});
await waitFor(() => expect(notifications()).toHaveLength(5));
});
});