release: v0.20.0
This commit is contained in:
@@ -48,3 +48,31 @@ describe('localizedLeafletPrefix', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LeafletMap marker names', () => {
|
||||
it('names a marker by its label, not by the count it shows', async () => {
|
||||
const { container } = render(
|
||||
<I18nProvider locale="de">
|
||||
<LeafletMap
|
||||
markers={[
|
||||
{
|
||||
lat: 54.16,
|
||||
lng: 15.39,
|
||||
html: '<div class="map-marker">12</div>',
|
||||
label: 'Dźwirzyno, Polen, 12 Fotos',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
const icon = await waitFor(() => {
|
||||
const el = container.querySelector('.leaflet-marker-icon');
|
||||
if (!el) throw new Error('marker not placed yet');
|
||||
return el;
|
||||
});
|
||||
expect(icon.getAttribute('aria-label')).toBe('Dźwirzyno, Polen, 12 Fotos');
|
||||
expect(icon.getAttribute('title')).toBe('Dźwirzyno, Polen, 12 Fotos');
|
||||
expect(icon.getAttribute('role')).toBe('button');
|
||||
});
|
||||
});
|
||||
|
||||
259
components/__tests__/LightboxZoom.test.tsx
Normal file
259
components/__tests__/LightboxZoom.test.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { Lightbox } from '../Lightbox';
|
||||
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
|
||||
|
||||
/**
|
||||
* The lightbox zoom (#467), wired up: the control, the keys, Esc order,
|
||||
* double-click, swipe while zoomed, and that the full-resolution file is
|
||||
* requested only when the visitor zooms.
|
||||
*
|
||||
* jsdom does no layout, so the fit box is stubbed: every image is laid out at
|
||||
* 1000×600, at the origin. A 6000px photo on a 1× screen is 1:1 at 6×.
|
||||
*/
|
||||
|
||||
const photo = (id: string, extra: Partial<PhotoItem> = {}): PhotoItem => ({
|
||||
id,
|
||||
type: 'image',
|
||||
thumbUrl: `/t/${id}`,
|
||||
previewUrl: `/p/${id}`,
|
||||
exifUrl: `/e/${id}`,
|
||||
aspectRatio: 5 / 3,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const ZOOMABLE = photo('z1', { zoomUrl: '/api/zoom/a/z1', zoomWidth: 6000, zoomHeight: 3600 });
|
||||
const ZOOMABLE_2 = photo('z2', { zoomUrl: '/api/zoom/a/z2', zoomWidth: 6000, zoomHeight: 3600 });
|
||||
const PLAIN = photo('p1');
|
||||
const VIDEO: PhotoItem = {
|
||||
...photo('v1', { zoomUrl: '/api/zoom/a/v1', zoomWidth: 6000, zoomHeight: 3600 }),
|
||||
type: 'video',
|
||||
videoUrl: '/v/v1',
|
||||
};
|
||||
|
||||
function Harness({
|
||||
assets,
|
||||
start = 0,
|
||||
onClose = () => {},
|
||||
onNextSpy,
|
||||
}: {
|
||||
assets: PhotoItem[];
|
||||
start?: number;
|
||||
onClose?: () => void;
|
||||
onNextSpy?: () => void;
|
||||
}) {
|
||||
const [index, setIndex] = useState(start);
|
||||
return (
|
||||
<Lightbox
|
||||
assets={assets}
|
||||
currentIndex={index}
|
||||
onClose={onClose}
|
||||
onNext={() => {
|
||||
onNextSpy?.();
|
||||
setIndex((i) => (i + 1) % assets.length);
|
||||
}}
|
||||
onPrev={() => setIndex((i) => (i - 1 + assets.length) % assets.length)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const restore: Array<() => void> = [];
|
||||
function stub<T extends object>(target: T, key: string, get: () => unknown) {
|
||||
const original = Object.getOwnPropertyDescriptor(target, key);
|
||||
Object.defineProperty(target, key, { configurable: true, get });
|
||||
restore.push(() => {
|
||||
if (original) Object.defineProperty(target, key, original);
|
||||
else delete (target as Record<string, unknown>)[key];
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stub(HTMLElement.prototype, 'offsetWidth', function (this: HTMLElement) {
|
||||
return this.tagName === 'IMG' ? 1000 : 0;
|
||||
});
|
||||
stub(HTMLElement.prototype, 'offsetHeight', function (this: HTMLElement) {
|
||||
return this.tagName === 'IMG' ? 600 : 0;
|
||||
});
|
||||
stub(window, 'devicePixelRatio', () => 1);
|
||||
window.matchMedia = vi.fn(() => ({ matches: false })) as unknown as typeof window.matchMedia;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
while (restore.length) restore.pop()!();
|
||||
});
|
||||
|
||||
const dialog = () => screen.getByRole('dialog');
|
||||
const preview = () => dialog().querySelector<HTMLImageElement>('img[src^="/p/"]')!;
|
||||
const fullImage = () => dialog().querySelector<HTMLImageElement>('img[src^="/api/zoom/"]');
|
||||
const zoomButton = () =>
|
||||
within(dialog()).queryByRole('button', { name: /Zoom to full resolution|Fit the photo/ });
|
||||
const key = (k: string) => fireEvent.keyDown(document, { key: k });
|
||||
|
||||
describe('the zoom control', () => {
|
||||
it('is offered for a zoomable photo', () => {
|
||||
render(<Harness assets={[ZOOMABLE]} />);
|
||||
expect(zoomButton()).not.toBeNull();
|
||||
expect(zoomButton()!.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('is absent without a zoom source, and for a video', () => {
|
||||
render(<Harness assets={[PLAIN, VIDEO]} />);
|
||||
expect(zoomButton()).toBeNull();
|
||||
fireEvent.click(screen.getByLabelText('Next photo'));
|
||||
expect(zoomButton()).toBeNull();
|
||||
});
|
||||
|
||||
it('toggles 1:1 and back', () => {
|
||||
render(<Harness assets={[ZOOMABLE]} />);
|
||||
fireEvent.click(zoomButton()!);
|
||||
expect(zoomButton()!.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(preview().style.transform).toContain('scale(6)');
|
||||
fireEvent.click(zoomButton()!);
|
||||
expect(zoomButton()!.getAttribute('aria-pressed')).toBe('false');
|
||||
expect(preview().style.transform).toContain('scale(1)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('on a touch device (review of #830)', () => {
|
||||
const coarse = () => {
|
||||
window.matchMedia = vi.fn((q: string) => ({
|
||||
matches: q === '(pointer: coarse)',
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
};
|
||||
const HUGE = photo('h1', { zoomUrl: '/api/zoom/a/h1', zoomWidth: 9520, zoomHeight: 6336 });
|
||||
|
||||
it('offers no zoom above 50 MP', () => {
|
||||
coarse();
|
||||
render(<Harness assets={[HUGE]} />);
|
||||
expect(zoomButton()).toBeNull();
|
||||
key('+');
|
||||
expect(fullImage()).toBeNull();
|
||||
});
|
||||
|
||||
it('still offers it for a smaller photo', () => {
|
||||
coarse();
|
||||
render(
|
||||
<Harness
|
||||
assets={[photo('s1', { zoomUrl: '/api/zoom/a/s1', zoomWidth: 4032, zoomHeight: 3024 })]}
|
||||
/>,
|
||||
);
|
||||
expect(zoomButton()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the large photo zoomable with a mouse', () => {
|
||||
render(<Harness assets={[HUGE]} />);
|
||||
expect(zoomButton()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the full-resolution file', () => {
|
||||
it('is not requested on open or on the next photo', () => {
|
||||
render(<Harness assets={[ZOOMABLE, ZOOMABLE_2]} />);
|
||||
expect(fullImage()).toBeNull();
|
||||
fireEvent.click(screen.getByLabelText('Next photo'));
|
||||
expect(fullImage()).toBeNull();
|
||||
});
|
||||
|
||||
it('is requested on the first zoom, under the same transform as the preview', () => {
|
||||
render(<Harness assets={[ZOOMABLE]} />);
|
||||
key('+');
|
||||
const full = fullImage();
|
||||
expect(full?.getAttribute('src')).toBe('/api/zoom/a/z1');
|
||||
expect(full!.style.transform).toBe(preview().style.transform);
|
||||
});
|
||||
|
||||
it('when missing, says so, returns to fit and withdraws the control', () => {
|
||||
render(<Harness assets={[ZOOMABLE]} />);
|
||||
key('+');
|
||||
fireEvent.error(fullImage()!);
|
||||
expect(screen.getByText('Full resolution is not available for this photo.')).toBeTruthy();
|
||||
expect(zoomButton()).toBeNull();
|
||||
expect(preview().style.transform).toContain('scale(1)');
|
||||
});
|
||||
|
||||
it('starts over on the next photo', () => {
|
||||
render(<Harness assets={[ZOOMABLE, ZOOMABLE_2]} />);
|
||||
key('+');
|
||||
expect(fullImage()).not.toBeNull();
|
||||
fireEvent.click(screen.getByLabelText('Next photo'));
|
||||
expect(fullImage()).toBeNull();
|
||||
expect(preview().style.transform).toBe('');
|
||||
expect(zoomButton()!.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('keys', () => {
|
||||
it('+ / = zoom in by steps up to 1:1, - out, 0 resets', () => {
|
||||
render(<Harness assets={[ZOOMABLE]} />);
|
||||
key('+');
|
||||
expect(preview().style.transform).toContain('scale(2)');
|
||||
key('=');
|
||||
expect(preview().style.transform).toContain('scale(4)');
|
||||
key('+');
|
||||
expect(preview().style.transform).toContain('scale(6)');
|
||||
key('-');
|
||||
expect(preview().style.transform).toContain('scale(3)');
|
||||
key('0');
|
||||
expect(preview().style.transform).toContain('scale(1)');
|
||||
});
|
||||
|
||||
it('leave a photo without zoom alone', () => {
|
||||
render(<Harness assets={[PLAIN]} />);
|
||||
key('+');
|
||||
expect(preview().style.transform).toBe('');
|
||||
});
|
||||
|
||||
it('Esc leaves the zoom first, and closes the viewer only after that', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Harness assets={[ZOOMABLE]} onClose={onClose} />);
|
||||
key('+');
|
||||
key('Escape');
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(zoomButton()!.getAttribute('aria-pressed')).toBe('false');
|
||||
key('Escape');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lists the zoom keys only where zoom is offered', () => {
|
||||
const { unmount } = render(<Harness assets={[ZOOMABLE]} />);
|
||||
key('?');
|
||||
expect(screen.queryByText('Zoom in / out')).not.toBeNull();
|
||||
unmount();
|
||||
render(<Harness assets={[PLAIN]} />);
|
||||
key('?');
|
||||
expect(screen.queryByText('Zoom in / out')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('gestures', () => {
|
||||
it('double-click goes to 1:1 around the cursor, and back', () => {
|
||||
render(<Harness assets={[ZOOMABLE]} />);
|
||||
const surface = preview().parentElement!;
|
||||
fireEvent.doubleClick(surface, { clientX: 600, clientY: 300 });
|
||||
// Fit box centre (500, 300); the cursor 100px right of it stays put at 6×.
|
||||
expect(preview().style.transform).toBe('translate3d(-500px, 0px, 0) scale(6)');
|
||||
fireEvent.doubleClick(surface, { clientX: 600, clientY: 300 });
|
||||
expect(preview().style.transform).toContain('scale(1)');
|
||||
});
|
||||
|
||||
it('no swipe navigation while zoomed', () => {
|
||||
const onNextSpy = vi.fn();
|
||||
render(<Harness assets={[ZOOMABLE, ZOOMABLE_2]} onNextSpy={onNextSpy} />);
|
||||
const swipeLeft = () => {
|
||||
fireEvent.touchStart(dialog(), { touches: [{ clientX: 600, clientY: 300 }] });
|
||||
fireEvent.touchEnd(dialog(), {
|
||||
touches: [],
|
||||
changedTouches: [{ clientX: 300, clientY: 300 }],
|
||||
});
|
||||
};
|
||||
key('+');
|
||||
swipeLeft();
|
||||
expect(onNextSpy).not.toHaveBeenCalled();
|
||||
key('0');
|
||||
swipeLeft();
|
||||
expect(onNextSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
97
components/__tests__/ThemeToggle.test.tsx
Normal file
97
components/__tests__/ThemeToggle.test.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* `mode: auto` follows OS changes while the page is open (T-10/T-18), unless
|
||||
* the visitor made a choice or the operator fixed a mode.
|
||||
*/
|
||||
|
||||
let osLight = false;
|
||||
let onChange: (() => void) | null = null;
|
||||
|
||||
// Node 26 defines its own global localStorage (undefined without
|
||||
// --localstorage-file), which shadows jsdom's; a Map-backed stub stands in.
|
||||
function stubStorage() {
|
||||
const store = new Map<string, string>();
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
osLight = false;
|
||||
onChange = null;
|
||||
stubStorage();
|
||||
document.documentElement.setAttribute('data-default-theme', 'auto');
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
vi.stubGlobal('matchMedia', (query: string) => ({
|
||||
media: query,
|
||||
get matches() {
|
||||
return query === '(prefers-color-scheme: light)' ? osLight : false;
|
||||
},
|
||||
addEventListener: (_: string, cb: () => void) => {
|
||||
onChange = cb;
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
await import('../ThemeToggle');
|
||||
return document.documentElement;
|
||||
}
|
||||
|
||||
describe('ThemeToggle under mode: auto', () => {
|
||||
it('follows the OS when it switches', async () => {
|
||||
const html = await load();
|
||||
expect(html.getAttribute('data-theme')).toBe('dark');
|
||||
osLight = true;
|
||||
onChange?.();
|
||||
expect(html.getAttribute('data-theme')).toBe('light');
|
||||
// Following the OS is not a choice — nothing is stored.
|
||||
expect(localStorage.getItem('theme')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a stored choice when the OS switches', async () => {
|
||||
localStorage.setItem('theme', 'dark');
|
||||
const html = await load();
|
||||
osLight = true;
|
||||
onChange?.();
|
||||
expect(html.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
|
||||
it('keeps a fixed configured mode when the OS switches', async () => {
|
||||
document.documentElement.setAttribute('data-default-theme', 'dark');
|
||||
const html = await load();
|
||||
osLight = true;
|
||||
onChange?.();
|
||||
expect(html.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThemeToggle on a page rendered from the error shell', () => {
|
||||
it('re-applies the stored mode after the client render overwrote it', async () => {
|
||||
localStorage.setItem('theme', 'light');
|
||||
const html = await load();
|
||||
// The 404 shell's client render writes the server default back onto <html>.
|
||||
html.setAttribute('data-theme', 'dark');
|
||||
const { syncThemeFromDom } = await import('../ThemeToggle');
|
||||
syncThemeFromDom();
|
||||
expect(html.getAttribute('data-theme')).toBe('light');
|
||||
});
|
||||
|
||||
it('picks up a configured mode that only appeared after the module loaded', async () => {
|
||||
document.documentElement.removeAttribute('data-default-theme');
|
||||
const html = await load();
|
||||
expect(html.getAttribute('data-theme')).toBe('dark');
|
||||
html.setAttribute('data-default-theme', 'light');
|
||||
const { syncThemeFromDom } = await import('../ThemeToggle');
|
||||
syncThemeFromDom();
|
||||
expect(html.getAttribute('data-theme')).toBe('light');
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,19 @@ function surfaces(preset: string, mode: 'dark' | 'light'): string[] {
|
||||
return SURFACES.map((s) => token(own, s) ?? token(base, s)!).filter((v) => v.startsWith('#'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The --error a preset resolves to in one mode, in cascade order: a preset
|
||||
* may set its own where the default red misses 4.5:1 on its surfaces. The
|
||||
* preset's dark block comes after `[data-theme='light']` in tokens.css with
|
||||
* the same specificity, so in light mode it beats the global light value.
|
||||
*/
|
||||
function errorToken(preset: string, mode: 'dark' | 'light'): string | undefined {
|
||||
const dark = token(block(`[data-preset='${preset}']`), 'error');
|
||||
if (mode === 'dark') return dark ?? token(ROOT, 'error');
|
||||
const light = token(block(`[data-preset='${preset}'][data-theme='light']`), 'error');
|
||||
return light ?? dark ?? token(LIGHT, 'error');
|
||||
}
|
||||
|
||||
describe('--error (T-8)', () => {
|
||||
it('is defined for both modes', () => {
|
||||
expect(token(ROOT, 'error')).toBeDefined();
|
||||
@@ -78,7 +91,7 @@ describe('--error (T-8)', () => {
|
||||
for (const preset of Object.keys(THEME_PRESETS)) {
|
||||
for (const mode of ['dark', 'light'] as const) {
|
||||
it(`${preset} (${mode}): clears 4.5:1 on every surface`, () => {
|
||||
const error = parse(token(mode === 'dark' ? ROOT : LIGHT, 'error')!);
|
||||
const error = parse(errorToken(preset, mode)!);
|
||||
for (const surface of surfaces(preset, mode)) {
|
||||
expect(ratio(error, parse(surface)), surface).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
|
||||
74
components/__tests__/useSwipe.test.tsx
Normal file
74
components/__tests__/useSwipe.test.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useSwipe } from '@/hooks/useSwipe';
|
||||
|
||||
/**
|
||||
* A swipe is one finger (#467). A pinch that drifts sideways used to jump to
|
||||
* the next photo, because only the first touch was ever looked at.
|
||||
*/
|
||||
|
||||
type T = { clientX: number; clientY: number };
|
||||
const at = (x: number, y = 100): T => ({ clientX: x, clientY: y });
|
||||
const ev = (touches: T[], changedTouches: T[] = []) =>
|
||||
({ touches, changedTouches }) as unknown as TouchEvent;
|
||||
|
||||
function setup(enabled = true) {
|
||||
const onSwipeLeft = vi.fn();
|
||||
const onSwipeRight = vi.fn();
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { enabled: boolean }) =>
|
||||
useSwipe({ onSwipeLeft, onSwipeRight, enabled: props.enabled }),
|
||||
{ initialProps: { enabled } },
|
||||
);
|
||||
return { onSwipeLeft, onSwipeRight, handlers: () => result.current, rerender };
|
||||
}
|
||||
|
||||
describe('useSwipe', () => {
|
||||
it('turns a one-finger horizontal drag into a swipe', () => {
|
||||
const s = setup();
|
||||
s.handlers().handleTouchStart(ev([at(300)]));
|
||||
s.handlers().handleTouchEnd(ev([], [at(100)]));
|
||||
expect(s.onSwipeLeft).toHaveBeenCalledTimes(1);
|
||||
|
||||
s.handlers().handleTouchStart(ev([at(100)]));
|
||||
s.handlers().handleTouchEnd(ev([], [at(300)]));
|
||||
expect(s.onSwipeRight).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores a pinch, however far it drifts', () => {
|
||||
const s = setup();
|
||||
// First finger down, second joins, both drift left, lift one by one.
|
||||
s.handlers().handleTouchStart(ev([at(400)]));
|
||||
s.handlers().handleTouchStart(ev([at(400), at(500)]));
|
||||
s.handlers().handleTouchEnd(ev([at(150)], [at(250)]));
|
||||
s.handlers().handleTouchEnd(ev([], [at(100)]));
|
||||
expect(s.onSwipeLeft).not.toHaveBeenCalled();
|
||||
expect(s.onSwipeRight).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts the next one-finger swipe again after a pinch', () => {
|
||||
const s = setup();
|
||||
s.handlers().handleTouchStart(ev([at(400), at(500)]));
|
||||
s.handlers().handleTouchEnd(ev([], [at(100)]));
|
||||
s.handlers().handleTouchStart(ev([at(300)]));
|
||||
s.handlers().handleTouchEnd(ev([], [at(100)]));
|
||||
expect(s.onSwipeLeft).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing while disabled (the lightbox is zoomed)', () => {
|
||||
const s = setup(false);
|
||||
s.handlers().handleTouchStart(ev([at(300)]));
|
||||
s.handlers().handleTouchEnd(ev([], [at(100)]));
|
||||
expect(s.onSwipeLeft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a gesture that started disabled even if it ends enabled', () => {
|
||||
// Pinched out to fit and let go: the pan that preceded it is no swipe.
|
||||
const s = setup(false);
|
||||
s.handlers().handleTouchStart(ev([at(300)]));
|
||||
s.rerender({ enabled: true });
|
||||
s.handlers().handleTouchEnd(ev([], [at(100)]));
|
||||
expect(s.onSwipeLeft).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user