diff --git a/client/src/components/AppLayout.vue b/client/src/components/AppLayout.vue index 2dfdff057..5726327aa 100644 --- a/client/src/components/AppLayout.vue +++ b/client/src/components/AppLayout.vue @@ -78,7 +78,7 @@ const viewKey = computed(() => { - + diff --git a/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.spec.ts b/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.spec.ts index 2a6e4c295..4ec5721ac 100644 --- a/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.spec.ts +++ b/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.spec.ts @@ -135,6 +135,15 @@ describe('BookMetadataFetchWidget', () => { expect(wrapper.text()).toContain('Author enrichment finished') }) + it('leaves the book card to a page that shows the queue itself, and still shows author work', () => { + bookStatus.value = idleBookStatus({ queued: 4, sessionTotal: 4 }) + authorStatus.value = idleAuthorStatus({ queued: 2, total: 2, sessionTotal: 2 }) + const wrapper = shallowMount(BookMetadataFetchWidget, { props: { hideBookCard: true } }) + + expect(wrapper.text()).not.toContain('Fetching metadata') + expect(wrapper.text()).toContain('Enriching authors') + }) + it('keeps a dismissed card hidden for the current run and restores it for later work', async () => { authorStatus.value = idleAuthorStatus({ queued: 2, total: 2, sessionTotal: 2 }) const wrapper = shallowMount(BookMetadataFetchWidget) diff --git a/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.vue b/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.vue index 8cbe75780..e12d29dfa 100644 --- a/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.vue +++ b/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.vue @@ -13,6 +13,11 @@ import BookMetadataFetchReportModal from './BookMetadataFetchReportModal.vue' import AuthorEnrichmentExpanded from './AuthorEnrichmentExpanded.vue' import AuthorEnrichmentReportModal from './AuthorEnrichmentReportModal.vue' +const props = defineProps<{ + /** Set where the page already shows the book queue, so the card does not repeat it over the page's own controls. */ + hideBookCard?: boolean +}>() + const { status, subscribe } = useBookMetadataFetchStatus() const { status: authorStatus, subscribe: subscribeAuthors } = useAuthorEnrichmentStatus() const { hasPermission, isSuperuser } = usePermissions() @@ -52,7 +57,7 @@ const hasAuthorStatus = computed(() => hasAuthorWork.value || authorStatus.value const cardUserId = computed(() => user.value?.id ?? null) const { dismissed: bookCardDismissed, dismiss: dismissBookCard } = useMetadataCardDismissal('books', status, hasBookWork, cardUserId) const { dismissed: authorCardDismissed, dismiss: dismissAuthorCard } = useMetadataCardDismissal('authors', authorStatus, hasAuthorWork, cardUserId) -const showBookCard = computed(() => hasBookStatus.value && !bookCardDismissed.value) +const showBookCard = computed(() => hasBookStatus.value && !bookCardDismissed.value && !props.hideBookCard) const showAuthorCard = computed(() => hasAuthorStatus.value && !authorCardDismissed.value) watch(hasAuthorWork, (active) => { diff --git a/client/src/features/book-metadata-fetch/composables/useBookMetadataFetchConfig.ts b/client/src/features/book-metadata-fetch/composables/useBookMetadataFetchConfig.ts deleted file mode 100644 index 225ba5361..000000000 --- a/client/src/features/book-metadata-fetch/composables/useBookMetadataFetchConfig.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ref } from 'vue' -import type { BookMetadataFetchConfig, BookMetadataFetchConfigOverride, BookMetadataFetchLibraryConfig } from '@bookorbit/types' -import { api } from '@/lib/api' - -const globalConfig = ref(null) - -export function useBookMetadataFetchConfig() { - async function fetchGlobalConfig(): Promise { - const res = await api('/api/v1/book-metadata-fetch/config') - if (!res.ok) throw new Error('Failed to load config') - const data: BookMetadataFetchConfig = await res.json() - globalConfig.value = data - return data - } - - async function saveGlobalConfig(config: BookMetadataFetchConfig): Promise { - const res = await api('/api/v1/book-metadata-fetch/config', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config), - }) - if (!res.ok) throw new Error('Failed to save config') - const data: BookMetadataFetchConfig = await res.json() - globalConfig.value = data - return data - } - - async function fetchLibraryConfig(libraryId: number): Promise { - const res = await api(`/api/v1/book-metadata-fetch/config/libraries/${libraryId}`) - if (!res.ok) throw new Error('Failed to load library config') - return res.json() - } - - async function saveLibraryConfig(libraryId: number, override: BookMetadataFetchConfigOverride): Promise { - const res = await api(`/api/v1/book-metadata-fetch/config/libraries/${libraryId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(override ?? {}), - }) - if (!res.ok) throw new Error('Failed to save library config') - return res.json() - } - - return { - globalConfig, - fetchGlobalConfig, - saveGlobalConfig, - fetchLibraryConfig, - saveLibraryConfig, - } -} diff --git a/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.spec.ts b/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.spec.ts deleted file mode 100644 index 503b47a75..000000000 --- a/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { flushPromises } from '@vue/test-utils' -import { effectScope, nextTick, ref, type EffectScope } from 'vue' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { BookMetadataFetchConditions } from '@bookorbit/types' -import { invalidateEligibleCountPreviews, useEligibleCountPreview } from './useEligibleCountPreview' - -const apiMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json: () => Promise }>>()) - -vi.mock('@/lib/api', () => ({ - api: (...args: unknown[]) => apiMock(...args), -})) - -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((r) => { - resolve = r - }) - return { promise, resolve } -} - -function response(count: number) { - return { - ok: true, - json: async () => ({ count }), - } -} - -function makeConditions(threshold: number): BookMetadataFetchConditions { - return { - neverFetched: { enabled: false }, - scoreThreshold: { enabled: true, threshold }, - missingFields: { enabled: false, fields: [] }, - } -} - -describe('useEligibleCountPreview', () => { - let scope: EffectScope - - beforeEach(() => { - vi.useFakeTimers() - apiMock.mockReset() - scope = effectScope() - }) - - afterEach(() => { - scope.stop() - vi.useRealTimers() - }) - - it('ignores stale in-flight count responses after conditions change', async () => { - const first = deferred<{ ok: boolean; json: () => Promise }>() - const second = deferred<{ ok: boolean; json: () => Promise }>() - apiMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) - - const conditions = ref(makeConditions(70)) - const preview = scope.run(() => useEligibleCountPreview(conditions))! - - await vi.advanceTimersByTimeAsync(400) - expect(apiMock).toHaveBeenCalledTimes(1) - - conditions.value = makeConditions(45) - await nextTick() - first.resolve(response(12)) - await flushPromises() - - expect(preview.count.value).toBeNull() - - await vi.advanceTimersByTimeAsync(400) - second.resolve(response(8)) - await flushPromises() - - expect(preview.count.value).toBe(8) - }) - - it('refreshes all previews when invalidated', async () => { - apiMock.mockResolvedValue(response(3)) - const conditions = ref(makeConditions(70)) - - scope.run(() => useEligibleCountPreview(conditions, 5)) - await vi.advanceTimersByTimeAsync(400) - await flushPromises() - - invalidateEligibleCountPreviews() - await nextTick() - await vi.advanceTimersByTimeAsync(400) - await flushPromises() - - expect(apiMock).toHaveBeenCalledTimes(2) - const secondCall = apiMock.mock.calls[1] - expect(secondCall).toBeDefined() - const [, init] = secondCall as [string, RequestInit] - expect(JSON.parse(String(init.body))).toMatchObject({ - libraryId: 5, - conditions: { - scoreThreshold: { threshold: 70 }, - }, - }) - }) - - it('clears count without fetching when conditions are unavailable', async () => { - const conditions = ref(null) - const preview = scope.run(() => useEligibleCountPreview(conditions))! - - await vi.advanceTimersByTimeAsync(400) - await flushPromises() - - expect(apiMock).not.toHaveBeenCalled() - expect(preview.count.value).toBeNull() - expect(preview.loading.value).toBe(false) - }) -}) diff --git a/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.ts b/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.ts deleted file mode 100644 index 18e3ad951..000000000 --- a/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { onScopeDispose, ref, watch, type Ref } from 'vue' -import type { BookMetadataFetchConditions } from '@bookorbit/types' -import { api } from '@/lib/api' - -const refreshToken = ref(0) - -export function invalidateEligibleCountPreviews() { - refreshToken.value += 1 -} - -export function useEligibleCountPreview(conditions: Ref, libraryId?: number) { - const count = ref(null) - const loading = ref(false) - let debounceTimer: ReturnType | null = null - let requestId = 0 - - async function fetchCount() { - const currentRequestId = ++requestId - const current = conditions.value - if (!current) { - count.value = null - loading.value = false - return - } - loading.value = true - try { - const body: { conditions: BookMetadataFetchConditions; libraryId?: number } = { conditions: current } - if (libraryId !== undefined) body.libraryId = libraryId - const res = await api('/api/v1/book-metadata-fetch/preview-count', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - if (res.ok) { - const data: { count: number } = await res.json() - if (currentRequestId !== requestId) return - count.value = data.count - } - } finally { - if (currentRequestId === requestId) loading.value = false - } - } - - watch( - [conditions, refreshToken], - () => { - requestId += 1 - if (debounceTimer) clearTimeout(debounceTimer) - debounceTimer = setTimeout(fetchCount, 400) - }, - { deep: true, immediate: true }, - ) - - onScopeDispose(() => { - requestId += 1 - if (debounceTimer) clearTimeout(debounceTimer) - }) - - return { count, loading } -} diff --git a/client/src/features/settings/lib/settings-nav.ts b/client/src/features/settings/lib/settings-nav.ts index 8f3ab026b..dbd675001 100644 --- a/client/src/features/settings/lib/settings-nav.ts +++ b/client/src/features/settings/lib/settings-nav.ts @@ -61,6 +61,8 @@ export interface SettingsNavItem { id: string routeName: string labelKey: string + /** The page heading, when the short nav label would not say what the page is on its own. */ + titleKey?: string descriptionKey?: string icon: Component /** Extra English search terms so the rail search finds a page by concept, not just by title. */ @@ -321,6 +323,7 @@ export const SETTINGS_NAV: readonly SettingsNavGroup[] = [ id: 'metadata-auto-fetch', routeName: 'settings-metadata-auto-fetch', labelKey: 'settings.metadata.tabs.auto-fetch', + titleKey: 'titles.metadata.auto-fetch', descriptionKey: 'settings.metadata.tabSubtitles.auto-fetch', icon: Zap, keywords: 'auto fetch schedule missing fields import books conditions', diff --git a/client/src/features/settings/metadata-auto-fetch/BookMetadataFetchSettings.vue b/client/src/features/settings/metadata-auto-fetch/BookMetadataFetchSettings.vue index 3a288bdbb..9adf0511e 100644 --- a/client/src/features/settings/metadata-auto-fetch/BookMetadataFetchSettings.vue +++ b/client/src/features/settings/metadata-auto-fetch/BookMetadataFetchSettings.vue @@ -1,42 +1,248 @@ diff --git a/client/src/features/settings/metadata-auto-fetch/__tests__/BookMetadataFetchSettings.spec.ts b/client/src/features/settings/metadata-auto-fetch/__tests__/BookMetadataFetchSettings.spec.ts new file mode 100644 index 000000000..c9d8316b9 --- /dev/null +++ b/client/src/features/settings/metadata-auto-fetch/__tests__/BookMetadataFetchSettings.spec.ts @@ -0,0 +1,224 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { ref } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { BookMetadataFetchBreakdown, BookMetadataFetchConfig, BookMetadataFetchLibraryConfigEntry } from '@bookorbit/types' +import BookMetadataFetchSettings from '../BookMetadataFetchSettings.vue' + +const apiMock = vi.hoisted(() => vi.fn<(path: string, init?: RequestInit) => Promise<{ ok: boolean; json: () => Promise }>>()) +const toastMock = vi.hoisted(() => ({ success: vi.fn<(message: string) => void>(), error: vi.fn<(message: string) => void>() })) + +vi.mock('@/lib/api', () => ({ api: (path: string, init?: RequestInit) => apiMock(path, init) })) +vi.mock('vue-sonner', () => ({ toast: toastMock })) +vi.mock('@vueuse/core', () => ({ useMediaQuery: () => ref(false) })) +vi.mock('../components/AutoFetchQueueBand.vue', () => ({ default: { template: '
' } })) +vi.mock('../components/AutoFetchPreviewList.vue', () => ({ + default: { + props: ['items', 'matching', 'total', 'conditions', 'libraryNames'], + template: '
', + }, +})) + +const global: BookMetadataFetchConfig = { + enabled: false, + triggerOnImport: false, + conditions: { + neverFetched: { enabled: true }, + scoreThreshold: { enabled: true, threshold: 60 }, + missingFields: { enabled: true, fields: ['description', 'cover'] }, + }, +} + +function entry( + libraryId: number, + libraryName: string, + override: BookMetadataFetchLibraryConfigEntry['override'] = null, +): BookMetadataFetchLibraryConfigEntry { + return { libraryId, libraryName, ...global, override, lastRunAt: null, lastQueuedCount: null } +} + +const breakdown: BookMetadataFetchBreakdown = { + total: 480, + matching: 456, + neverFetched: 456, + lowScore: 5, + missingAny: 27, + missingByField: { + title: 0, + subtitle: 292, + description: 27, + cover: 0, + audioCover: 0, + authors: 1, + publisher: 20, + isbn: 136, + publishedYear: 24, + language: 9, + pageCount: 189, + communityRating: 236, + seriesName: 351, + seriesIndex: 359, + genres: 113, + narrators: 3, + duration: 3, + abridged: 0, + }, + withAudio: 16, + scoreBins: Array.from({ length: 20 }, (_, i) => (i > 12 ? 30 : 1)), + unscored: 0, +} + +function ok(data: unknown) { + return { ok: true, json: async () => data } +} + +let libraries: BookMetadataFetchLibraryConfigEntry[] + +function lastCall(path: string, method: string) { + const calls = apiMock.mock.calls.filter(([p, init]) => p === path && (init?.method ?? 'GET') === method) + return calls[calls.length - 1] +} + +async function mountPage() { + const wrapper = mount(BookMetadataFetchSettings, { global: { stubs: { RouterLink: true } } }) + await flushPromises() + await vi.advanceTimersByTimeAsync(300) + await flushPromises() + return wrapper +} + +function tab(wrapper: Awaited>, name: string) { + return wrapper.findAll('[role="tab"]').find((item) => item.text().startsWith(name))! +} + +describe('BookMetadataFetchSettings', () => { + beforeEach(() => { + vi.useFakeTimers() + apiMock.mockReset() + toastMock.success.mockReset() + toastMock.error.mockReset() + libraries = [entry(2, 'Audiobooks'), entry(6, 'Comics', { conditions: { scoreThreshold: { enabled: true, threshold: 75 } } })] + apiMock.mockImplementation(async (path, init) => { + const method = init?.method ?? 'GET' + if (path === '/api/v1/book-metadata-fetch/config' && method === 'GET') return ok(global) + if (path === '/api/v1/book-metadata-fetch/config' && method === 'PUT') return ok(JSON.parse(String(init!.body))) + if (path === '/api/v1/book-metadata-fetch/config/libraries') return ok(libraries) + if (path.startsWith('/api/v1/book-metadata-fetch/config/libraries/') && method === 'PUT') { + const body = JSON.parse(String(init!.body)) + return ok({ ...global, override: Object.keys(body).length ? body : null, lastRunAt: null, lastQueuedCount: null }) + } + if (path === '/api/v1/book-metadata-fetch/preview-breakdown') return ok(breakdown) + if (path === '/api/v1/book-metadata-fetch/preview-books') return ok({ items: [], total: 456 }) + if (path.startsWith('/api/v1/book-metadata-fetch/run')) return ok({ queued: 4 }) + return ok({}) + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('opens on the global rule with a tab per library and counts for the libraries that follow it', async () => { + const wrapper = await mountPage() + + expect(wrapper.findAll('[role="tab"]').map((item) => item.text())).toEqual(['Global defaults', 'Audiobooks', 'Comics1']) + expect(wrapper.text()).toContain('Applies to 1 library, 480 books.') + expect(wrapper.text()).toContain('Comics has its own rule.') + const scope = JSON.parse(String(lastCall('/api/v1/book-metadata-fetch/preview-breakdown', 'POST')![1]!.body)) + expect(scope).toEqual({ conditions: global.conditions, libraryIds: [2] }) + expect(wrapper.text()).toContain('456') + }) + + it('writes both import flags from the one switch', async () => { + const wrapper = await mountPage() + + await wrapper.get('[aria-labelledby="auto-fetch-import-label"]').trigger('click') + await wrapper + .findAll('button') + .find((button) => button.text() === 'Save Global defaults')! + .trigger('click') + await flushPromises() + + const body = JSON.parse(String(lastCall('/api/v1/book-metadata-fetch/config', 'PUT')![1]!.body)) + expect(body).toMatchObject({ enabled: true, triggerOnImport: true }) + expect(toastMock.success).toHaveBeenCalledWith('Saved Global defaults') + }) + + it('saves only what a library changes, and marks each condition as its own or the global one', async () => { + const wrapper = await mountPage() + await tab(wrapper, 'Audiobooks').trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('Audiobooks follows the global rule.') + expect(wrapper.findAll('section[data-condition] h3 + span').map((item) => item.text())).toEqual(['Global', 'Global', 'Global']) + + await wrapper.get('[data-condition="scoreThreshold"] button[aria-label="Raise the threshold"]').trigger('click') + await flushPromises() + expect(wrapper.get('[data-condition="scoreThreshold"] h3 + span').text()).toContain('Own') + expect(wrapper.text()).toContain('Global rule: Score below 60') + expect(wrapper.get('[data-testid="auto-fetch-run"]').attributes('disabled')).toBeDefined() + + await wrapper + .findAll('button') + .find((button) => button.text() === 'Save Audiobooks')! + .trigger('click') + await flushPromises() + + const body = JSON.parse(String(lastCall('/api/v1/book-metadata-fetch/config/libraries/2', 'PUT')![1]!.body)) + expect(body).toEqual({ conditions: { scoreThreshold: { enabled: true, threshold: 65 } } }) + expect(tab(wrapper, 'Audiobooks').text()).toBe('Audiobooks1') + }) + + it('keeps turning a library back to the global rule a draft until it is saved', async () => { + const wrapper = await mountPage() + await tab(wrapper, 'Comics').trigger('click') + await flushPromises() + + await wrapper + .findAll('button') + .find((button) => button.text() === 'Use the global rule')! + .trigger('click') + await flushPromises() + expect(lastCall('/api/v1/book-metadata-fetch/config/libraries/6', 'PUT')).toBeUndefined() + expect(wrapper.text()).toContain('1 unsaved change in Comics') + + await wrapper + .findAll('button') + .find((button) => button.text() === 'Discard')! + .trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('Comics has its own rule for 1 setting.') + + await wrapper + .findAll('button') + .find((button) => button.text() === 'Use the global rule')! + .trigger('click') + await wrapper + .findAll('button') + .find((button) => button.text() === 'Save Comics')! + .trigger('click') + await flushPromises() + expect(JSON.parse(String(lastCall('/api/v1/book-metadata-fetch/config/libraries/6', 'PUT')![1]!.body))).toEqual({}) + }) + + it('runs every library from the global tab and the one library from its tab, passing ignore-cache only when asked', async () => { + const wrapper = await mountPage() + + await wrapper.get('[data-testid="auto-fetch-run"]').trigger('click') + await flushPromises() + expect(lastCall('/api/v1/book-metadata-fetch/run', 'POST')![1]).toEqual({ method: 'POST' }) + expect(toastMock.success).toHaveBeenCalledWith('Queued 4 books') + + await tab(wrapper, 'Comics').trigger('click') + await wrapper.get('[data-testid="ignore-cache-checkbox"]').setValue(true) + await wrapper.get('[data-testid="auto-fetch-run"]').trigger('click') + await flushPromises() + expect(JSON.parse(String(lastCall('/api/v1/book-metadata-fetch/run/6', 'POST')![1]!.body))).toEqual({ ignoreCache: true }) + expect((wrapper.get('[data-testid="ignore-cache-checkbox"]').element as HTMLInputElement).checked).toBe(false) + }) + + it('shows an error instead of an empty form when the settings cannot load', async () => { + apiMock.mockImplementation(async () => ({ ok: false, json: async () => ({}) })) + const wrapper = await mountPage() + + expect(wrapper.get('[role="alert"]').text()).toContain('could not be loaded') + }) +}) diff --git a/client/src/features/settings/metadata-auto-fetch/components/AutoFetchConditionColumns.vue b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchConditionColumns.vue new file mode 100644 index 000000000..0a862ae96 --- /dev/null +++ b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchConditionColumns.vue @@ -0,0 +1,214 @@ + + + diff --git a/client/src/features/settings/metadata-auto-fetch/components/AutoFetchFieldChips.vue b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchFieldChips.vue new file mode 100644 index 000000000..21a2e8be3 --- /dev/null +++ b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchFieldChips.vue @@ -0,0 +1,85 @@ + + + diff --git a/client/src/features/settings/metadata-auto-fetch/components/AutoFetchPreviewList.vue b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchPreviewList.vue new file mode 100644 index 000000000..63f7b1934 --- /dev/null +++ b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchPreviewList.vue @@ -0,0 +1,134 @@ + + + diff --git a/client/src/features/settings/metadata-auto-fetch/components/AutoFetchQueueBand.vue b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchQueueBand.vue new file mode 100644 index 000000000..de0886f20 --- /dev/null +++ b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchQueueBand.vue @@ -0,0 +1,177 @@ + + + diff --git a/client/src/features/settings/metadata-auto-fetch/components/AutoFetchScoreHistogram.vue b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchScoreHistogram.vue new file mode 100644 index 000000000..cf87a0e7d --- /dev/null +++ b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchScoreHistogram.vue @@ -0,0 +1,116 @@ + + + diff --git a/client/src/features/settings/metadata-auto-fetch/components/AutoFetchThresholdStepper.vue b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchThresholdStepper.vue new file mode 100644 index 000000000..7dd47bda8 --- /dev/null +++ b/client/src/features/settings/metadata-auto-fetch/components/AutoFetchThresholdStepper.vue @@ -0,0 +1,73 @@ + + + diff --git a/client/src/features/settings/metadata-auto-fetch/components/ConditionConfigurator.vue b/client/src/features/settings/metadata-auto-fetch/components/ConditionConfigurator.vue deleted file mode 100644 index 50c6d5aae..000000000 --- a/client/src/features/settings/metadata-auto-fetch/components/ConditionConfigurator.vue +++ /dev/null @@ -1,98 +0,0 @@ - - - diff --git a/client/src/features/settings/metadata-auto-fetch/components/GlobalAutoFetchConfig.spec.ts b/client/src/features/settings/metadata-auto-fetch/components/GlobalAutoFetchConfig.spec.ts deleted file mode 100644 index 986a6b00e..000000000 --- a/client/src/features/settings/metadata-auto-fetch/components/GlobalAutoFetchConfig.spec.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { flushPromises, mount } from '@vue/test-utils' -import { nextTick, ref } from 'vue' -import type { BookMetadataFetchConfig, BookMetadataFetchStatusEvent } from '@bookorbit/types' - -// --- hoisted mocks --------------------------------------------------------- - -const mockTriggerGlobal = vi.fn<(options?: { ignoreCache?: boolean }) => Promise<{ queued: number }>>() -const mockSaveGlobalConfig = vi.fn<(c: BookMetadataFetchConfig) => Promise>() -const mockStatus = ref({ - queued: 0, - processing: 0, - failed: 0, - latestFailureAt: null, - paused: false, - sessionTotal: 0, - sessionDone: 0, - currentItemName: null, -}) -const mockEligibleCount = ref(null) -const mockCountLoading = ref(false) -const mockInvalidate = vi.fn<() => void>() - -vi.mock('@/features/book-metadata-fetch/composables/useBookMetadataFetchActions', () => ({ - useBookMetadataFetchActions: () => ({ triggerGlobal: mockTriggerGlobal }), -})) - -vi.mock('@/features/book-metadata-fetch/composables/useBookMetadataFetchConfig', () => ({ - useBookMetadataFetchConfig: () => ({ saveGlobalConfig: mockSaveGlobalConfig }), -})) - -vi.mock('@/features/book-metadata-fetch/composables/useBookMetadataFetchStatus', () => ({ - useBookMetadataFetchStatus: () => ({ status: mockStatus }), -})) - -vi.mock('@/features/book-metadata-fetch/composables/useEligibleCountPreview', () => ({ - useEligibleCountPreview: () => ({ count: mockEligibleCount, loading: mockCountLoading }), - invalidateEligibleCountPreviews: () => mockInvalidate(), -})) - -vi.mock('@vueuse/core', () => ({ - useMediaQuery: () => ref(false), -})) - -vi.mock('./ConditionConfigurator.vue', () => ({ - default: { template: '
' }, -})) - -vi.mock('@/components/ui/ToggleSwitch.vue', () => ({ - default: { template: '', props: ['modelValue', 'disabled'] }, -})) - -// --------------------------------------------------------------------------- - -const { default: GlobalAutoFetchConfig } = await import('./GlobalAutoFetchConfig.vue') - -function makeConfig(overrides: Partial = {}): BookMetadataFetchConfig { - return { - enabled: true, - triggerOnImport: false, - conditions: { - neverFetched: { enabled: true }, - scoreThreshold: { enabled: false, threshold: 60 }, - missingFields: { enabled: false, fields: [] }, - }, - ...overrides, - } -} - -function idleStatus(): BookMetadataFetchStatusEvent { - return { queued: 0, processing: 0, failed: 0, latestFailureAt: null, paused: false, sessionTotal: 0, sessionDone: 0, currentItemName: null } -} - -function buttonByText(wrapper: ReturnType, text: string) { - return wrapper.findAll('button').find((button) => button.text().trim() === text)! -} - -describe('GlobalAutoFetchConfig', () => { - beforeEach(() => { - vi.clearAllMocks() - mockStatus.value = idleStatus() - mockEligibleCount.value = null - mockCountLoading.value = false - mockTriggerGlobal.mockResolvedValue({ queued: 0 }) - mockSaveGlobalConfig.mockImplementation(async (c) => c) - }) - - // ------------------------------------------------------------------------- - // statusLabel - idle queue - // ------------------------------------------------------------------------- - - it('shows eligible count when queue is idle and count is loaded', async () => { - mockEligibleCount.value = 42 - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - expect(wrapper.find('.hidden.md\\:flex span').text()).toBe('~42 eligible') - }) - - it('shows nothing when queue is idle and eligible count is null', async () => { - mockEligibleCount.value = null - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - expect(wrapper.find('.hidden.md\\:flex span').exists()).toBe(false) - }) - - it('suppresses the label while eligible count is still loading', async () => { - mockEligibleCount.value = 10 - mockCountLoading.value = true - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - expect(wrapper.find('.hidden.md\\:flex span').exists()).toBe(false) - }) - - // ------------------------------------------------------------------------- - // statusLabel - active queue - // ------------------------------------------------------------------------- - - it('shows remaining count when books are queued and processing', async () => { - mockStatus.value = { ...idleStatus(), queued: 48, processing: 2 } - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - expect(wrapper.find('.hidden.md\\:flex span').text()).toBe('50 remaining') - }) - - it('shows remaining count when only queued (processing=0)', async () => { - mockStatus.value = { ...idleStatus(), queued: 10, processing: 0 } - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - expect(wrapper.find('.hidden.md\\:flex span').text()).toBe('10 remaining') - }) - - it('shows paused label when queue is paused with items', async () => { - mockStatus.value = { ...idleStatus(), queued: 30, processing: 0, paused: true } - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - expect(wrapper.find('.hidden.md\\:flex span').text()).toBe('30 in queue - paused') - }) - - it('queue remaining takes priority over eligible count', async () => { - mockEligibleCount.value = 5 - mockStatus.value = { ...idleStatus(), queued: 20, processing: 1 } - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - expect(wrapper.find('.hidden.md\\:flex span').text()).toBe('21 remaining') - }) - - // ------------------------------------------------------------------------- - // statusLabel - after trigger - // ------------------------------------------------------------------------- - - it('shows "Queued N books" after a successful trigger', async () => { - mockTriggerGlobal.mockResolvedValue({ queued: 2048 }) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - - await buttonByText(wrapper, 'Run now').trigger('click') - await flushPromises() - - expect(wrapper.find('.hidden.md\\:flex span').text()).toBe('Queued 2,048 books') - }) - - it('shows "No eligible books found" when trigger returns 0', async () => { - mockTriggerGlobal.mockResolvedValue({ queued: 0 }) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - - await buttonByText(wrapper, 'Run now').trigger('click') - await flushPromises() - - expect(wrapper.find('.hidden.md\\:flex span').text()).toBe('No eligible books found') - }) - - it('trigger result takes priority over active queue status', async () => { - mockStatus.value = { ...idleStatus(), queued: 100, processing: 1 } - mockTriggerGlobal.mockResolvedValue({ queued: 50 }) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - - await buttonByText(wrapper, 'Run now').trigger('click') - await flushPromises() - - expect(wrapper.find('.hidden.md\\:flex span').text()).toBe('Queued 50 books') - }) - - it('runs with cached results unless the ignore-cache box is checked', async () => { - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - - await buttonByText(wrapper, 'Run now').trigger('click') - await flushPromises() - expect(mockTriggerGlobal).toHaveBeenLastCalledWith({ ignoreCache: false }) - - await wrapper.get('[data-testid="ignore-cache-checkbox"]').setValue(true) - await buttonByText(wrapper, 'Run now').trigger('click') - await flushPromises() - expect(mockTriggerGlobal).toHaveBeenLastCalledWith({ ignoreCache: true }) - }) - - it('invalidates eligible count previews after trigger', async () => { - mockTriggerGlobal.mockResolvedValue({ queued: 10 }) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - - await buttonByText(wrapper, 'Run now').trigger('click') - await flushPromises() - - expect(mockInvalidate).toHaveBeenCalledTimes(1) - }) - - // ------------------------------------------------------------------------- - // Mobile footer mirrors statusLabel - // ------------------------------------------------------------------------- - - it('mobile footer also shows statusLabel', async () => { - mockEligibleCount.value = 7 - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - const mobileSpan = wrapper.find('.md\\:hidden.sticky span.text-muted-foreground') - expect(mobileSpan.text()).toBe('~7 eligible') - }) - - it('mobile footer shows remaining count when queue is active', async () => { - mockStatus.value = { ...idleStatus(), queued: 15, processing: 0 } - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - const mobileSpan = wrapper.find('.md\\:hidden.sticky span.text-muted-foreground') - expect(mobileSpan.text()).toBe('15 remaining') - }) - - // ------------------------------------------------------------------------- - // Save - // ------------------------------------------------------------------------- - - it('calls saveGlobalConfig and emits updated when save is clicked', async () => { - const updated = makeConfig({ enabled: false }) - mockSaveGlobalConfig.mockResolvedValue(updated) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - - await buttonByText(wrapper, 'Save').trigger('click') - await flushPromises() - - expect(mockSaveGlobalConfig).toHaveBeenCalledTimes(1) - expect(wrapper.emitted('updated')).toEqual([[updated]]) - }) - - it('disables save button while saving', async () => { - let resolvePromise!: (v: BookMetadataFetchConfig) => void - mockSaveGlobalConfig.mockReturnValue(new Promise((r) => (resolvePromise = r))) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - - const btn = buttonByText(wrapper, 'Save') - await btn.trigger('click') - await nextTick() - - expect((btn.element as HTMLButtonElement).disabled).toBe(true) - resolvePromise(makeConfig()) - await flushPromises() - expect((btn.element as HTMLButtonElement).disabled).toBe(false) - }) - - it('disables trigger button while triggering', async () => { - let resolvePromise!: (v: { queued: number }) => void - mockTriggerGlobal.mockReturnValue(new Promise((r) => (resolvePromise = r))) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - - const btn = buttonByText(wrapper, 'Run now') - await btn.trigger('click') - await nextTick() - - expect((btn.element as HTMLButtonElement).disabled).toBe(true) - resolvePromise({ queued: 0 }) - await flushPromises() - expect((btn.element as HTMLButtonElement).disabled).toBe(false) - }) - - // ------------------------------------------------------------------------- - // Conditions panel toggle - // ------------------------------------------------------------------------- - - it('hides ConditionConfigurator when conditions panel is collapsed', async () => { - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: makeConfig() } }) - await nextTick() - - // ChevronUp is shown when open (conditionsOpen=true by default) - expect(wrapper.findComponent({ name: 'ChevronUp' }).exists()).toBe(false) - - // Click to collapse - await wrapper.find('.bg-card button.w-full').trigger('click') - await nextTick() - - // After collapse ChevronDown takes over for conditions toggle - collapsed state - expect(wrapper.find('.bg-card button.w-full').exists()).toBe(true) - }) - - it('updates local config when config prop changes', async () => { - const original = makeConfig({ enabled: true }) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config: original } }) - await nextTick() - - const updated = makeConfig({ enabled: false }) - await wrapper.setProps({ config: updated }) - await nextTick() - - const toggles = wrapper.findAll('input[type="checkbox"]') - expect(toggles.length).toBeGreaterThan(0) - }) - - it('activeConditionSummary lists missingFields when enabled with fields', async () => { - const config = makeConfig({ - conditions: { - neverFetched: { enabled: false }, - scoreThreshold: { enabled: false, threshold: 60 }, - missingFields: { enabled: true, fields: ['cover', 'description'] }, - }, - }) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config } }) - await nextTick() - - expect(wrapper.text()).toContain('Missing 2 fields') - }) - - it('activeConditionSummary uses singular "field" for exactly one missing field', async () => { - const config = makeConfig({ - conditions: { - neverFetched: { enabled: false }, - scoreThreshold: { enabled: false, threshold: 60 }, - missingFields: { enabled: true, fields: ['cover'] }, - }, - }) - const wrapper = mount(GlobalAutoFetchConfig, { props: { config } }) - await nextTick() - - expect(wrapper.text()).toContain('Missing 1 field') - expect(wrapper.text()).not.toContain('fields') - }) -}) diff --git a/client/src/features/settings/metadata-auto-fetch/components/GlobalAutoFetchConfig.vue b/client/src/features/settings/metadata-auto-fetch/components/GlobalAutoFetchConfig.vue deleted file mode 100644 index c05c17162..000000000 --- a/client/src/features/settings/metadata-auto-fetch/components/GlobalAutoFetchConfig.vue +++ /dev/null @@ -1,191 +0,0 @@ - - - diff --git a/client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts b/client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts deleted file mode 100644 index 7dc8e6e3b..000000000 --- a/client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { flushPromises, mount } from '@vue/test-utils' -import { ref } from 'vue' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { BookMetadataFetchConfig, BookMetadataFetchLibraryConfig, Library } from '@bookorbit/types' -import LibraryAutoFetchConfig from './LibraryAutoFetchConfig.vue' - -const apiMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json: () => Promise }>>()) - -vi.mock('@/lib/api', () => ({ - api: (...args: unknown[]) => apiMock(...args), -})) - -vi.mock('@vueuse/core', () => ({ - useMediaQuery: () => ref(false), -})) - -vi.mock('@/components/ui/ToggleSwitch.vue', () => ({ - default: { - props: ['modelValue'], - emits: ['update:modelValue'], - template: '