diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 5a0b785..77e9e38 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.88.1" +version = "0.89.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 20507fc..765d388 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.88.1" +version = "0.89.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/package.json b/frontend/package.json index bac57a1..50639c3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.88.1", + "version": "0.89.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/HistoryList.svelte b/frontend/src/lib/components/HistoryList.svelte new file mode 100644 index 0000000..e9b987e --- /dev/null +++ b/frontend/src/lib/components/HistoryList.svelte @@ -0,0 +1,230 @@ + + +{#if clearError} + +{/if} + +{#if rows.length === 0} +

{emptyText}

+{:else} + +{/if} + + diff --git a/frontend/src/lib/components/HistoryList.svelte.test.ts b/frontend/src/lib/components/HistoryList.svelte.test.ts new file mode 100644 index 0000000..7fcad8d --- /dev/null +++ b/frontend/src/lib/components/HistoryList.svelte.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/svelte'; +import HistoryList from './HistoryList.svelte'; +import type { ReadProgressSummary } from '$lib/api/read_progress'; + +afterEach(() => cleanup()); + +function entry(over: Partial = {}): ReadProgressSummary { + return { + manga_id: 'm1', + manga_title: 'Berserk', + manga_cover_image_path: null, + chapter_id: 'c1', + chapter_number: 5, + page: 1, + updated_at: '2026-01-02T00:00:00Z', + ...over + }; +} + +describe('HistoryList', () => { + it('renders one row per entry with a continue link to the chapter', () => { + render(HistoryList, { + props: { entries: [entry({ manga_id: 'm1', chapter_id: 'c9', chapter_number: 9 })] } + }); + const cont = screen.getByRole('link', { name: /Continue Chapter 9/i }); + expect(cont.getAttribute('href')).toBe('/manga/m1/chapter/c9'); + }); + + it('appends the page number to the continue line only past page 1', () => { + // The page suffix is a separate text node from "Continue Chapter N", + // so assert against the link's normalized text rather than getByText. + render(HistoryList, { props: { entries: [entry({ page: 7, chapter_number: 3 })] } }); + const link = screen.getByRole('link', { name: /Continue Chapter 3/i }); + expect(link.textContent?.replace(/\s+/g, ' ').trim()).toBe('Continue Chapter 3 — page 7'); + cleanup(); + render(HistoryList, { props: { entries: [entry({ page: 1, chapter_number: 3 })] } }); + expect(screen.queryByText(/page 1/i)).toBeNull(); + }); + + it('shows a "chapter removed" fallback when the chapter id survives but its number is gone', () => { + render(HistoryList, { + props: { entries: [entry({ chapter_id: 'c1', chapter_number: null })] } + }); + expect(screen.getByText(/chapter removed/i)).toBeTruthy(); + expect(screen.queryByRole('link', { name: /Continue/i })).toBeNull(); + }); + + it('shows a "whole manga" fallback when there is no chapter', () => { + render(HistoryList, { + props: { entries: [entry({ chapter_id: null, chapter_number: null, page: 4 })] } + }); + expect(screen.getByText(/Whole manga, page 4/i)).toBeTruthy(); + }); + + it('renders the empty state with the provided copy and testid when there are no entries', () => { + render(HistoryList, { + props: { entries: [], emptyText: 'Nothing here yet.', testid: 'library-history' } + }); + const empty = screen.getByTestId('library-history-empty'); + expect(empty.textContent).toContain('Nothing here yet.'); + expect(screen.queryByTestId('library-history-list')).toBeNull(); + }); + + it('omits clear buttons entirely when no onClear is supplied (read-only mode)', () => { + render(HistoryList, { props: { entries: [entry()] } }); + expect(screen.queryByRole('button', { name: /Clear/i })).toBeNull(); + }); + + it('optimistically removes a row and calls onClear when the clear button is pressed', async () => { + const onClear = vi.fn().mockResolvedValue(undefined); + render(HistoryList, { + props: { + entries: [entry({ manga_id: 'm1', manga_title: 'Berserk' })], + onClear, + testid: 'history-reading' + } + }); + await fireEvent.click(screen.getByRole('button', { name: /Clear Berserk from history/i })); + expect(onClear).toHaveBeenCalledWith( + expect.objectContaining({ manga_id: 'm1' }) + ); + await waitFor(() => expect(screen.queryByText('Berserk')).toBeNull()); + }); + + it('restores the row and surfaces an inline error when onClear rejects', async () => { + const onClear = vi.fn().mockRejectedValue(new Error('network down')); + render(HistoryList, { + props: { + entries: [entry({ manga_id: 'm1', manga_title: 'Berserk' })], + onClear, + testid: 'history-reading' + } + }); + await fireEvent.click(screen.getByRole('button', { name: /Clear Berserk from history/i })); + await waitFor(() => { + const err = screen.getByTestId('history-reading-error'); + expect(err.textContent).toMatch(/network down/i); + }); + // Optimistic removal rolled back — the row is back. + expect(screen.getByText('Berserk')).toBeTruthy(); + }); +}); diff --git a/frontend/src/routes/library/+page.svelte b/frontend/src/routes/library/+page.svelte index 89f8194..09af536 100644 --- a/frontend/src/routes/library/+page.svelte +++ b/frontend/src/routes/library/+page.svelte @@ -1,16 +1,19 @@