Files
Mangalord/frontend/e2e/reader-swipe.spec.ts
MechaCat02 3f1a5e9c41 feat(reader): swipe left/right to turn pages in single mode
Wire horizontal swipe into the mobile tap-zone overlay (single mode only,
so continuous mode keeps native vertical scroll). A leftward swipe past the
threshold turns to the next page, rightward to the previous; a mostly-
vertical drag is ignored so panning a tall page never turns it. The gesture
suppresses the synthesized zone tap so it doesn't double-fire. Swipe
classification is a pure, unit-tested helper; e2e drives real touch pointer
events for horizontal (both directions) and vertical cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:23:34 +02:00

74 lines
4.2 KiB
TypeScript

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: '<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1000"/>' });
}
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 }
);
}
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/);
});