Files
Mangalord/frontend/e2e/reader-page-sizing.spec.ts
MechaCat02 cf8971faae fix(reader): render pages at a consistent width instead of thin stripes
Reader page sizing was height-driven — `max-height: 90vh` in single mode
and unbounded natural width in continuous mode. A long vertical page hit
the height cap first and, preserving aspect ratio, collapsed into a thin
vertical stripe; narrow and wide scans rendered at different widths.

Switch both modes to width-driven sizing via a capped `--reader-page-width`
reading column (min(100%, 700px)): every page renders at the same width
regardless of intrinsic dimensions, and tall pages extend downward and
scroll instead of shrinking. Top-align the single-mode grid and make the
prev/next chevrons sticky so they stay reachable on a long page.

Add a Playwright spec asserting equal rendered width across differently-
proportioned pages and that a tall page exceeds the viewport height. The
page-context-menu fixture served a 1x1 image that now renders as a 700x700
square taller than the viewport, making Playwright scroll it into view
before right-clicking — a synthetic scroll that trips the menu's by-design
close-on-scroll. Give that fixture a realistic landscape aspect so the page
fits the viewport, matching real user behaviour (no scroll on right-click).

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

150 lines
6.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, type Page } from './fixtures';
// Regression coverage for the reader page-sizing fix: pages must render at a
// consistent width regardless of their intrinsic dimensions, and a long
// (tall) page must extend downward and scroll instead of collapsing into a
// thin vertical stripe.
//
// The failure this guards against: sizing used to be height-driven
// (`max-height: 90vh` in single mode; unbounded natural width in continuous
// mode), so a tall webtoon strip shrank to a sliver and a narrow scan and a
// wide scan rendered at different widths.
const MANGA_ID = 'm1';
const CHAPTER_ID = 'c1';
// A capped reading column of ~700px on desktop; both modes should honour it.
const READING_WIDTH = 700;
const manga = {
id: MANGA_ID,
title: 'Sizing Test',
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
};
const chapter = {
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 1 is wide (1400×900), page 2 is a tall webtoon strip (800×4000).
const pages = [
{ id: 'p1', chapter_id: CHAPTER_ID, page_number: 1, storage_key: 'p/wide', content_type: 'image/svg+xml' },
{ id: 'p2', chapter_id: CHAPTER_ID, page_number: 2, storage_key: 'p/tall', content_type: 'image/svg+xml' }
];
// An SVG with an explicit width/height gives the <img> a real intrinsic
// size, so the rendered aspect ratio matches a genuine page image.
function svg(w: number, h: number): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect width="${w}" height="${h}" fill="#888"/></svg>`;
}
async function mockReader(page: Page) {
// Single dispatcher over the whole API surface — takes precedence over
// the fixture's 503 fallback and avoids route-glob precedence puzzles.
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) });
// Images.
if (pathname.includes('/files/p/wide')) {
return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg(1400, 900) });
}
if (pathname.includes('/files/p/tall')) {
return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg(800, 4000) });
}
// Auth: public, anonymous.
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' } });
}
// Reader data.
if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}/pages`)) {
return json(200, { pages });
}
if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}`)) return json(200, chapter);
if (pathname.includes(`/mangas/${MANGA_ID}/chapters`)) {
return json(200, { items: [chapter], page: { limit: 200, offset: 0, total: 1 } });
}
if (pathname.endsWith(`/mangas/${MANGA_ID}`)) return json(200, manga);
if (pathname.includes(`/me/read-progress/${MANGA_ID}`)) {
return json(401, { error: { code: 'unauthenticated', message: 'no' } });
}
console.warn(`[e2e] reader spec unmocked: ${pathname}`);
return json(503, { error: { code: 'e2e_unmocked', message: pathname } });
});
}
test('continuous mode renders all pages at a consistent capped width; tall pages scroll', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('mangalord-reader-mode', 'continuous'));
await mockReader(page);
await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`);
const wide = page.getByTestId('reader-page-1');
const tall = page.getByTestId('reader-page-2');
await expect(wide).toBeVisible();
await expect(tall).toBeVisible();
const wideBox = await wide.boundingBox();
const tallBox = await tall.boundingBox();
const viewport = page.viewportSize();
if (!wideBox || !tallBox || !viewport) throw new Error('missing layout metrics');
// 1. Consistent width regardless of intrinsic dimensions.
expect(Math.abs(wideBox.width - tallBox.width)).toBeLessThanOrEqual(1);
// 2. Capped reading column on desktop (not full-bleed, not a stripe).
expect(wideBox.width).toBeGreaterThan(READING_WIDTH - 40);
expect(wideBox.width).toBeLessThanOrEqual(READING_WIDTH + 2);
// 3. The tall page extends past the viewport (scrolls, doesn't shrink).
expect(tallBox.height).toBeGreaterThan(viewport.height);
});
test('single mode renders a tall page at the capped width, not a thin stripe', async ({ page }) => {
await mockReader(page); // default mode is single
await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`);
const img = page.getByTestId('reader-page');
await expect(img).toBeVisible();
// Advance from the wide first page to the tall second page.
await page.getByTestId('reader-next').click();
await expect(img).toHaveAttribute('src', /tall/);
const box = await img.boundingBox();
const viewport = page.viewportSize();
if (!box || !viewport) throw new Error('missing layout metrics');
// Width is the capped reading column, not a sliver.
expect(box.width).toBeGreaterThan(READING_WIDTH - 40);
expect(box.width).toBeLessThanOrEqual(READING_WIDTH + 2);
// And the page runs past the viewport so the reader scrolls it.
expect(box.height).toBeGreaterThan(viewport.height);
});