import { test, expect, type Page } from '@playwright/test'; // Phase 5: Account becomes an inset-grouped hub on mobile (Profile / // Preferences / Change password + red Log out at the bottom) and // /library hosts a SegmentedControl over Bookmarks / Collections / // History. Profile-layout horizontal tabs are hidden on mobile. Desktop // is unchanged. const MOBILE = { width: 390, height: 844 } as const; const DESKTOP = { width: 1280, height: 720 } as const; async function mockSession( page: Page, opts: { authed?: boolean; logoutCalls?: { count: number } } = {} ) { 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: 'fabian', created_at: '2026-01-05T00:00:00Z', is_admin: false } }) : JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } }) }) ); await page.route('**/api/v1/auth/me/preferences', (route) => route.fulfill({ status: authed ? 200 : 401, contentType: 'application/json', body: authed ? JSON.stringify({ reader_mode: 'single', reader_page_gap: 'none', updated_at: '2026-01-05T00:00:00Z' }) : JSON.stringify({ error: { code: 'x', message: 'x' } }) }) ); await page.route('**/api/v1/auth/logout', (route) => { if (opts.logoutCalls) opts.logoutCalls.count += 1; return route.fulfill({ status: 204, body: '' }); }); } async function mockLibraryData(page: Page, opts: { authed?: boolean } = {}) { const status = opts.authed ? 200 : 401; const emptyPage = JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }); const unauth = JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } }); await page.route('**/api/v1/me/bookmarks*', (route) => route.fulfill({ status, contentType: 'application/json', body: opts.authed ? emptyPage : unauth }) ); await page.route('**/api/v1/me/collections*', (route) => route.fulfill({ status, contentType: 'application/json', body: opts.authed ? emptyPage : unauth }) ); await page.route('**/api/v1/me/read-progress*', (route) => route.fulfill({ status, contentType: 'application/json', body: opts.authed ? emptyPage : unauth }) ); } test.describe('mobile library', () => { test('phone viewport: BottomNav Library tab navigates to /library', async ({ page }) => { await mockSession(page); await mockLibraryData(page); await page.route('**/api/v1/mangas*', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) }) ); await page.setViewportSize(MOBILE); await page.goto('/'); await page.getByTestId('bottom-nav-library').click(); await expect(page).toHaveURL(/\/library$/); await expect(page.getByTestId('library-tabs')).toBeVisible(); }); test('phone viewport: SegmentedControl swaps Library sub-tabs and updates ?tab=', async ({ page }) => { await mockSession(page, { authed: true }); await mockLibraryData(page, { authed: true }); await page.setViewportSize(MOBILE); await page.goto('/library'); // Default sub-tab is bookmarks — URL has no `tab=` param. await expect(page).toHaveURL(/\/library$/); await expect(page.getByTestId('library-bookmarks-empty')).toBeVisible(); await page .getByTestId('library-tabs') .getByRole('radio', { name: 'Collections' }) .click(); await expect(page).toHaveURL(/\?tab=collections$/); await expect(page.getByTestId('library-collections-empty')).toBeVisible(); await page .getByTestId('library-tabs') .getByRole('radio', { name: 'History' }) .click(); await expect(page).toHaveURL(/\?tab=history$/); await expect(page.getByTestId('library-history-empty')).toBeVisible(); // Returning to Bookmarks clears the param entirely. await page .getByTestId('library-tabs') .getByRole('radio', { name: 'Bookmarks' }) .click(); await expect(page).toHaveURL(/\/library$/); }); test('phone viewport on /library unauth: sign-in prompt, no list', async ({ page }) => { await mockSession(page); await mockLibraryData(page); await page.setViewportSize(MOBILE); await page.goto('/library'); await expect(page.getByTestId('library-signin')).toBeVisible(); await expect(page.getByTestId('library-bookmarks-empty')).toHaveCount(0); }); }); test.describe('mobile account hub', () => { test('phone viewport authed: inset-grouped hub renders with profile / preferences / change-password / logout rows', async ({ page }) => { await mockSession(page, { authed: true }); await page.setViewportSize(MOBILE); await page.goto('/profile/account'); await expect(page.getByTestId('account-hub')).toBeVisible(); await expect(page.getByTestId('account-username')).toHaveText('fabian'); await expect(page.getByTestId('account-row-profile')).toBeVisible(); await expect(page.getByTestId('account-row-preferences')).toBeVisible(); await expect(page.getByTestId('account-row-change-password')).toBeVisible(); await expect(page.getByTestId('account-row-logout')).toBeVisible(); // Desktop card MUST not render — only the hub view is active // on mobile so we don't double-up the password form testids. await expect(page.getByTestId('account-desktop-card')).toHaveCount(0); }); test('phone viewport authed: Change password row opens the bottom sheet', async ({ page }) => { await mockSession(page, { authed: true }); await page.setViewportSize(MOBILE); await page.goto('/profile/account'); await expect(page.getByTestId('password-sheet')).toBeHidden(); await page.getByTestId('account-row-change-password').click(); await expect(page.getByTestId('password-sheet')).toBeVisible(); await expect( page.getByTestId('password-sheet').getByTestId('current-password') ).toBeVisible(); }); test('phone viewport authed: Log out row fires /auth/logout and routes to /login', async ({ page }) => { const logoutCalls = { count: 0 }; await mockSession(page, { authed: true, logoutCalls }); await page.setViewportSize(MOBILE); await page.goto('/profile/account'); await page.getByTestId('account-row-logout').click(); await expect(page).toHaveURL(/\/login$/); expect(logoutCalls.count).toBe(1); }); test('phone viewport unauth: sign-in CTA, no hub', async ({ page }) => { await mockSession(page); await page.setViewportSize(MOBILE); await page.goto('/profile/account'); await expect(page.getByTestId('account-signin')).toBeVisible(); await expect(page.getByTestId('account-hub')).toHaveCount(0); }); test('phone viewport authed: profile horizontal tabs are hidden', async ({ page }) => { await mockSession(page, { authed: true }); await page.setViewportSize(MOBILE); await page.goto('/profile/account'); await expect(page.getByTestId('tab-account')).toBeHidden(); }); test('desktop viewport authed: existing password card visible, hub is not', async ({ page }) => { await mockSession(page, { authed: true }); await page.setViewportSize(DESKTOP); await page.goto('/profile/account'); await expect(page.getByTestId('account-desktop-card')).toBeVisible(); await expect(page.getByTestId('password-form')).toBeVisible(); await expect(page.getByTestId('account-hub')).toHaveCount(0); // Desktop profile tabs remain. await expect(page.getByTestId('tab-account')).toBeVisible(); }); });