// @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, * mouse click and pan, 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 => ({ 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 ( { onNextSpy?.(); setIndex((i) => (i + 1) % assets.length); }} onPrev={() => setIndex((i) => (i - 1 + assets.length) % assets.length)} /> ); } const restore: Array<() => void> = []; function stub(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)[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('img[src^="/p/"]')!; const fullImage = () => dialog().querySelector('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(); expect(zoomButton()).not.toBeNull(); expect(zoomButton()!.getAttribute('aria-pressed')).toBe('false'); }); it('is absent without a zoom source, and for a video', () => { render(); expect(zoomButton()).toBeNull(); fireEvent.click(screen.getByLabelText('Next photo')); expect(zoomButton()).toBeNull(); }); it('toggles 1:1 and back', () => { render(); 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(); expect(zoomButton()).toBeNull(); key('+'); expect(fullImage()).toBeNull(); }); it('still offers it for a smaller photo', () => { coarse(); render( , ); expect(zoomButton()).not.toBeNull(); }); it('keeps the large photo zoomable with a mouse', () => { render(); expect(zoomButton()).not.toBeNull(); }); }); describe('the full-resolution file', () => { it('is not requested on open or on the next photo', () => { render(); 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(); 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(); 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(); 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(); 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(); key('+'); expect(preview().style.transform).toBe(''); }); it('Esc leaves the zoom first, and closes the viewer only after that', () => { const onClose = vi.fn(); render(); 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(); key('?'); expect(screen.queryByText('Zoom in / out')).not.toBeNull(); unmount(); render(); key('?'); expect(screen.queryByText('Zoom in / out')).toBeNull(); }); }); describe('gestures', () => { /** A mouse press at a point, released `moveBy` px further on. */ const press = (surface: HTMLElement, x: number, y: number, moveBy = 0) => { const at = { pointerId: 1, pointerType: 'mouse', button: 0, clientY: y }; fireEvent.pointerDown(surface, { ...at, clientX: x }); if (moveBy) fireEvent.pointerMove(surface, { ...at, clientX: x + moveBy }); fireEvent.pointerUp(surface, { ...at, clientX: x + moveBy }); }; it('a click on the photo zooms in by half around the cursor, and back', () => { render(); const surface = preview().parentElement!; press(surface, 600, 300); // Fit box centre (500, 300); the cursor 100px right of it stays put at // 1.5×, and the top edge is clamped to the 768px jsdom viewport. expect(preview().style.transform).toBe('translate3d(-50px, 18px, 0) scale(1.5)'); press(surface, 600, 300); expect(preview().style.transform).toContain('scale(1)'); }); it('a drag pans the zoomed photo instead of leaving the zoom', () => { render(); const surface = preview().parentElement!; press(surface, 600, 300); press(surface, 600, 300, 100); expect(preview().style.transform).toBe('translate3d(50px, 18px, 0) scale(1.5)'); }); it('a click beside the photo does not zoom', () => { render(); press(preview().parentElement!, 1010, 300); expect(preview().style.transform).toBe(''); }); it('the dblclick event itself no longer zooms', () => { render(); const surface = preview().parentElement!; fireEvent.doubleClick(surface, { clientX: 600, clientY: 300 }); expect(preview().style.transform).toBe(''); }); it('no swipe navigation while zoomed', () => { const onNextSpy = vi.fn(); render(); 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); }); });