diff --git a/client/src/features/series/views/SeriesDetailView.spec.ts b/client/src/features/series/views/SeriesDetailView.spec.ts new file mode 100644 index 000000000..b838c0224 --- /dev/null +++ b/client/src/features/series/views/SeriesDetailView.spec.ts @@ -0,0 +1,308 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, nextTick, reactive, ref, type PropType } from 'vue' +import { mount } from '@vue/test-utils' +import type { BookCard, SeriesDetail } from '@bookorbit/types' +import SeriesDetailView from './SeriesDetailView.vue' + +type ViewMode = 'grid' | 'list' | 'table' + +class MockIntersectionObserver { + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + takeRecords = vi.fn(() => []) +} + +const mocks = vi.hoisted(() => ({ + route: null as unknown as { params: { seriesName: string }; query: Record }, + routerPush: vi.fn<(to: unknown) => Promise>(), + fetchLibraries: vi.fn<() => Promise>(), + setBookContext: vi.fn<(ids: number[], total: number) => void>(), + loadBooks: vi.fn<(input?: unknown) => Promise>(), + seriesInfo: null as unknown as { value: SeriesDetail | null }, + items: null as unknown as { value: BookCard[] }, + total: null as unknown as { value: number }, + loading: null as unknown as { value: boolean }, + error: null as unknown as { value: string | null }, + notFound: null as unknown as { value: boolean }, + hasMore: null as unknown as { value: boolean }, + sort: null as unknown as { value: 'seriesIndex' | 'title' | 'addedAt' }, + order: null as unknown as { value: 'asc' | 'desc' }, + libraryId: null as unknown as { value: number | null }, + effectiveViewMode: null as unknown as { value: ViewMode }, + fetchSeriesBooks: vi.fn(), + api: vi.fn(), +})) + +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useRoute: () => mocks.route, + useRouter: () => ({ push: mocks.routerPush }), + } +}) + +vi.mock('@/features/auth/composables/usePermissions', () => ({ + usePermissions: () => ({ hasPermission: () => true }), +})) + +vi.mock('@/features/book/composables/useBookNavigation', () => ({ + useBookNavigation: () => ({ setBookContext: mocks.setBookContext }), +})) + +vi.mock('@/features/book/composables/useCoverVersions', () => ({ + useCoverVersions: () => ({ coverUrl: (bookId: number) => `/covers/${bookId}` }), +})) + +vi.mock('@/composables/useDisplaySettings', () => ({ + useDisplaySettings: () => ({ + portraitCoverSize: ref(160), + gridGap: ref(16), + }), +})) + +vi.mock('@/composables/useEffectiveViewMode', () => ({ + useEffectiveViewMode: () => ({ + effectiveViewMode: mocks.effectiveViewMode, + }), +})) + +vi.mock('@/features/library/composables/useLibraries', () => ({ + useLibraries: () => ({ + libraries: ref([]), + fetchLibraries: mocks.fetchLibraries, + }), +})) + +vi.mock('@/composables/usePageTitle', () => ({ + usePageTitle: () => undefined, +})) + +vi.mock('@/features/book/composables/useSafeHtml', () => ({ + useSafeHtml: () => ref(''), +})) + +vi.mock('../api/series', () => ({ + fetchSeriesBooks: (...args: unknown[]) => mocks.fetchSeriesBooks(...args), +})) + +vi.mock('@/lib/api', () => ({ + api: (...args: unknown[]) => mocks.api(...args), +})) + +vi.mock('../composables/useSeriesDetail', () => ({ + useSeriesDetail: () => ({ + seriesInfo: mocks.seriesInfo, + items: mocks.items, + total: mocks.total, + loading: mocks.loading, + error: mocks.error, + notFound: mocks.notFound, + hasMore: mocks.hasMore, + sort: mocks.sort, + order: mocks.order, + libraryId: mocks.libraryId, + load: mocks.loadBooks, + }), +})) + +function makeBook(overrides: Partial = {}): BookCard { + return { + id: 1, + status: 'present', + title: 'Series Book', + authors: ['Author'], + seriesName: 'The Series', + seriesIndex: 1, + files: [], + publishedYear: null, + language: null, + genres: [], + tags: [], + rating: null, + readingProgress: null, + readStatus: null, + addedAt: '2026-01-01T00:00:00.000Z', + updatedAt: null, + metadataScore: null, + hasCover: false, + hasMetadataLocks: false, + lockedFields: [], + subtitle: null, + publisher: null, + pageCount: null, + isbn13: null, + narrators: [], + ...overrides, + } +} + +function makeSeriesInfo(): SeriesDetail { + return { + name: 'The Series', + bookCount: 1, + readCount: 0, + authors: ['Author'], + possibleGaps: [], + } +} + +const VirtualBookGridStub = defineComponent({ + name: 'VirtualBookGrid', + emits: ['action', 'update:book'], + template: ` +
+ + +
+ `, +}) + +const BookListRowStub = defineComponent({ + name: 'BookListRow', + props: { + book: { + type: Object as PropType, + required: true, + }, + }, + emits: ['action'], + template: ``, +}) + +const VirtualBookTableStub = defineComponent({ + name: 'VirtualBookTable', + emits: ['action', 'update:sort', 'update:book'], + template: ``, +}) + +const AddToCollectionSheetStub = defineComponent({ + name: 'AddToCollectionSheet', + props: { + open: { + type: Boolean, + required: true, + }, + bookIds: { + type: Array as PropType, + required: true, + }, + }, + emits: ['update:open'], + template: ` +
+ +
+ `, +}) + +function mountView(mode: ViewMode) { + mocks.effectiveViewMode.value = mode + + return mount(SeriesDetailView, { + global: { + stubs: { + VirtualBookGrid: VirtualBookGridStub, + BookListRow: BookListRowStub, + VirtualBookTable: VirtualBookTableStub, + AddToCollectionSheet: AddToCollectionSheetStub, + EntityNotFound: true, + SeriesCompletionBar: true, + SeriesGapBanner: true, + }, + }, + }) +} + +describe('SeriesDetailView', () => { + beforeEach(() => { + mocks.route = reactive({ + params: { seriesName: 'The%20Series' }, + query: {}, + }) + + mocks.seriesInfo = ref(makeSeriesInfo()) + mocks.items = ref([makeBook({ id: 7 })]) + mocks.total = ref(1) + mocks.loading = ref(false) + mocks.error = ref(null) + mocks.notFound = ref(false) + mocks.hasMore = ref(false) + mocks.sort = ref('seriesIndex') + mocks.order = ref('asc') + mocks.libraryId = ref(null) + mocks.effectiveViewMode = ref('grid') + + mocks.routerPush.mockReset() + mocks.routerPush.mockResolvedValue(undefined) + mocks.fetchLibraries.mockReset() + mocks.fetchLibraries.mockResolvedValue(undefined) + mocks.setBookContext.mockReset() + mocks.loadBooks.mockReset() + mocks.loadBooks.mockResolvedValue(undefined) + mocks.fetchSeriesBooks.mockReset() + mocks.fetchSeriesBooks.mockResolvedValue({ + items: [makeBook({ id: 7, seriesIndex: 1 })], + total: 1, + page: 0, + size: 8, + seriesInfo: makeSeriesInfo(), + }) + mocks.api.mockReset() + mocks.api.mockResolvedValue({ ok: false }) + + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('opens AddToCollectionSheet with the clicked book id from grid actions', async () => { + const wrapper = mountView('grid') + await nextTick() + + await wrapper.get('[data-testid="grid-add-action"]').trigger('click') + + const sheet = wrapper.get('[data-testid="collection-sheet"]') + expect(sheet.attributes('data-open')).toBe('true') + expect(sheet.attributes('data-book-ids')).toBe('42') + }) + + it('opens AddToCollectionSheet with the clicked book id from table actions', async () => { + const wrapper = mountView('table') + await nextTick() + + await wrapper.get('[data-testid="table-add-action"]').trigger('click') + + const sheet = wrapper.get('[data-testid="collection-sheet"]') + expect(sheet.attributes('data-open')).toBe('true') + expect(sheet.attributes('data-book-ids')).toBe('91') + }) + + it('navigates on quick-view and closes the collection sheet state', async () => { + const wrapper = mountView('grid') + await nextTick() + + await wrapper.get('[data-testid="grid-add-action"]').trigger('click') + await wrapper.get('[data-testid="grid-quick-action"]').trigger('click') + + expect(mocks.routerPush).toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 42 } }) + const sheet = wrapper.get('[data-testid="collection-sheet"]') + expect(sheet.attributes('data-open')).toBe('false') + expect(sheet.attributes('data-book-ids')).toBe('') + }) + + it('clears selected ids when the collection sheet closes', async () => { + const wrapper = mountView('grid') + await nextTick() + + await wrapper.get('[data-testid="grid-add-action"]').trigger('click') + await wrapper.get('[data-testid="collection-sheet-close"]').trigger('click') + + const sheet = wrapper.get('[data-testid="collection-sheet"]') + expect(sheet.attributes('data-open')).toBe('false') + expect(sheet.attributes('data-book-ids')).toBe('') + }) +}) diff --git a/client/src/features/series/views/SeriesDetailView.vue b/client/src/features/series/views/SeriesDetailView.vue index faa825ab5..7ac96a82e 100644 --- a/client/src/features/series/views/SeriesDetailView.vue +++ b/client/src/features/series/views/SeriesDetailView.vue @@ -19,6 +19,7 @@ import { usePageTitle } from '@/composables/usePageTitle' import { useSafeHtml } from '@/features/book/composables/useSafeHtml' import { api } from '@/lib/api' import EntityNotFound from '@/components/EntityNotFound.vue' +import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue' import SeriesCompletionBar from '../components/SeriesCompletionBar.vue' import SeriesGapBanner from '../components/SeriesGapBanner.vue' import { fetchSeriesBooks } from '../api/series' @@ -87,6 +88,8 @@ const canEditMetadata = computed(() => hasPermission('library_edit_metadata')) const safeLeadDescription = useSafeHtml(() => leadBook.value?.description) const visibleSeriesAuthors = computed(() => (seriesInfo.value?.authors ?? []).slice(0, 5)) const hiddenSeriesAuthorsCount = computed(() => Math.max(0, (seriesInfo.value?.authors.length ?? 0) - visibleSeriesAuthors.value.length)) +const addToCollectionOpen = ref(false) +const addToCollectionBookId = ref(null) const leadFallbackStyle = computed(() => { const name = seriesInfo.value?.name ?? seriesName.value return bookCoverStyle(name || 'Series') @@ -113,10 +116,26 @@ const leadMetaItems = computed(() => { return items }) -function handleBookAction(book: BookCard, action: string) { +type BookActionType = 'quick-view' | 'add-to-collection' | 'delete' + +function handleBookAction(book: BookCard, action: BookActionType) { if (action === 'quick-view') { + addToCollectionOpen.value = false + addToCollectionBookId.value = null void router.push({ name: 'book-detail', params: { bookId: book.id } }) + return } + + if (action === 'add-to-collection') { + addToCollectionBookId.value = book.id + addToCollectionOpen.value = true + return + } +} + +function handleAddToCollectionOpenChange(open: boolean) { + addToCollectionOpen.value = open + if (!open) addToCollectionBookId.value = null } function handleTableBookUpdate(updated: BookCard) { @@ -585,4 +604,10 @@ watch( + +