feat(search): tag-based page search surface + per-page tags & collections
Add the /search surface (Pages / Chapters / Mangas tabs) backed by per-user page tags and per-page collections: schema (migration 0023), backend endpoints for page tags/collections and tagged-page aggregations (with the OCR text-search param reserved at 501), plus the frontend API clients, library Page-tags tab, collection page sections, page context menu / AddTagsSheet, and reader long-press wiring. Includes the continuous-reader navigation fixes (?page=N handling, chapter-reset timing, back-button pops history) and tag-normalization hardening accumulated on the branch. Bump version 0.60.2 -> 0.62.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
100
frontend/src/lib/components/AddToCollectionModal.svelte.test.ts
Normal file
100
frontend/src/lib/components/AddToCollectionModal.svelte.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||
import { tick } from 'svelte';
|
||||
import AddToCollectionModal from './AddToCollectionModal.svelte';
|
||||
|
||||
// Stub the API modules at the import-graph level so the modal exercises
|
||||
// its dispatch (manga vs page) without hitting fetch.
|
||||
const collectionsApi = vi.hoisted(() => ({
|
||||
listMyCollections: vi.fn(),
|
||||
getMyCollectionsContaining: vi.fn(),
|
||||
addMangaToCollection: vi.fn(),
|
||||
removeMangaFromCollection: vi.fn(),
|
||||
createCollection: vi.fn()
|
||||
}));
|
||||
const pageCollectionsApi = vi.hoisted(() => ({
|
||||
getMyCollectionsContainingPage: vi.fn(),
|
||||
addPageToCollection: vi.fn(),
|
||||
removePageFromCollection: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/collections', () => collectionsApi);
|
||||
vi.mock('$lib/api/page_collections', () => pageCollectionsApi);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function summary(id: string, name: string, containing: string[] = []) {
|
||||
return {
|
||||
id,
|
||||
user_id: 'u1',
|
||||
name,
|
||||
description: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
manga_count: 0,
|
||||
sample_covers: [],
|
||||
_containing: containing
|
||||
};
|
||||
}
|
||||
|
||||
describe('AddToCollectionModal', () => {
|
||||
it('manga target loads via getMyCollectionsContaining', async () => {
|
||||
collectionsApi.listMyCollections.mockResolvedValueOnce({
|
||||
items: [summary('c1', 'Favorites')],
|
||||
page: { limit: 200, offset: 0, total: 1 }
|
||||
});
|
||||
collectionsApi.getMyCollectionsContaining.mockResolvedValueOnce(['c1']);
|
||||
|
||||
render(AddToCollectionModal, {
|
||||
props: {
|
||||
open: true,
|
||||
target: { kind: 'manga', id: 'm1' },
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
// Wait for the $effect → load() → await chain to settle.
|
||||
await tick();
|
||||
await Promise.resolve();
|
||||
await tick();
|
||||
|
||||
expect(collectionsApi.getMyCollectionsContaining).toHaveBeenCalledWith('m1');
|
||||
expect(pageCollectionsApi.getMyCollectionsContainingPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('page target loads via getMyCollectionsContainingPage', async () => {
|
||||
collectionsApi.listMyCollections.mockResolvedValueOnce({
|
||||
items: [summary('c1', 'Favorite panels')],
|
||||
page: { limit: 200, offset: 0, total: 1 }
|
||||
});
|
||||
pageCollectionsApi.getMyCollectionsContainingPage.mockResolvedValueOnce([]);
|
||||
|
||||
render(AddToCollectionModal, {
|
||||
props: {
|
||||
open: true,
|
||||
target: { kind: 'page', id: 'p1' },
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
await tick();
|
||||
await Promise.resolve();
|
||||
await tick();
|
||||
|
||||
expect(pageCollectionsApi.getMyCollectionsContainingPage).toHaveBeenCalledWith('p1');
|
||||
expect(collectionsApi.getMyCollectionsContaining).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Favorite panels')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not load when closed', () => {
|
||||
render(AddToCollectionModal, {
|
||||
props: {
|
||||
open: false,
|
||||
target: { kind: 'manga', id: 'm1' },
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
expect(collectionsApi.listMyCollections).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user