import { test, expect, type Page } from './fixtures'; // Single-page reader: a horizontal swipe turns the page; a mostly-vertical // drag does not (so panning a tall page never turns it). Swipe is wired into // the mobile tap-zone overlay, which only exists in single mode. const MANGA_ID = 'm1'; const CHAPTER_ID = 'c1'; async function mockReader(page: Page) { await page.route('**/api/v1/**', async (route) => { const { pathname } = new URL(route.request().url()); const json = (status: number, body: unknown) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); if (pathname.includes('/files/')) { return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: '' }); } if (pathname.endsWith('/auth/config')) return json(200, { self_register_enabled: true, private_mode: false }); if (pathname.endsWith('/auth/me')) return json(401, { error: { code: 'unauthenticated', message: 'no' } }); if (pathname.endsWith('/auth/me/preferences')) return json(401, { error: { code: 'unauthenticated', message: 'no' } }); if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}/pages`)) { return json(200, { pages: [ { id: 'p1', chapter_id: CHAPTER_ID, page_number: 1, storage_key: 'k/1', content_type: 'image/svg+xml' }, { id: 'p2', chapter_id: CHAPTER_ID, page_number: 2, storage_key: 'k/2', content_type: 'image/svg+xml' } ] }); } if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}`)) return json(200, { id: CHAPTER_ID, manga_id: MANGA_ID, number: 1, title: null, page_count: 2, created_at: '2026-01-01T00:00:00Z', size_bytes: 0 }); if (pathname.includes(`/mangas/${MANGA_ID}/chapters`)) return json(200, { items: [{ id: CHAPTER_ID, manga_id: MANGA_ID, number: 1, title: null, page_count: 2, created_at: '2026-01-01T00:00:00Z', size_bytes: 0 }], page: { limit: 200, offset: 0, total: 1 } }); if (pathname.endsWith(`/mangas/${MANGA_ID}`)) return json(200, { id: MANGA_ID, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null, cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0 }); if (pathname.includes('/me/read-progress')) return json(401, { error: { code: 'unauthenticated', message: 'no' } }); return json(503, { error: { code: 'e2e_unmocked', message: pathname } }); }); } // Dispatch a touch pointer swipe on the tap-zone overlay. async function swipe(page: Page, dx: number, dy: number) { await page.evaluate( ({ dx, dy }) => { const el = document.querySelector('[data-testid="reader-tap-center"]'); if (!el) throw new Error('tap zone not found'); const sx = 300; const sy = 500; const ev = (type: string, x: number, y: number) => new PointerEvent(type, { clientX: x, clientY: y, pointerType: 'touch', bubbles: true, cancelable: true }); el.dispatchEvent(ev('pointerdown', sx, sy)); el.dispatchEvent(ev('pointermove', sx + dx / 2, sy + dy / 2)); el.dispatchEvent(ev('pointerup', sx + dx, sy + dy)); }, { dx, dy } ); } // Dispatch pointerdown, hold (so long-press fires), then swipe+lift. async function holdThenSwipe(page: Page, holdMs: number, dx: number) { await page.evaluate(() => { const el = document.querySelector('[data-testid="reader-tap-center"]'); if (!el) throw new Error('tap zone not found'); el.dispatchEvent(new PointerEvent('pointerdown', { clientX: 300, clientY: 500, pointerType: 'touch', bubbles: true, cancelable: true })); }); await page.waitForTimeout(holdMs); await page.evaluate(({ dx }) => { const el = document.querySelector('[data-testid="reader-tap-center"]'); if (!el) throw new Error('tap zone not found'); el.dispatchEvent(new PointerEvent('pointermove', { clientX: 300 + dx, clientY: 505, pointerType: 'touch', bubbles: true, cancelable: true })); el.dispatchEvent(new PointerEvent('pointerup', { clientX: 300 + dx, clientY: 505, pointerType: 'touch', bubbles: true, cancelable: true })); }, { dx }); } test('a long-press then swipe does not also turn the page', async ({ page }) => { await mockReader(page); await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`); const img = page.getByTestId('reader-page'); await expect(img).toHaveAttribute('src', /k\/1/); // Hold past the 450ms long-press threshold, then swipe left before lifting. await holdThenSwipe(page, 550, -200); // The long-press consumed the gesture — the page must not have turned. await expect(img).toHaveAttribute('src', /k\/1/); }); test('horizontal swipe turns the page; vertical drag does not', async ({ page }) => { await mockReader(page); await page.setViewportSize({ width: 390, height: 844 }); // mobile → tap zones render await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`); const img = page.getByTestId('reader-page'); await expect(img).toHaveAttribute('src', /k\/1/); // A near-vertical drag must NOT turn the page. await swipe(page, 6, -220); await expect(img).toHaveAttribute('src', /k\/1/); // A leftward horizontal swipe advances to the next page. await swipe(page, -200, 8); await expect(img).toHaveAttribute('src', /k\/2/); // A rightward horizontal swipe goes back. await swipe(page, 200, 8); await expect(img).toHaveAttribute('src', /k\/1/); });