import { test, expect, type Page } from './fixtures'; // Phase 2: the catalog (/) gets a mobile chrome — search input full // width, Filter and Sort as chip buttons that open bottom sheets, and // the active-filter row exposes selected facets as removable chips. The // inline desktop filter panel is hidden on mobile. const MOBILE = { width: 390, height: 844 } as const; const DESKTOP = { width: 1280, height: 720 } as const; async function mockAnonymous(page: Page) { await page.route('**/api/v1/auth/config', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ self_register_enabled: true, private_mode: false }) }); }); await page.route('**/api/v1/auth/me', async (route) => { await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } }) }); }); } async function mockCatalog( page: Page, opts: { genres?: { id: string; name: string }[]; capture?: (url: URL) => void } = {} ) { const genres = opts.genres ?? [ { id: 'g-action', name: 'Action' }, { id: 'g-romance', name: 'Romance' } ]; await page.route('**/api/v1/genres*', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(genres) }); }); await page.route('**/api/v1/mangas*', async (route) => { const url = new URL(route.request().url()); opts.capture?.(url); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) }); }); } test.describe('mobile catalog chrome', () => { test('phone viewport: Sort chip is visible, inline desktop select is hidden', async ({ page }) => { await mockAnonymous(page); await mockCatalog(page); await page.setViewportSize(MOBILE); await page.goto('/'); await expect(page.getByTestId('sort-chip')).toBeVisible(); await expect(page.getByTestId('sort-select')).toBeHidden(); }); test('desktop viewport: inline Sort select is visible, mobile chip is hidden', async ({ page }) => { await mockAnonymous(page); await mockCatalog(page); await page.setViewportSize(DESKTOP); await page.goto('/'); await expect(page.getByTestId('sort-select')).toBeVisible(); await expect(page.getByTestId('sort-chip')).toBeHidden(); }); // `empty` only renders after onMount's listMangas() resolves, which // means hydration is complete and click handlers are attached. Without // this gate, Playwright dispatches the click against the SSR'd static // button before Svelte takes over and the state mutation is dropped. async function waitForHydration(page: Page) { await expect(page.getByTestId('empty')).toBeVisible(); } test('phone viewport: Filter chip opens the bottom sheet, not the inline panel', async ({ page }) => { await mockAnonymous(page); await mockCatalog(page); await page.setViewportSize(MOBILE); await page.goto('/'); await waitForHydration(page); await expect(page.getByTestId('filter-sheet')).toBeHidden(); await page.getByTestId('filters-toggle').click(); await expect(page.getByTestId('filter-sheet')).toBeVisible(); // Inline panel must not render on mobile — the snippet gated by // !isMobileViewport means the same form is never on the page twice. await expect(page.getByTestId('filters-panel')).toHaveCount(0); }); test('phone viewport: picking a genre updates URL + shows an active-filter chip', async ({ page }) => { // The API uses `genre_id` (singular, comma-joined) while the URL // we surface to the user uses `genres` — verify both paths. let lastGenreIdParam: string | null = null; await mockAnonymous(page); await mockCatalog(page, { capture: (url) => { lastGenreIdParam = url.searchParams.get('genre_id'); } }); await page.setViewportSize(MOBILE); await page.goto('/'); await waitForHydration(page); await page.getByTestId('filters-toggle').click(); await page.getByTestId('genre-filter-Action').click(); await expect(page).toHaveURL(/genres=g-action/); await expect(page.getByTestId('active-filter-genre-Action')).toBeVisible(); expect(lastGenreIdParam).toBe('g-action'); }); test('phone viewport: removing an active-filter chip clears that facet', async ({ page }) => { await mockAnonymous(page); await mockCatalog(page); await page.setViewportSize(MOBILE); await page.goto('/?genres=g-action'); await waitForHydration(page); // The chip appears after hydrateFromUrl resolves the genre id. const chip = page.getByTestId('active-filter-genre-Action'); await expect(chip).toBeVisible(); // Chip's remove button has an aria-label "Remove genre Action". await chip.getByRole('button', { name: 'Remove genre Action' }).click(); await expect(chip).toHaveCount(0); await expect(page).toHaveURL((url) => !url.search.includes('genres=')); }); test('phone viewport: Sort sheet swaps field + direction, staying open', async ({ page }) => { let lastSortParam: string | null = null; let lastOrderParam: string | null = null; await mockAnonymous(page); await mockCatalog(page, { capture: (url) => { lastSortParam = url.searchParams.get('sort'); lastOrderParam = url.searchParams.get('order'); } }); await page.setViewportSize(MOBILE); await page.goto('/'); await waitForHydration(page); await page.getByTestId('sort-chip').click(); await expect(page.getByTestId('sort-sheet')).toBeVisible(); // Picking a field reloads but keeps the sheet open so the direction // can be adjusted in the same interaction. await page.getByTestId('sort-sheet').getByRole('radio', { name: /^Title$/ }).click(); await expect(page.getByTestId('sort-sheet')).toBeVisible(); await expect(page).toHaveURL(/sort=title/); expect(lastSortParam).toBe('title'); // The API call always carries an explicit direction; Title's natural // default is A→Z (asc)... expect(lastOrderParam).toBe('asc'); // ...but the browser URL omits it since it's the default. await expect(page).not.toHaveURL(/order=/); // Flipping the direction to Z→A surfaces an explicit order param both // on the wire and in the URL. await page.getByTestId('sort-order-mobile-desc').click(); await expect(page).toHaveURL(/order=desc/); expect(lastOrderParam).toBe('desc'); // The Done button gives an explicit dismissal affordance once the // field + direction are set. await page.getByTestId('sort-done').click(); await expect(page.getByTestId('sort-sheet')).toBeHidden(); }); });