import { test, expect, type Page } from '@playwright/test'; // Phase 3: the manga detail page gains a mobile hero (blurred backdrop // + transparent app bar), a sticky bottom CTA whose wording reflects // read progress, a 3-line description clamp with Read more, and an // overflow Sheet that hosts secondary actions (Edit / Upload chapter / // Add to collection / Force resync). Desktop layout is preserved. const MOBILE = { width: 390, height: 844 } as const; const DESKTOP = { width: 1280, height: 720 } as const; const mangaId = 'a1111111-1111-1111-1111-111111111111'; const firstChapterId = 'c1111111-1111-1111-1111-111111111111'; const latestChapterId = 'c2222222-2222-2222-2222-222222222222'; // Chapters arrive newest-first from the API (source_index DESC) — the // detail page's "Read first chapter" CTA targets the last element. const chaptersFixture = [ { id: latestChapterId, manga_id: mangaId, number: 2, title: 'Second', page_count: 10, created_at: '2026-02-01T00:00:00Z' }, { id: firstChapterId, manga_id: mangaId, number: 1, title: 'The Brand', page_count: 8, created_at: '2026-01-01T00:00:00Z' } ]; const shortDescription = 'A brief synopsis.'; const longDescription = 'This is an extended description that runs over multiple lines so that ' + 'the clamp + Read more behavior on mobile has actual content to truncate. ' + 'Long-form descriptions are common for manga that have been crawled from ' + 'public sources with editorial blurbs, so the catalog has to handle them ' + 'gracefully without pushing the chapter list below the fold on phones.'; function mangaFixture(description: string = shortDescription) { return { id: mangaId, title: 'Berserk', status: 'ongoing', alt_titles: [], description, cover_image_path: `mangas/${mangaId}/cover.png`, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', authors: [{ id: 'au1', name: 'Kentaro Miura' }], genres: [], tags: [] }; } async function mockDetail( page: Page, opts: { description?: string; readProgress?: { chapter_id: string; chapter_number: number; page: number; } | null; authed?: boolean; similar?: Array>; } = {} ) { const authed = opts.authed ?? false; await page.route('**/api/v1/auth/config', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ self_register_enabled: true, private_mode: false }) }) ); await page.route('**/api/v1/auth/me', (route) => route.fulfill({ status: authed ? 200 : 401, contentType: 'application/json', body: authed ? JSON.stringify({ user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } }) : JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } }) }) ); await page.route('**/api/v1/auth/me/preferences', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }) ); await page.route('**/api/v1/me/bookmarks*', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) }) ); await page.route(`**/api/v1/mangas/${mangaId}`, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mangaFixture(opts.description)) }) ); await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: chaptersFixture, page: { limit: 50, offset: 0, total: chaptersFixture.length } }) }) ); await page.route(`**/api/v1/mangas/${mangaId}/similar`, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: opts.similar ?? [] }) }) ); await page.route(`**/api/v1/me/read-progress/${mangaId}`, (route) => route.fulfill({ status: opts.readProgress ? 200 : 404, contentType: 'application/json', body: opts.readProgress ? JSON.stringify(opts.readProgress) : JSON.stringify({ error: { code: 'not_found', message: 'not found' } }) }) ); // 1x1 transparent PNG so cover image requests don't 404 noisily. const png = Buffer.from( '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082', 'hex' ); await page.route('**/api/v1/files/**', (route) => route.fulfill({ status: 200, contentType: 'image/png', body: png }) ); } test.describe('mobile manga detail', () => { test('phone viewport: with no progress, CTA reads "Read first chapter" and links to the oldest chapter', async ({ page }) => { await mockDetail(page); await page.setViewportSize(MOBILE); await page.goto(`/manga/${mangaId}`); await expect(page.getByTestId('mobile-hero')).toBeVisible(); const cta = page.getByTestId('continue-cta'); await expect(cta).toHaveText('Read first chapter'); await expect(cta).toHaveAttribute( 'href', `/manga/${mangaId}/chapter/${firstChapterId}` ); }); test('phone viewport: with progress, CTA reads "Continue {chapterLabel}" and links to the in-progress chapter', async ({ page }) => { await mockDetail(page, { authed: true, readProgress: { chapter_id: latestChapterId, chapter_number: 2, page: 5 } }); await page.setViewportSize(MOBILE); await page.goto(`/manga/${mangaId}`); const cta = page.getByTestId('continue-cta'); // chapterLabel returns the title when present, falling back to // "Chapter N" only when empty — the fixture has title "Second". await expect(cta).toHaveText(/Continue\s+Second/); await expect(cta).toHaveAttribute( 'href', `/manga/${mangaId}/chapter/${latestChapterId}` ); }); test('phone viewport: short description shows no Read more toggle', async ({ page }) => { await mockDetail(page, { description: shortDescription }); await page.setViewportSize(MOBILE); await page.goto(`/manga/${mangaId}`); await expect(page.getByTestId('manga-description')).toBeVisible(); await expect(page.getByTestId('read-more-toggle')).toHaveCount(0); }); test('phone viewport: long description shows Read more, expands on tap, collapses on second tap', async ({ page }) => { await mockDetail(page, { description: longDescription }); await page.setViewportSize(MOBILE); await page.goto(`/manga/${mangaId}`); const toggle = page.getByTestId('read-more-toggle'); await expect(toggle).toHaveText('Read more'); await toggle.click(); await expect(toggle).toHaveText('Read less'); await toggle.click(); await expect(toggle).toHaveText('Read more'); }); test('phone viewport: overflow ⋯ opens the actions sheet with Edit / Upload / Add-to-collection rows', async ({ page }) => { await mockDetail(page, { authed: true }); await page.setViewportSize(MOBILE); await page.goto(`/manga/${mangaId}`); await expect(page.getByTestId('detail-overflow-sheet')).toBeHidden(); await page.getByTestId('detail-overflow').click(); const sheet = page.getByTestId('detail-overflow-sheet'); await expect(sheet).toBeVisible(); await expect(sheet.getByTestId('overflow-edit')).toBeVisible(); await expect(sheet.getByTestId('overflow-upload-chapter')).toBeVisible(); await expect(sheet.getByTestId('overflow-add-to-collection')).toBeVisible(); }); test('desktop viewport: mobile hero is hidden, existing layout + action-row stays', async ({ page }) => { await mockDetail(page, { authed: true }); await page.setViewportSize(DESKTOP); await page.goto(`/manga/${mangaId}`); // Hero block exists in DOM but is hidden by CSS at >640px. await expect(page.getByTestId('mobile-hero')).toBeHidden(); // The desktop title/cover/action surfaces remain visible. await expect(page.getByTestId('manga-title')).toBeVisible(); await expect(page.getByTestId('bookmark-toggle')).toBeVisible(); }); test('renders the Similar section with recommended cards when present', async ({ page }) => { await mockDetail(page, { similar: [ { id: 'b2222222-2222-2222-2222-222222222222', title: 'Vinland Saga', 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: [{ id: 'au2', name: 'Makoto Yukimura' }], genres: [] } ] }); await page.goto(`/manga/${mangaId}`); const section = page.getByTestId('similar-section'); await expect(section).toBeVisible(); await expect(section.getByText('Vinland Saga')).toBeVisible(); }); test('omits the Similar section when there are no recommendations', async ({ page }) => { await mockDetail(page, { similar: [] }); await page.goto(`/manga/${mangaId}`); // The chapter list is the anchor that the page has rendered. await expect(page.getByTestId('manga-title')).toBeVisible(); await expect(page.getByTestId('similar-section')).toHaveCount(0); }); });