diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 7b44851..98a18c1 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.48.0" +version = "0.49.1" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 323f95c..028582e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.48.0" +version = "0.49.1" edition = "2021" default-run = "mangalord" @@ -57,3 +57,13 @@ http-body-util = "0.1" mime = "0.3" futures-util = "0.3" tokio = { version = "1", features = ["test-util"] } + +# Trim debug builds: keep line numbers in panics / backtraces but drop the +# full DWARF info (variable-level inspection in gdb/lldb). With a sqlx + +# axum + tokio dep tree the default ("full") leaves backend/target on the +# order of tens of GiB; this typically cuts ~50–70% off that. +[profile.dev] +debug = "line-tables-only" + +[profile.test] +debug = "line-tables-only" diff --git a/frontend/e2e/manga-list.spec.ts b/frontend/e2e/manga-list.spec.ts index 19e00e5..04bd298 100644 --- a/frontend/e2e/manga-list.spec.ts +++ b/frontend/e2e/manga-list.spec.ts @@ -10,6 +10,15 @@ import { test, expect, type Page } from '@playwright/test'; const emptyPage = { items: [], page: { limit: 50, offset: 0, total: null } }; async function mockAnonymous(page: Page) { + // Force public mode so the root +layout.ts doesn't bounce us to /login + // (a dev backend with PRIVATE_MODE=true must not leak into E2E runs). + 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, @@ -69,3 +78,53 @@ test('search updates the manga list', async ({ page }) => { await expect(page.getByTestId('manga-list')).toContainText('Berserk'); expect(lastSearch).toBe('berserk'); }); + +test('clicking Next paginates to page 2 and updates the URL', async ({ page }) => { + await mockAnonymous(page); + + // Fake a catalogue of 75 mangas; page 1 is ids 1..50, page 2 is ids 51..75. + const TOTAL = 75; + function mangaAt(i: number) { + return { + id: `m${i}`, + title: `Manga ${i}`, + author: 'Test', + description: null, + cover_image_path: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + authors: [], + genres: [] + }; + } + + await page.route('**/api/v1/mangas*', async (route) => { + const url = new URL(route.request().url()); + const limit = Number(url.searchParams.get('limit') ?? '50'); + const offset = Number(url.searchParams.get('offset') ?? '0'); + const items: ReturnType[] = []; + for (let i = offset + 1; i <= Math.min(offset + limit, TOTAL); i++) { + items.push(mangaAt(i)); + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items, + page: { limit, offset, total: TOTAL } + }) + }); + }); + + await page.goto('/'); + await expect(page.getByTestId('manga-total')).toContainText('Showing 1–50 of 75'); + await expect(page.getByTestId('manga-list')).toContainText('Manga 1'); + await expect(page.getByTestId('manga-list')).not.toContainText('Manga 75'); + + await page.getByTestId('manga-pager').getByRole('button', { name: /next/i }).click(); + + await expect(page).toHaveURL(/[?&]page=2(&|$)/); + await expect(page.getByTestId('manga-total')).toContainText('Showing 51–75 of 75'); + await expect(page.getByTestId('manga-list')).toContainText('Manga 75'); + await expect(page.getByTestId('manga-list')).not.toContainText('Manga 1'); +}); diff --git a/frontend/e2e/page-title.spec.ts b/frontend/e2e/page-title.spec.ts new file mode 100644 index 0000000..ccf16c2 --- /dev/null +++ b/frontend/e2e/page-title.spec.ts @@ -0,0 +1,67 @@ +import { test, expect, type Page } from '@playwright/test'; + +// Guards the title-on-nav behavior: without this, a stale title from +// the last manga / author page lingers when the user navigates to a +// generic page like /upload. + +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' } }) + }); + }); + await page.route('**/api/v1/mangas*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) + }); + }); +} + +test('static route titles use the brand-first layout map', async ({ page }) => { + await mockAnonymous(page); + + await page.goto('/'); + await expect(page).toHaveTitle('Mangalord'); + + await page.goto('/upload'); + await expect(page).toHaveTitle('Mangalord | Upload'); + + await page.goto('/login'); + await expect(page).toHaveTitle('Mangalord | Login'); + + await page.goto('/bookmarks'); + await expect(page).toHaveTitle('Mangalord | Bookmarks'); + + await page.goto('/collections'); + await expect(page).toHaveTitle('Mangalord | Collections'); +}); + +test('title updates when navigating away from a content page', async ({ page }) => { + await mockAnonymous(page); + + // Pretend we just left a manga detail page — the document title + // would have been overridden to "Mangalord | Berserk". Use evaluate + // to set it synthetically so we can assert the regression cleanly + // even though the dynamic page itself isn't mocked here. + await page.goto('/'); + await page.evaluate(() => { + document.title = 'Mangalord | Berserk'; + }); + expect(await page.title()).toBe('Mangalord | Berserk'); + + // Client-side nav to /upload — the root layout must reassert its + // mapped title or the stale "Berserk" lingers. + await page.goto('/upload'); + await expect(page).toHaveTitle('Mangalord | Upload'); +}); diff --git a/frontend/package.json b/frontend/package.json index 7aa908a..679b33e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.48.0", + "version": "0.49.1", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/Pager.svelte b/frontend/src/lib/components/Pager.svelte new file mode 100644 index 0000000..f3b5dfb --- /dev/null +++ b/frontend/src/lib/components/Pager.svelte @@ -0,0 +1,128 @@ + + +{#if totalPages > 1} + +{/if} + + diff --git a/frontend/src/lib/components/Pager.svelte.test.ts b/frontend/src/lib/components/Pager.svelte.test.ts new file mode 100644 index 0000000..a587349 --- /dev/null +++ b/frontend/src/lib/components/Pager.svelte.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/svelte'; +import Pager from './Pager.svelte'; + +afterEach(() => cleanup()); + +describe('Pager', () => { + it('renders nothing when totalPages <= 1', () => { + const { container } = render(Pager, { props: { page: 1, totalPages: 1, onChange: () => {} } }); + expect(container.querySelector('nav')).toBeNull(); + }); + + it('disables Prev on the first page and Next on the last', () => { + const { rerender } = render(Pager, { + props: { page: 1, totalPages: 5, onChange: () => {} } + }); + expect((screen.getByRole('button', { name: /prev/i }) as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByRole('button', { name: /next/i }) as HTMLButtonElement).disabled).toBe(false); + + rerender({ page: 5, totalPages: 5, onChange: () => {} }); + expect((screen.getByRole('button', { name: /prev/i }) as HTMLButtonElement).disabled).toBe(false); + expect((screen.getByRole('button', { name: /next/i }) as HTMLButtonElement).disabled).toBe(true); + }); + + it('marks the current page button as aria-current', () => { + render(Pager, { props: { page: 3, totalPages: 5, onChange: () => {} } }); + const current = screen.getByRole('button', { name: /go to page 3/i }); + expect(current.getAttribute('aria-current')).toBe('page'); + }); + + it('fires onChange with the clicked page number', async () => { + const onChange = vi.fn(); + render(Pager, { props: { page: 1, totalPages: 5, onChange } }); + screen.getByRole('button', { name: /go to page 3/i }).click(); + expect(onChange).toHaveBeenCalledWith(3); + }); + + it('Prev decrements and Next increments via onChange', () => { + const onChange = vi.fn(); + render(Pager, { props: { page: 3, totalPages: 5, onChange } }); + screen.getByRole('button', { name: /prev/i }).click(); + screen.getByRole('button', { name: /next/i }).click(); + expect(onChange).toHaveBeenNthCalledWith(1, 2); + expect(onChange).toHaveBeenNthCalledWith(2, 4); + }); + + it('shows every page button when totalPages <= 7', () => { + render(Pager, { props: { page: 4, totalPages: 7, onChange: () => {} } }); + for (let n = 1; n <= 7; n++) { + expect(screen.getByRole('button', { name: new RegExp(`go to page ${n}$`, 'i') })).toBeTruthy(); + } + }); + + it('collapses middle pages with ellipsis when totalPages > 7 and current is in the middle', () => { + render(Pager, { props: { page: 10, totalPages: 24, onChange: () => {} } }); + // First and last are always shown + expect(screen.getByRole('button', { name: /go to page 1$/i })).toBeTruthy(); + expect(screen.getByRole('button', { name: /go to page 24$/i })).toBeTruthy(); + // Current and direct neighbours are shown + expect(screen.getByRole('button', { name: /go to page 9$/i })).toBeTruthy(); + expect(screen.getByRole('button', { name: /go to page 10$/i })).toBeTruthy(); + expect(screen.getByRole('button', { name: /go to page 11$/i })).toBeTruthy(); + // Distant pages are NOT rendered as buttons + expect(screen.queryByRole('button', { name: /go to page 2$/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /go to page 23$/i })).toBeNull(); + // Ellipsis appears on both sides + const ellipses = screen.getAllByText('…'); + expect(ellipses.length).toBeGreaterThanOrEqual(2); + }); + + it('does not duplicate boundary buttons when current is near the edge', () => { + render(Pager, { props: { page: 2, totalPages: 20, onChange: () => {} } }); + // Each page button rendered should be unique — no duplicate "go to page 1" + const first = screen.getAllByRole('button', { name: /go to page 1$/i }); + expect(first.length).toBe(1); + }); +}); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index c934a44..1f530af 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,6 +1,7 @@ + + {layoutTitle} + +