From 050f51b4c988a3adb45961b848628bb8e5b5b036 Mon Sep 17 00:00:00 2001 From: Catherine Luse Date: Wed, 15 Jul 2026 21:50:17 -0700 Subject: [PATCH 1/9] Cover OpenAI wiki generation and update flows Extends openai.spec.ts with generateWikiContent (create vs. update prompts, no-content and error fallbacks) and updateWikiPagesFromChapter (create-new, update-with-changes, no-change mention-only, and the continue-on-error path) using injected wiki DB functions. openai.ts reaches 100% line coverage. Co-Authored-By: Claude Opus 4.8 --- src/__tests__/openai.spec.ts | 171 +++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/src/__tests__/openai.spec.ts b/src/__tests__/openai.spec.ts index 0eec846..12c40d3 100644 --- a/src/__tests__/openai.spec.ts +++ b/src/__tests__/openai.spec.ts @@ -5,6 +5,8 @@ import { generateReview, generateChapterSummary, generatePartSummary, + generateWikiContent, + updateWikiPagesFromChapter, BUILT_IN_PROFILES, type AIProfile, } from '@/lib/openai' @@ -230,3 +232,172 @@ describe('generatePartSummary', () => { ).rejects.toThrow(/No response from OpenAI/) }) }) + +describe('generateWikiContent', () => { + it('builds a "create" prompt for a new page and returns parsed JSON', async () => { + create.mockResolvedValue({ + choices: [ + { + message: { + content: JSON.stringify({ + content: '# Alice\n\nProfile.', + summary: 'A hero.', + hasChanges: true, + }), + }, + }, + ], + }) + + const result = await generateWikiContent('sk', 'Alice', 'chapter text', 'summary', null) + + expect(result).toMatchObject({ content: '# Alice\n\nProfile.', hasChanges: true }) + const [system, user] = create.mock.calls[0][0].messages + expect(system.content).toContain('creating a character page') + expect(user.content).toContain('Create a wiki page for character: Alice') + }) + + it('builds an "update" prompt when existing content is provided', async () => { + create.mockResolvedValue({ + choices: [{ message: { content: JSON.stringify({ content: 'x', summary: 'y', hasChanges: false }) } }], + }) + + await generateWikiContent('sk', 'The Keep', 'text', 'summary', '# The Keep\n\nOld.', 'location') + + const [system, user] = create.mock.calls[0][0].messages + expect(system.content).toContain('updating a location/setting page') + expect(user.content).toContain('EXISTING WIKI CONTENT:') + expect(user.content).toContain('# The Keep\n\nOld.') + }) + + it('returns a basic fallback when generation fails', async () => { + create.mockRejectedValue(new Error('rate limited')) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const result = await generateWikiContent('sk', 'Alice', 'text', 'A recap.', null) + + expect(result.content).toContain('# Alice') + expect(result.summary).toBe('Character from the story') + expect(result.hasChanges).toBe(true) + }) + + it('falls back to a basic page when the model returns no content', async () => { + create.mockResolvedValue({ choices: [{ message: {} }] }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const result = await generateWikiContent('sk', 'Zed', 'text', 'A recap.', null, 'concept') + + expect(result.content).toContain('# Zed') + expect(result.summary).toBe('Concept from the story') + }) +}) + +describe('updateWikiPagesFromChapter', () => { + function deps() { + return { + getWikiPageFn: vi.fn(), + createWikiPageFn: vi.fn(async () => 'new-page-id'), + updateWikiPageFn: vi.fn(async () => {}), + trackWikiUpdateFn: vi.fn(async () => {}), + addChapterWikiMentionFn: vi.fn(async () => {}), + } + } + + function run(d: ReturnType, characters: string[]) { + return updateWikiPagesFromChapter( + 'sk', + 'book-1', + 'ch-1', + 'chapter text', + 'chapter summary', + characters, + d.getWikiPageFn, + d.createWikiPageFn, + d.updateWikiPageFn, + d.trackWikiUpdateFn, + d.addChapterWikiMentionFn, + ) + } + + it('creates a new page and tracks the creation for an unknown character', async () => { + create.mockResolvedValue({ + choices: [{ message: { content: JSON.stringify({ content: 'c', summary: 's', hasChanges: true }) } }], + }) + const d = deps() + d.getWikiPageFn.mockResolvedValue(null) + + await run(d, ['Alice']) + + expect(d.createWikiPageFn).toHaveBeenCalledWith( + expect.objectContaining({ page_name: 'Alice', page_type: 'character', created_by_ai: true }), + ) + expect(d.trackWikiUpdateFn).toHaveBeenCalledWith( + expect.objectContaining({ wiki_page_id: 'new-page-id', update_type: 'created' }), + ) + expect(d.addChapterWikiMentionFn).toHaveBeenCalledWith('ch-1', 'new-page-id') + expect(d.updateWikiPageFn).not.toHaveBeenCalled() + }) + + it('updates an existing page when there are changes', async () => { + create.mockResolvedValue({ + choices: [ + { + message: { + content: JSON.stringify({ + content: 'updated', + summary: 's2', + hasChanges: true, + hasContradictions: true, + contradictions: 'eye color changed', + changeSummary: 'added detail', + }), + }, + }, + ], + }) + const d = deps() + d.getWikiPageFn.mockResolvedValue({ id: 'existing-1', content: 'old' }) + + await run(d, ['Bob']) + + expect(d.updateWikiPageFn).toHaveBeenCalledWith('existing-1', { + content: 'updated', + summary: 's2', + }) + expect(d.trackWikiUpdateFn).toHaveBeenCalledWith( + expect.objectContaining({ update_type: 'update_with_contradictions' }), + ) + expect(d.addChapterWikiMentionFn).toHaveBeenCalledWith('ch-1', 'existing-1') + expect(d.createWikiPageFn).not.toHaveBeenCalled() + }) + + it('skips the update when an existing page has no changes but still records the mention', async () => { + create.mockResolvedValue({ + choices: [{ message: { content: JSON.stringify({ content: 'x', summary: 'y', hasChanges: false }) } }], + }) + const d = deps() + d.getWikiPageFn.mockResolvedValue({ id: 'existing-2', content: 'old' }) + + await run(d, ['Carol']) + + expect(d.updateWikiPageFn).not.toHaveBeenCalled() + expect(d.trackWikiUpdateFn).not.toHaveBeenCalled() + expect(d.addChapterWikiMentionFn).toHaveBeenCalledWith('ch-1', 'existing-2') + }) + + it('continues with other characters when one fails', async () => { + create.mockResolvedValue({ + choices: [{ message: { content: JSON.stringify({ content: 'c', summary: 's', hasChanges: true }) } }], + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const d = deps() + d.getWikiPageFn + .mockRejectedValueOnce(new Error('db down')) + .mockResolvedValueOnce(null) + + await run(d, ['Broken', 'Alice']) + + // First character threw before creating; second still created. + expect(d.createWikiPageFn).toHaveBeenCalledTimes(1) + }) +}) From 0bc953b29a41438934b4f80a3dcbf4fb290f6c98 Mon Sep 17 00:00:00 2001 From: Catherine Luse Date: Wed, 15 Jul 2026 21:50:57 -0700 Subject: [PATCH 2/9] Add unit tests for the useBooks composable Mocks useDatabase and verifies the re-exposed state/loaders and that createBook persists a normalized book (empty chapter/part order, null cover, ISO created_at) and returns it with chapterCount 0. useBooks.ts reaches 100% coverage. Co-Authored-By: Claude Opus 4.8 --- src/__tests__/useBooks.spec.ts | 55 ++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/__tests__/useBooks.spec.ts diff --git a/src/__tests__/useBooks.spec.ts b/src/__tests__/useBooks.spec.ts new file mode 100644 index 0000000..1dccb3e --- /dev/null +++ b/src/__tests__/useBooks.spec.ts @@ -0,0 +1,55 @@ +import { ref } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useBooks } from '@/composables/useBooks' + +const { saveBook, booksRef, loadingRef, errorRef, loadBooks } = vi.hoisted(() => ({ + saveBook: vi.fn(async () => {}), + booksRef: { current: [] as unknown[] }, + loadingRef: { current: false }, + errorRef: { current: null as string | null }, + loadBooks: vi.fn(async () => {}), +})) + +vi.mock('@/composables/useDatabase', () => ({ + useDatabase: () => ({ + books: ref(booksRef.current), + loading: ref(loadingRef.current), + error: ref(errorRef.current), + loadBooks, + saveBook, + }), +})) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('useBooks', () => { + it('re-exposes the database state and loaders', () => { + const { books, loading, error, loadBooks: exposedLoad, createBook } = useBooks() + expect(books.value).toEqual([]) + expect(loading.value).toBe(false) + expect(error.value).toBeNull() + expect(exposedLoad).toBe(loadBooks) + expect(typeof createBook).toBe('function') + }) + + it('createBook persists a normalized book and returns it with a chapter count', async () => { + const { createBook } = useBooks() + const result = await createBook({ id: 'book-1', title: 'My Book' }) + + expect(saveBook).toHaveBeenCalledTimes(1) + const saved = saveBook.mock.calls[0][0] as Record + expect(saved).toMatchObject({ + id: 'book-1', + title: 'My Book', + chapter_order: '[]', + part_order: '[]', + cover_image_id: null, + }) + expect(typeof saved.created_at).toBe('string') + expect(Number.isNaN(Date.parse(saved.created_at as string))).toBe(false) + + expect(result).toMatchObject({ id: 'book-1', title: 'My Book', chapterCount: 0 }) + }) +}) From 2dfeae44434ae71e39bd35c0cfcc1aef619da5a4 Mon Sep 17 00:00:00 2001 From: Catherine Luse Date: Wed, 15 Jul 2026 21:52:49 -0700 Subject: [PATCH 3/9] Test IndexedDb and Electron image content stores Complements the existing imageContentStore conversion tests by covering the two ImageContentStore implementations: - IndexedDbImageContentStore: null/legacy-Blob/structured-record reads, the not-a-Blob and size-check failures, write record shape, delete, exists, and string-only listStoredIds (mocked indexedDbStorage) - ElectronImageContentStore: bridge read/write/delete, the missing file-path paths, and exists-false on a bridge error imageContentStore.ts reaches 100% line coverage. Co-Authored-By: Claude Opus 4.8 --- .../imageContentStoreClasses.spec.ts | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 src/__tests__/imageContentStoreClasses.spec.ts diff --git a/src/__tests__/imageContentStoreClasses.spec.ts b/src/__tests__/imageContentStoreClasses.spec.ts new file mode 100644 index 0000000..051f0ae --- /dev/null +++ b/src/__tests__/imageContentStoreClasses.spec.ts @@ -0,0 +1,166 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ImageAsset } from '@/lib/database' +import { + ElectronImageContentStore, + IndexedDbImageContentStore, + blobToDataUrl, +} from '@/lib/imageContentStore' + +const { read, write, del, listKeys } = vi.hoisted(() => ({ + read: vi.fn(), + write: vi.fn(async () => {}), + del: vi.fn(async () => {}), + listKeys: vi.fn(async () => [] as unknown[]), +})) + +vi.mock('@/lib/indexedDbStorage', () => ({ + IMAGE_BLOBS_STORE: 'imageBlobs', + readIndexedDbValue: read, + writeIndexedDbValue: write, + deleteIndexedDbValue: del, + listIndexedDbKeys: listKeys, +})) + +function asset(overrides: Partial = {}): ImageAsset { + return { + id: 'img-1', + book_id: 'b1', + chapter_id: null, + asset_type: 'illustration' as ImageAsset['asset_type'], + file_name: 'img.png', + file_path: 'images/img.png', + mime_type: 'image/png', + image_data: null, + notes: '', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('IndexedDbImageContentStore', () => { + const store = new IndexedDbImageContentStore() + + it('returns null when nothing is stored', async () => { + read.mockResolvedValue(null) + expect(await store.read(asset())).toBeNull() + }) + + it('returns a raw legacy Blob record directly', async () => { + const blob = new Blob(['hello'], { type: 'image/png' }) + read.mockResolvedValue(blob) + expect(await store.read(asset())).toBe(blob) + }) + + it('validates and returns the blob from a structured record', async () => { + const blob = new Blob(['hello'], { type: 'image/png' }) + read.mockResolvedValue({ blob, byteSize: blob.size, updatedAt: 'now' }) + expect(await store.read(asset())).toBe(blob) + }) + + it('throws when the stored record is not a Blob', async () => { + read.mockResolvedValue({ blob: 'not-a-blob', byteSize: 5, updatedAt: 'now' }) + await expect(store.read(asset())).rejects.toThrow(/not a valid Blob/) + }) + + it('throws when the stored blob fails its size check', async () => { + const blob = new Blob(['hello'], { type: 'image/png' }) + read.mockResolvedValue({ blob, byteSize: blob.size + 1, updatedAt: 'now' }) + await expect(store.read(asset())).rejects.toThrow(/failed its size check/) + }) + + it('writes a structured record with byteSize and timestamp', async () => { + const blob = new Blob(['hello'], { type: 'image/png' }) + await store.write(asset({ id: 'img-9' }), blob) + expect(write).toHaveBeenCalledWith( + 'imageBlobs', + 'img-9', + expect.objectContaining({ blob, byteSize: blob.size }), + undefined, + ) + }) + + it('delegates delete to indexedDb', async () => { + await store.delete(asset({ id: 'img-3' })) + expect(del).toHaveBeenCalledWith('imageBlobs', 'img-3', undefined) + }) + + it('exists reflects whether a record is present', async () => { + read.mockResolvedValue(new Blob(['x'])) + expect(await store.exists(asset())).toBe(true) + read.mockResolvedValue(null) + expect(await store.exists(asset())).toBe(false) + }) + + it('listStoredIds returns only string keys', async () => { + listKeys.mockResolvedValue(['a', 5, 'b']) + expect(await store.listStoredIds()).toEqual(['a', 'b']) + }) +}) + +describe('ElectronImageContentStore', () => { + function bridge() { + return { + readImageData: vi.fn(async () => ({ dataUrl: 'data:image/png;base64,aGVsbG8=' })), + writeImageData: vi.fn(async () => {}), + deleteImageFile: vi.fn(async () => {}), + } + } + + it('reads via the bridge and converts the data URL to a Blob', async () => { + const b = bridge() + const store = new ElectronImageContentStore(b as never) + const blob = await store.read(asset()) + expect(b.readImageData).toHaveBeenCalledWith({ + relativePath: 'images/img.png', + mimeType: 'image/png', + }) + expect(blob).toBeInstanceOf(Blob) + }) + + it('returns null when the asset has no file path', async () => { + const store = new ElectronImageContentStore(bridge() as never) + expect(await store.read(asset({ file_path: '' }))).toBeNull() + }) + + it('writes the blob as a data URL', async () => { + const b = bridge() + const store = new ElectronImageContentStore(b as never) + const blob = new Blob(['hi'], { type: 'image/png' }) + await store.write(asset(), blob) + expect(b.writeImageData).toHaveBeenCalledWith({ + relativePath: 'images/img.png', + dataUrl: await blobToDataUrl(blob), + }) + }) + + it('throws on write when the asset has no file path', async () => { + const store = new ElectronImageContentStore(bridge() as never) + await expect(store.write(asset({ file_path: '' }), new Blob(['x']))).rejects.toThrow( + /path is required/, + ) + }) + + it('deletes via the bridge, and is a no-op without a file path', async () => { + const b = bridge() + const store = new ElectronImageContentStore(b as never) + await store.delete(asset()) + expect(b.deleteImageFile).toHaveBeenCalledWith({ relativePath: 'images/img.png' }) + + b.deleteImageFile.mockClear() + await store.delete(asset({ file_path: '' })) + expect(b.deleteImageFile).not.toHaveBeenCalled() + }) + + it('exists returns false when the bridge read throws', async () => { + const b = bridge() + b.readImageData.mockRejectedValue(new Error('missing file')) + const store = new ElectronImageContentStore(b as never) + expect(await store.exists(asset())).toBe(false) + }) +}) From 690175de666691ab17c692d16901127b30b14868 Mon Sep 17 00:00:00 2001 From: Catherine Luse Date: Wed, 15 Jul 2026 22:01:54 -0700 Subject: [PATCH 4/9] Add unit tests for the usePartImages composable Mocks useImageLibrary and useDatabase (jsdom env) and covers refreshPartImages (empty/loaded/error), the lightbox open/close for internal and external images, computed active-image state, prev/next navigation, saving notes and tags (success + error), the download-link flow, and the canStoreImages watcher. usePartImages.ts reaches ~93% line coverage. Co-Authored-By: Claude Opus 4.8 --- src/__tests__/usePartImages.spec.ts | 224 ++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 src/__tests__/usePartImages.spec.ts diff --git a/src/__tests__/usePartImages.spec.ts b/src/__tests__/usePartImages.spec.ts new file mode 100644 index 0000000..952905d --- /dev/null +++ b/src/__tests__/usePartImages.spec.ts @@ -0,0 +1,224 @@ +// @vitest-environment jsdom +import { nextTick } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ImageAsset } from '@/lib/database' +import { usePartImages } from '@/composables/usePartImages' + +const h = vi.hoisted(() => ({ + canStoreImages: null as { value: boolean } | null, + fetchPartImages: vi.fn(), + getImageSource: vi.fn(), + getWikiPages: vi.fn(), + getImageWikiTags: vi.fn(), + setImageWikiTags: vi.fn(), + updateImageAssetNotes: vi.fn(), +})) + +vi.mock('@/composables/useImageLibrary', async () => { + const { ref } = await import('vue') + const canStoreImages = ref(true) + h.canStoreImages = canStoreImages + return { + useImageLibrary: () => ({ + canStoreImages, + fetchPartImages: h.fetchPartImages, + getImageSource: h.getImageSource, + }), + } +}) + +vi.mock('@/composables/useDatabase', () => ({ + useDatabase: () => ({ + getWikiPages: h.getWikiPages, + getImageWikiTags: h.getImageWikiTags, + setImageWikiTags: h.setImageWikiTags, + updateImageAssetNotes: h.updateImageAssetNotes, + }), +})) + +function img(id: string, overrides: Partial = {}): ImageAsset { + return { + id, + book_id: 'b1', + chapter_id: null, + asset_type: 'illustration' as ImageAsset['asset_type'], + file_name: `${id}.png`, + file_path: `images/${id}.png`, + mime_type: 'image/png', + image_data: null, + notes: '', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + h.fetchPartImages.mockResolvedValue([]) + h.getImageSource.mockImplementation(async (image: ImageAsset) => `src:${image.id}`) + h.getWikiPages.mockResolvedValue([]) + h.getImageWikiTags.mockResolvedValue([]) + h.setImageWikiTags.mockResolvedValue(undefined) + h.updateImageAssetNotes.mockResolvedValue(undefined) +}) + +const setup = (partId: string | undefined = 'part-1', bookId: string | undefined = 'book-1') => + usePartImages(() => partId, () => bookId) + +describe('refreshPartImages', () => { + it('clears state when there is no part id', async () => { + const c = usePartImages(() => undefined, () => undefined) + c.partImages.value = [img('x')] + await c.refreshPartImages() + expect(c.partImages.value).toEqual([]) + expect(h.fetchPartImages).not.toHaveBeenCalled() + }) + + it('loads images, sources, tags, and wiki pages', async () => { + h.fetchPartImages.mockResolvedValue([img('a'), img('b')]) + h.getImageWikiTags.mockResolvedValue([{ wiki_page_id: 'w1' }]) + h.getWikiPages.mockResolvedValue([{ id: 'w1', page_name: 'Alice', page_type: 'character' }]) + + const c = setup() + await c.refreshPartImages() + + expect(c.partImages.value.map((i) => i.id)).toEqual(['a', 'b']) + expect(c.partImageSources.value).toEqual({ a: 'src:a', b: 'src:b' }) + expect(c.partImageTags.value.a).toEqual([{ wiki_page_id: 'w1' }]) + expect(c.bookWikiPages.value).toEqual([{ id: 'w1', page_name: 'Alice', page_type: 'character' }]) + expect(c.partImagesLoading.value).toBe(false) + }) + + it('records an error when loading fails', async () => { + h.fetchPartImages.mockRejectedValue(new Error('boom')) + const c = setup() + await c.refreshPartImages() + expect(c.partImageError.value).toBe('boom') + expect(c.partImagesLoading.value).toBe(false) + }) +}) + +describe('modal + computed state', () => { + it('opens an internal image only when a source exists', async () => { + h.fetchPartImages.mockResolvedValue([img('a')]) + const c = setup() + await c.refreshPartImages() + + c.openImageModal('missing') + expect(c.showImageLightbox.value).toBe(false) + + c.openImageModal('a') + expect(c.showImageLightbox.value).toBe(true) + expect(c.activeImage.value?.id).toBe('a') + expect(c.activeImageSource.value).toBe('src:a') + expect(c.activeImageLabel.value).toBe('a.png') + }) + + it('opens an external asset and loads its tags', async () => { + h.getImageWikiTags.mockResolvedValue([{ wiki_page_id: 'w9' }]) + const c = setup() + await c.openImageAsset(img('cover'), 'src:cover') + + expect(c.showImageLightbox.value).toBe(true) + expect(c.activeImage.value?.id).toBe('cover') + expect(c.activeImageSource.value).toBe('src:cover') + expect(c.activeImageTags.value).toEqual([{ wiki_page_id: 'w9' }]) + + c.closeImageModal() + expect(c.showImageLightbox.value).toBe(false) + expect(c.activeImage.value).toBeNull() + }) +}) + +describe('navigation', () => { + it('moves between images that have sources', async () => { + h.fetchPartImages.mockResolvedValue([img('a'), img('b'), img('c')]) + const c = setup() + await c.refreshPartImages() + + c.openImageModal('a') + expect(c.hasPrevImage.value).toBe(false) + expect(c.hasNextImage.value).toBe(true) + + c.goToNextImage() + expect(c.activeImageId.value).toBe('b') + c.goToNextImage() + expect(c.activeImageId.value).toBe('c') + expect(c.hasNextImage.value).toBe(false) + + c.goToPrevImage() + expect(c.activeImageId.value).toBe('b') + }) +}) + +describe('saving notes and tags', () => { + it('saves notes for an internal image', async () => { + h.fetchPartImages.mockResolvedValue([img('a')]) + const c = setup() + await c.refreshPartImages() + c.openImageModal('a') + + await c.handleSaveActiveImageNotes('new note') + + expect(h.updateImageAssetNotes).toHaveBeenCalledWith('a', 'new note') + expect(c.partImages.value[0].notes).toBe('new note') + expect(c.savingImageNotes.value).toBe(false) + }) + + it('saves tags and refreshes them', async () => { + h.fetchPartImages.mockResolvedValue([img('a')]) + const c = setup() + await c.refreshPartImages() + c.openImageModal('a') + + h.getImageWikiTags.mockResolvedValue([{ wiki_page_id: 'w2' }]) + await c.handleSaveActiveImageTags(['w2']) + + expect(h.setImageWikiTags).toHaveBeenCalledWith('a', ['w2']) + expect(c.partImageTags.value.a).toEqual([{ wiki_page_id: 'w2' }]) + }) + + it('records an error when saving notes fails', async () => { + h.fetchPartImages.mockResolvedValue([img('a')]) + h.updateImageAssetNotes.mockRejectedValue(new Error('save failed')) + const c = setup() + await c.refreshPartImages() + c.openImageModal('a') + + await c.handleSaveActiveImageNotes('x') + expect(c.partImageError.value).toBe('save failed') + }) +}) + +describe('download + watch', () => { + it('creates and clicks a download link', async () => { + h.fetchPartImages.mockResolvedValue([img('a')]) + const c = setup() + await c.refreshPartImages() + + const click = vi.fn() + const realCreate = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = realCreate(tag) as HTMLAnchorElement + if (tag === 'a') el.click = click + return el + }) + + c.handleDownloadImage('a') + expect(click).toHaveBeenCalled() + }) + + it('refreshes when the storage capability changes', async () => { + const c = setup() + await c.refreshPartImages() + h.fetchPartImages.mockClear() + h.fetchPartImages.mockResolvedValue([img('z')]) + + h.canStoreImages!.value = false + await nextTick() + + expect(h.fetchPartImages).toHaveBeenCalled() + }) +}) From 6ae9a06e1c441a70fc4b22ea1499e32dbd6eeaec Mon Sep 17 00:00:00 2001 From: Catherine Luse Date: Wed, 15 Jul 2026 22:03:16 -0700 Subject: [PATCH 5/9] Add unit tests for the useWikiImages composable Covers refreshWikiImages (empty/loaded/error), hero-image selection (cover preference + first-image fallback), openHeroLightbox, set-as-cover (success + error), navigation, saving notes/tags, download, and the canStoreImages watcher. useWikiImages.ts reaches ~90% line coverage. Co-Authored-By: Claude Opus 4.8 --- src/__tests__/useWikiImages.spec.ts | 206 ++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 src/__tests__/useWikiImages.spec.ts diff --git a/src/__tests__/useWikiImages.spec.ts b/src/__tests__/useWikiImages.spec.ts new file mode 100644 index 0000000..43d7699 --- /dev/null +++ b/src/__tests__/useWikiImages.spec.ts @@ -0,0 +1,206 @@ +// @vitest-environment jsdom +import { nextTick } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ImageAsset } from '@/lib/database' +import { useWikiImages } from '@/composables/useWikiImages' + +const h = vi.hoisted(() => ({ + canStoreImages: null as { value: boolean } | null, + getImageSource: vi.fn(), + getWikiPageImageAssets: vi.fn(), + getWikiPageCoverImageAsset: vi.fn(), + setWikiPageCoverImageId: vi.fn(), + getImageWikiTags: vi.fn(), + getWikiPages: vi.fn(), + setImageWikiTags: vi.fn(), + updateImageAssetNotes: vi.fn(), +})) + +vi.mock('@/composables/useImageLibrary', async () => { + const { ref } = await import('vue') + const canStoreImages = ref(true) + h.canStoreImages = canStoreImages + return { useImageLibrary: () => ({ canStoreImages, getImageSource: h.getImageSource }) } +}) + +vi.mock('@/composables/useDatabase', () => ({ + useDatabase: () => ({ + getWikiPageImageAssets: h.getWikiPageImageAssets, + getWikiPageCoverImageAsset: h.getWikiPageCoverImageAsset, + setWikiPageCoverImageId: h.setWikiPageCoverImageId, + getImageWikiTags: h.getImageWikiTags, + getWikiPages: h.getWikiPages, + setImageWikiTags: h.setImageWikiTags, + updateImageAssetNotes: h.updateImageAssetNotes, + }), +})) + +function img(id: string, overrides: Partial = {}): ImageAsset { + return { + id, + book_id: 'b1', + chapter_id: null, + asset_type: 'illustration' as ImageAsset['asset_type'], + file_name: `${id}.png`, + file_path: `images/${id}.png`, + mime_type: 'image/png', + image_data: null, + notes: '', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + h.getWikiPageImageAssets.mockResolvedValue([]) + h.getImageSource.mockImplementation(async (image: ImageAsset) => `src:${image.id}`) + h.getWikiPageCoverImageAsset.mockResolvedValue(null) + h.setWikiPageCoverImageId.mockResolvedValue(undefined) + h.getImageWikiTags.mockResolvedValue([]) + h.getWikiPages.mockResolvedValue([]) + h.setImageWikiTags.mockResolvedValue(undefined) + h.updateImageAssetNotes.mockResolvedValue(undefined) +}) + +const setup = (wikiId = 'wiki-1', bookId = 'book-1') => + useWikiImages(() => wikiId, () => bookId) + +describe('refreshWikiImages', () => { + it('clears state when there is no wiki page id', async () => { + const c = useWikiImages(() => undefined, () => undefined) + c.wikiImages.value = [img('x')] + await c.refreshWikiImages() + expect(c.wikiImages.value).toEqual([]) + expect(h.getWikiPageImageAssets).not.toHaveBeenCalled() + }) + + it('loads images, sources, tags, cover, and wiki pages', async () => { + h.getWikiPageImageAssets.mockResolvedValue([img('a'), img('b')]) + h.getWikiPageCoverImageAsset.mockResolvedValue(img('b')) + h.getWikiPages.mockResolvedValue([{ id: 'w1', page_name: 'Alice', page_type: 'character' }]) + + const c = setup() + await c.refreshWikiImages() + + expect(c.wikiImages.value.map((i) => i.id)).toEqual(['a', 'b']) + expect(c.wikiImageSources.value).toEqual({ a: 'src:a', b: 'src:b' }) + expect(c.wikiCoverImageId.value).toBe('b') + expect(c.bookWikiPages.value).toHaveLength(1) + }) + + it('records an error when loading fails', async () => { + h.getWikiPageImageAssets.mockRejectedValue(new Error('nope')) + const c = setup() + await c.refreshWikiImages() + expect(c.wikiImageError.value).toBe('nope') + }) +}) + +describe('hero image', () => { + it('prefers the cover image and falls back to the first image', async () => { + h.getWikiPageImageAssets.mockResolvedValue([img('a'), img('b')]) + h.getWikiPageCoverImageAsset.mockResolvedValue(img('b')) + const c = setup() + await c.refreshWikiImages() + + expect(c.heroImage.value?.id).toBe('b') + expect(c.heroImageSrc.value).toBe('src:b') + + c.wikiCoverImageId.value = null + expect(c.heroImage.value?.id).toBe('a') + }) + + it('opens the hero image in the lightbox', async () => { + h.getWikiPageImageAssets.mockResolvedValue([img('a')]) + const c = setup() + await c.refreshWikiImages() + + c.openHeroLightbox() + expect(c.showImageLightbox.value).toBe(true) + expect(c.activeImageId.value).toBe('a') + }) +}) + +describe('cover, navigation, notes, tags, download', () => { + it('sets an image as the cover', async () => { + h.getWikiPageImageAssets.mockResolvedValue([img('a'), img('b')]) + const c = setup() + await c.refreshWikiImages() + + await c.handleSetAsCover('b') + expect(h.setWikiPageCoverImageId).toHaveBeenCalledWith('wiki-1', 'b') + expect(c.wikiCoverImageId.value).toBe('b') + expect(c.settingCoverId.value).toBeNull() + }) + + it('records an error when setting the cover fails', async () => { + h.getWikiPageImageAssets.mockResolvedValue([img('a')]) + h.setWikiPageCoverImageId.mockRejectedValue(new Error('cover failed')) + const c = setup() + await c.refreshWikiImages() + + await c.handleSetAsCover('a') + expect(c.wikiImageError.value).toBe('cover failed') + }) + + it('navigates between images', async () => { + h.getWikiPageImageAssets.mockResolvedValue([img('a'), img('b')]) + const c = setup() + await c.refreshWikiImages() + + c.openImageModal('a') + c.goToNextImage() + expect(c.activeImageId.value).toBe('b') + c.goToPrevImage() + expect(c.activeImageId.value).toBe('a') + c.closeImageModal() + expect(c.showImageLightbox.value).toBe(false) + }) + + it('saves notes and tags for the active image', async () => { + h.getWikiPageImageAssets.mockResolvedValue([img('a')]) + const c = setup() + await c.refreshWikiImages() + c.openImageModal('a') + + await c.handleSaveActiveImageNotes('note') + expect(h.updateImageAssetNotes).toHaveBeenCalledWith('a', 'note') + expect(c.wikiImages.value[0].notes).toBe('note') + + h.getImageWikiTags.mockResolvedValue([{ wiki_page_id: 'w2' }]) + await c.handleSaveActiveImageTags(['w2']) + expect(h.setImageWikiTags).toHaveBeenCalledWith('a', ['w2']) + expect(c.wikiImageTags.value.a).toEqual([{ wiki_page_id: 'w2' }]) + }) + + it('creates and clicks a download link', async () => { + h.getWikiPageImageAssets.mockResolvedValue([img('a')]) + const c = setup() + await c.refreshWikiImages() + + const click = vi.fn() + const realCreate = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = realCreate(tag) as HTMLAnchorElement + if (tag === 'a') el.click = click + return el + }) + + c.handleDownloadImage('a') + expect(click).toHaveBeenCalled() + }) + + it('refreshes when the storage capability changes', async () => { + const c = setup() + await c.refreshWikiImages() + h.getWikiPageImageAssets.mockClear() + h.getWikiPageImageAssets.mockResolvedValue([img('z')]) + + h.canStoreImages!.value = false + await nextTick() + expect(h.getWikiPageImageAssets).toHaveBeenCalled() + }) +}) From 205bbed5bf79b0d4a836610a0ec803eb6bc9204f Mon Sep 17 00:00:00 2001 From: Catherine Luse Date: Wed, 15 Jul 2026 22:04:47 -0700 Subject: [PATCH 6/9] Add unit tests for the useChapterImages composable Covers refreshChapterImages (empty/loaded/error), handleAddIllustrations (prepend/no-op/error), the delete flow (request/cancel/delete incl. cover-clear and error), set-as-cover, modal open/close + navigation, saving notes/tags, and download + hero lightbox. useChapterImages.ts reaches ~88% line coverage. Co-Authored-By: Claude Opus 4.8 --- src/__tests__/useChapterImages.spec.ts | 250 +++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 src/__tests__/useChapterImages.spec.ts diff --git a/src/__tests__/useChapterImages.spec.ts b/src/__tests__/useChapterImages.spec.ts new file mode 100644 index 0000000..de232cc --- /dev/null +++ b/src/__tests__/useChapterImages.spec.ts @@ -0,0 +1,250 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ImageAsset } from '@/lib/database' +import { useChapterImages } from '@/composables/useChapterImages' + +const h = vi.hoisted(() => ({ + canSelectImages: null as { value: boolean } | null, + canStoreImages: null as { value: boolean } | null, + addImagesToChapter: vi.fn(), + deleteImage: vi.fn(), + fetchChapterImages: vi.fn(), + fetchChapterCover: vi.fn(), + setChapterCoverImageId: vi.fn(), + getImageSource: vi.fn(), + getWikiPages: vi.fn(), + getImageWikiTags: vi.fn(), + setImageWikiTags: vi.fn(), + updateImageAssetNotes: vi.fn(), +})) + +vi.mock('@/composables/useImageLibrary', async () => { + const { ref } = await import('vue') + const canSelectImages = ref(true) + const canStoreImages = ref(true) + h.canSelectImages = canSelectImages + h.canStoreImages = canStoreImages + return { + useImageLibrary: () => ({ + canSelectImages, + canStoreImages, + addImagesToChapter: h.addImagesToChapter, + deleteImage: h.deleteImage, + fetchChapterImages: h.fetchChapterImages, + fetchChapterCover: h.fetchChapterCover, + setChapterCoverImageId: h.setChapterCoverImageId, + getImageSource: h.getImageSource, + }), + } +}) + +vi.mock('@/composables/useDatabase', () => ({ + useDatabase: () => ({ + getWikiPages: h.getWikiPages, + getImageWikiTags: h.getImageWikiTags, + setImageWikiTags: h.setImageWikiTags, + updateImageAssetNotes: h.updateImageAssetNotes, + }), +})) + +function img(id: string, overrides: Partial = {}): ImageAsset { + return { + id, + book_id: 'b1', + chapter_id: 'ch-1', + asset_type: 'illustration' as ImageAsset['asset_type'], + file_name: `${id}.png`, + file_path: `images/${id}.png`, + mime_type: 'image/png', + image_data: null, + notes: '', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + h.fetchChapterImages.mockResolvedValue([]) + h.fetchChapterCover.mockResolvedValue(null) + h.getImageSource.mockImplementation(async (image: ImageAsset) => `src:${image.id}`) + h.getWikiPages.mockResolvedValue([]) + h.getImageWikiTags.mockResolvedValue([]) + h.setImageWikiTags.mockResolvedValue(undefined) + h.updateImageAssetNotes.mockResolvedValue(undefined) + h.setChapterCoverImageId.mockResolvedValue(undefined) + h.deleteImage.mockResolvedValue(undefined) + h.addImagesToChapter.mockResolvedValue([]) +}) + +const setup = (chapterId = 'ch-1', bookId = 'book-1') => + useChapterImages(() => chapterId, () => bookId) + +describe('refreshChapterImages', () => { + it('clears state when there is no chapter id', async () => { + const c = useChapterImages(() => undefined, () => undefined) + c.chapterImages.value = [img('x')] + await c.refreshChapterImages() + expect(c.chapterImages.value).toEqual([]) + expect(h.fetchChapterImages).not.toHaveBeenCalled() + }) + + it('loads images, sources, tags, wiki pages, and cover', async () => { + h.fetchChapterImages.mockResolvedValue([img('a'), img('b')]) + h.fetchChapterCover.mockResolvedValue(img('a')) + h.getWikiPages.mockResolvedValue([{ id: 'w1', page_name: 'Alice', page_type: 'character' }]) + + const c = setup() + await c.refreshChapterImages() + + expect(c.chapterImages.value.map((i) => i.id)).toEqual(['a', 'b']) + expect(c.chapterImageSources.value).toEqual({ a: 'src:a', b: 'src:b' }) + expect(c.chapterCoverImageId.value).toBe('a') + expect(c.bookWikiPages.value).toHaveLength(1) + expect(c.heroImage.value?.id).toBe('a') + }) + + it('records an error when loading fails', async () => { + h.fetchChapterImages.mockRejectedValue(new Error('load fail')) + const c = setup() + await c.refreshChapterImages() + expect(c.chapterImageError.value).toBe('load fail') + }) +}) + +describe('handleAddIllustrations', () => { + it('prepends newly added images with their sources', async () => { + h.fetchChapterImages.mockResolvedValue([img('a')]) + const c = setup() + await c.refreshChapterImages() + + h.addImagesToChapter.mockResolvedValue([img('new')]) + await c.handleAddIllustrations() + + expect(h.addImagesToChapter).toHaveBeenCalledWith('book-1', 'ch-1') + expect(c.chapterImages.value.map((i) => i.id)).toEqual(['new', 'a']) + expect(c.chapterImageSources.value.new).toBe('src:new') + expect(c.addingChapterImages.value).toBe(false) + }) + + it('does nothing when no images are returned', async () => { + const c = setup() + await c.refreshChapterImages() + h.addImagesToChapter.mockResolvedValue([]) + await c.handleAddIllustrations() + expect(c.chapterImages.value).toEqual([]) + }) + + it('records an error when adding fails', async () => { + const c = setup() + h.addImagesToChapter.mockRejectedValue(new Error('add fail')) + await c.handleAddIllustrations() + expect(c.chapterImageError.value).toBe('add fail') + }) +}) + +describe('delete flow', () => { + it('requests then cancels a deletion', () => { + const c = setup() + c.requestDeleteIllustration('a') + expect(c.showDeleteIllustrationModal.value).toBe(true) + expect(c.illustrationToDelete.value).toBe('a') + + c.cancelDeleteIllustration() + expect(c.showDeleteIllustrationModal.value).toBe(false) + expect(c.illustrationToDelete.value).toBeNull() + }) + + it('deletes an illustration and clears its cover when it was the cover', async () => { + h.fetchChapterImages.mockResolvedValue([img('a'), img('b')]) + h.fetchChapterCover.mockResolvedValue(img('a')) + const c = setup() + await c.refreshChapterImages() + + c.requestDeleteIllustration('a') + await c.handleDeleteIllustration() + + expect(h.deleteImage).toHaveBeenCalled() + expect(c.chapterImages.value.map((i) => i.id)).toEqual(['b']) + expect(c.chapterImageSources.value.a).toBeUndefined() + expect(h.setChapterCoverImageId).toHaveBeenCalledWith('ch-1', null) + expect(c.chapterCoverImageId.value).toBeNull() + expect(c.showDeleteIllustrationModal.value).toBe(false) + }) + + it('records an error when deletion fails', async () => { + h.fetchChapterImages.mockResolvedValue([img('a')]) + h.deleteImage.mockRejectedValue(new Error('delete fail')) + const c = setup() + await c.refreshChapterImages() + + c.requestDeleteIllustration('a') + await c.handleDeleteIllustration() + expect(c.chapterImageError.value).toBe('delete fail') + }) +}) + +describe('cover, modal, notes, tags, download', () => { + it('sets an image as the cover', async () => { + h.fetchChapterImages.mockResolvedValue([img('a')]) + const c = setup() + await c.refreshChapterImages() + + await c.handleSetAsCover('a') + expect(h.setChapterCoverImageId).toHaveBeenCalledWith('ch-1', 'a') + expect(c.chapterCoverImageId.value).toBe('a') + }) + + it('opens/closes the modal and navigates', async () => { + h.fetchChapterImages.mockResolvedValue([img('a'), img('b')]) + const c = setup() + await c.refreshChapterImages() + + c.openImageModal('a') + expect(c.showImageLightbox.value).toBe(true) + c.goToNextImage() + expect(c.activeImageId.value).toBe('b') + c.goToPrevImage() + expect(c.activeImageId.value).toBe('a') + c.closeImageModal() + expect(c.showImageLightbox.value).toBe(false) + }) + + it('saves notes and tags', async () => { + h.fetchChapterImages.mockResolvedValue([img('a')]) + const c = setup() + await c.refreshChapterImages() + c.openImageModal('a') + + await c.handleSaveActiveImageNotes('note') + expect(c.chapterImages.value[0].notes).toBe('note') + + h.getImageWikiTags.mockResolvedValue([{ wiki_page_id: 'w2' }]) + await c.handleSaveActiveImageTags(['w2']) + expect(h.setImageWikiTags).toHaveBeenCalledWith('a', ['w2']) + expect(c.chapterImageTags.value.a).toEqual([{ wiki_page_id: 'w2' }]) + }) + + it('downloads and opens the hero image', async () => { + h.fetchChapterImages.mockResolvedValue([img('a')]) + const c = setup() + await c.refreshChapterImages() + + const click = vi.fn() + const realCreate = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = realCreate(tag) as HTMLAnchorElement + if (tag === 'a') el.click = click + return el + }) + + c.handleDownloadImage('a') + expect(click).toHaveBeenCalled() + + c.openHeroLightbox() + expect(c.activeImageId.value).toBe('a') + expect(c.chapterImageUploadAvailable.value).toBe(true) + }) +}) From 7b5de01a9a3e04ac6e339f15ebdf9d52cf60732a Mon Sep 17 00:00:00 2001 From: Catherine Luse Date: Wed, 15 Jul 2026 22:24:15 -0700 Subject: [PATCH 7/9] Cover warn/error branches in the image composables Adds tests for the console.warn catch paths (failed source/tag/wiki-page loads during refresh), save-notes/tags/cover error branches, external image save+download flows (usePartImages), active-image computed reads, delete-active-image lightbox close, and the no-active-image no-ops. Also restores spies between tests (afterEach) to prevent document.createElement spy stacking. usePartImages ~98%, useWikiImages 100%, useChapterImages ~97%. Co-Authored-By: Claude Opus 4.8 --- src/__tests__/useChapterImages.spec.ts | 88 +++++++++++++++++++++++++- src/__tests__/usePartImages.spec.ts | 66 ++++++++++++++++++- src/__tests__/useWikiImages.spec.ts | 65 ++++++++++++++++++- 3 files changed, 216 insertions(+), 3 deletions(-) diff --git a/src/__tests__/useChapterImages.spec.ts b/src/__tests__/useChapterImages.spec.ts index de232cc..d270bd6 100644 --- a/src/__tests__/useChapterImages.spec.ts +++ b/src/__tests__/useChapterImages.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ImageAsset } from '@/lib/database' import { useChapterImages } from '@/composables/useChapterImages' @@ -79,6 +79,10 @@ beforeEach(() => { h.addImagesToChapter.mockResolvedValue([]) }) +afterEach(() => { + vi.restoreAllMocks() +}) + const setup = (chapterId = 'ch-1', bookId = 'book-1') => useChapterImages(() => chapterId, () => bookId) @@ -247,4 +251,86 @@ describe('cover, modal, notes, tags, download', () => { expect(c.activeImageId.value).toBe('a') expect(c.chapterImageUploadAvailable.value).toBe(true) }) + + it('exposes active-image computeds for the open image', async () => { + h.fetchChapterImages.mockResolvedValue([img('a')]) + h.getImageWikiTags.mockResolvedValue([{ wiki_page_id: 'w1' }]) + const c = setup() + await c.refreshChapterImages() + + expect(c.activeImageSource.value).toBeNull() + expect(c.activeImageTags.value).toEqual([]) + expect(c.activeImageLabel.value).toBe('') + + c.openImageModal('a') + expect(c.activeImageSource.value).toBe('src:a') + expect(c.activeImageTags.value).toEqual([{ wiki_page_id: 'w1' }]) + expect(c.activeImageLabel.value).toBe('a.png') + }) +}) + +describe('warn and error branches', () => { + it('warns but continues when source/tag/page loads fail during refresh', async () => { + h.fetchChapterImages.mockResolvedValue([img('a')]) + h.getImageSource.mockRejectedValue(new Error('no source')) + h.getImageWikiTags.mockRejectedValue(new Error('no tags')) + h.getWikiPages.mockRejectedValue(new Error('no pages')) + + const c = setup() + await c.refreshChapterImages() + + expect(c.chapterImageSources.value).toEqual({}) + expect(c.bookWikiPages.value).toEqual([]) + expect(c.chapterImages.value).toHaveLength(1) + }) + + it('warns when a preview fails for a newly added illustration', async () => { + const c = setup() + await c.refreshChapterImages() + h.addImagesToChapter.mockResolvedValue([img('new')]) + h.getImageSource.mockRejectedValue(new Error('preview fail')) + + await c.handleAddIllustrations() + expect(c.chapterImages.value.map((i) => i.id)).toEqual(['new']) + expect(c.chapterImageSources.value.new).toBeUndefined() + }) + + it('closes the lightbox when the active image is deleted', async () => { + h.fetchChapterImages.mockResolvedValue([img('a')]) + const c = setup() + await c.refreshChapterImages() + c.openImageModal('a') + + c.requestDeleteIllustration('a') + await c.handleDeleteIllustration() + expect(c.showImageLightbox.value).toBe(false) + expect(c.activeImageId.value).toBeNull() + }) + + it('records errors when saving notes/tags or setting the cover fails', async () => { + h.fetchChapterImages.mockResolvedValue([img('a')]) + const c = setup() + await c.refreshChapterImages() + c.openImageModal('a') + + h.updateImageAssetNotes.mockRejectedValue(new Error('notes fail')) + await c.handleSaveActiveImageNotes('x') + expect(c.chapterImageError.value).toBe('notes fail') + + h.setImageWikiTags.mockRejectedValue(new Error('tags fail')) + await c.handleSaveActiveImageTags(['w']) + expect(c.chapterImageError.value).toBe('tags fail') + + h.setChapterCoverImageId.mockRejectedValue(new Error('cover fail')) + await c.handleSetAsCover('a') + expect(c.chapterImageError.value).toBe('cover fail') + }) + + it('does not cancel a deletion while one is in progress', () => { + const c = setup() + c.requestDeleteIllustration('a') + c.deletingIllustration.value = true + c.cancelDeleteIllustration() + expect(c.showDeleteIllustrationModal.value).toBe(true) + }) }) diff --git a/src/__tests__/usePartImages.spec.ts b/src/__tests__/usePartImages.spec.ts index 952905d..fae8660 100644 --- a/src/__tests__/usePartImages.spec.ts +++ b/src/__tests__/usePartImages.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { nextTick } from 'vue' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ImageAsset } from '@/lib/database' import { usePartImages } from '@/composables/usePartImages' @@ -64,6 +64,12 @@ beforeEach(() => { h.updateImageAssetNotes.mockResolvedValue(undefined) }) +afterEach(() => { + // Restore spies (e.g. document.createElement) so re-spying in a later test + // does not stack into infinite recursion. + vi.restoreAllMocks() +}) + const setup = (partId: string | undefined = 'part-1', bookId: string | undefined = 'book-1') => usePartImages(() => partId, () => bookId) @@ -192,6 +198,64 @@ describe('saving notes and tags', () => { }) }) +describe('warn branches and external-image flows', () => { + it('warns but continues when a source or tag load fails during refresh', async () => { + h.fetchPartImages.mockResolvedValue([img('a'), img('b')]) + h.getImageSource.mockImplementation(async (image: ImageAsset) => { + if (image.id === 'a') throw new Error('no source') + return `src:${image.id}` + }) + h.getImageWikiTags.mockRejectedValueOnce(new Error('no tags')) + h.getWikiPages.mockRejectedValue(new Error('no pages')) + + const c = setup() + await c.refreshPartImages() + + expect(c.partImageSources.value).toEqual({ b: 'src:b' }) + expect(c.bookWikiPages.value).toEqual([]) + expect(c.partImages.value).toHaveLength(2) + }) + + it('warns when loading tags fails while opening an external asset', async () => { + h.getImageWikiTags.mockRejectedValue(new Error('tag load failed')) + const c = setup() + await c.openImageAsset(img('ext'), 'src:ext') + expect(c.activeImageTags.value).toEqual([]) + expect(c.activeImageLabel.value).toBe('ext.png') + }) + + it('saves notes and tags for an external image and downloads it', async () => { + const c = setup() + await c.openImageAsset(img('ext'), 'src:ext') + + await c.handleSaveActiveImageNotes('external note') + expect(h.updateImageAssetNotes).toHaveBeenCalledWith('ext', 'external note') + expect(c.activeImage.value?.notes).toBe('external note') + + h.getImageWikiTags.mockResolvedValue([{ wiki_page_id: 'w5' }]) + await c.handleSaveActiveImageTags(['w5']) + expect(c.activeImageTags.value).toEqual([{ wiki_page_id: 'w5' }]) + + const click = vi.fn() + const realCreate = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = realCreate(tag) as HTMLAnchorElement + if (tag === 'a') el.click = click + return el + }) + c.handleDownloadImage('ext') + expect(click).toHaveBeenCalled() + }) + + it('does nothing when saving notes/tags with no active image', async () => { + const c = setup() + await c.handleSaveActiveImageNotes('x') + await c.handleSaveActiveImageTags(['w']) + expect(h.updateImageAssetNotes).not.toHaveBeenCalled() + expect(h.setImageWikiTags).not.toHaveBeenCalled() + }) +}) + describe('download + watch', () => { it('creates and clicks a download link', async () => { h.fetchPartImages.mockResolvedValue([img('a')]) diff --git a/src/__tests__/useWikiImages.spec.ts b/src/__tests__/useWikiImages.spec.ts index 43d7699..a4039c5 100644 --- a/src/__tests__/useWikiImages.spec.ts +++ b/src/__tests__/useWikiImages.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { nextTick } from 'vue' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ImageAsset } from '@/lib/database' import { useWikiImages } from '@/composables/useWikiImages' @@ -65,6 +65,10 @@ beforeEach(() => { h.updateImageAssetNotes.mockResolvedValue(undefined) }) +afterEach(() => { + vi.restoreAllMocks() +}) + const setup = (wikiId = 'wiki-1', bookId = 'book-1') => useWikiImages(() => wikiId, () => bookId) @@ -122,6 +126,23 @@ describe('hero image', () => { expect(c.showImageLightbox.value).toBe(true) expect(c.activeImageId.value).toBe('a') }) + + it('exposes active-image source/tags/label for the open image', async () => { + h.getWikiPageImageAssets.mockResolvedValue([img('a')]) + h.getImageWikiTags.mockResolvedValue([{ wiki_page_id: 'w1' }]) + const c = setup() + await c.refreshWikiImages() + + // Nothing open yet. + expect(c.activeImageSource.value).toBeNull() + expect(c.activeImageTags.value).toEqual([]) + expect(c.activeImageLabel.value).toBe('') + + c.openImageModal('a') + expect(c.activeImageSource.value).toBe('src:a') + expect(c.activeImageTags.value).toEqual([{ wiki_page_id: 'w1' }]) + expect(c.activeImageLabel.value).toBe('a.png') + }) }) describe('cover, navigation, notes, tags, download', () => { @@ -204,3 +225,45 @@ describe('cover, navigation, notes, tags, download', () => { expect(h.getWikiPageImageAssets).toHaveBeenCalled() }) }) + +describe('warn and error branches', () => { + it('warns but continues when source/tag/page loads fail during refresh', async () => { + h.getWikiPageImageAssets.mockResolvedValue([img('a')]) + h.getImageSource.mockRejectedValue(new Error('no source')) + h.getImageWikiTags.mockRejectedValue(new Error('no tags')) + h.getWikiPages.mockRejectedValue(new Error('no pages')) + + const c = setup() + await c.refreshWikiImages() + + expect(c.wikiImageSources.value).toEqual({}) + expect(c.bookWikiPages.value).toEqual([]) + expect(c.wikiImages.value).toHaveLength(1) + }) + + it('records an error when saving notes or tags fails', async () => { + h.getWikiPageImageAssets.mockResolvedValue([img('a')]) + const c = setup() + await c.refreshWikiImages() + c.openImageModal('a') + + h.updateImageAssetNotes.mockRejectedValue(new Error('notes fail')) + await c.handleSaveActiveImageNotes('x') + expect(c.wikiImageError.value).toBe('notes fail') + + h.setImageWikiTags.mockRejectedValue(new Error('tags fail')) + await c.handleSaveActiveImageTags(['w']) + expect(c.wikiImageError.value).toBe('tags fail') + }) + + it('no-ops save handlers and hero lightbox with no active/hero image', async () => { + const c = setup() + await c.refreshWikiImages() // empty + await c.handleSaveActiveImageNotes('x') + await c.handleSaveActiveImageTags(['w']) + c.openHeroLightbox() + expect(c.showImageLightbox.value).toBe(false) + expect(c.heroImage.value).toBeNull() + expect(h.updateImageAssetNotes).not.toHaveBeenCalled() + }) +}) From 0bf2c2ef3c10fa5ec2169d7c2a9aaf65b1d5be31 Mon Sep 17 00:00:00 2001 From: Catherine Luse Date: Wed, 15 Jul 2026 22:26:38 -0700 Subject: [PATCH 8/9] Extend summary-context composable coverage useChapterSummaryContext: buildPriorChapterSummariesInBook, part-number mapping, cache clearing/reuse, book-order and created_at ordering fallbacks, and malformed-id-array handling (now 100%). usePartSummaryContext: partNumber/chapterTitleMap computeds, reset and error paths, created_at ordering for unordered chapters, and parseJsonArray edge cases (now ~99%). Co-Authored-By: Claude Opus 4.8 --- .../useChapterSummaryContext.spec.ts | 99 +++++++++++++++++++ src/__tests__/usePartSummaryContext.spec.ts | 66 +++++++++++++ 2 files changed, 165 insertions(+) diff --git a/src/__tests__/useChapterSummaryContext.spec.ts b/src/__tests__/useChapterSummaryContext.spec.ts index 1b2576e..c1930c9 100644 --- a/src/__tests__/useChapterSummaryContext.spec.ts +++ b/src/__tests__/useChapterSummaryContext.spec.ts @@ -154,3 +154,102 @@ describe('useChapterSummaryContext', () => { expect(getSummary).toHaveBeenCalledWith('chapter-1') }) }) + +function makeSummary(chapterId: string): ChapterSummary { + return { + id: `summary-${chapterId}`, + chapter_id: chapterId, + summary: `${chapterId} summary`, + pov: null, + characters: null, + beats: null, + spoilers_ok: false, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + } +} + +function ctx(overrides: Partial[0]> = {}) { + return useChapterSummaryContext({ + getCurrentBook: createBook, + getParts: createParts, + getChapters: createChapters, + getSummary: vi.fn(async (id: string) => makeSummary(id)), + getPartSummary: vi.fn(async () => null), + ...overrides, + }) +} + +describe('useChapterSummaryContext — additional coverage', () => { + it('builds prior chapter summaries across the whole book, stopping at the current chapter', async () => { + const summaries = await ctx().buildPriorChapterSummariesInBook('chapter-3') + expect(summaries.map((s) => s.id)).toEqual(['chapter-1', 'chapter-2']) + expect(summaries[0].title).toBe('Arrival') + }) + + it('maps part numbers from the book part order', () => { + const c = ctx() + expect(c.getPartNumber('part-2')).toBe(2) + expect(c.getPartNumber('unknown')).toBeNull() + expect(c.getPartNumber(null)).toBeNull() + }) + + it('clearSummaryCaches forces a re-fetch', async () => { + const getSummary = vi.fn(async (id: string) => makeSummary(id)) + const c = ctx({ getSummary }) + await c.buildPriorChapterSummariesInBook('chapter-2') + expect(getSummary).toHaveBeenCalledTimes(1) + + await c.buildPriorChapterSummariesInBook('chapter-2') + expect(getSummary).toHaveBeenCalledTimes(1) // cached + + c.clearSummaryCaches() + await c.buildPriorChapterSummariesInBook('chapter-2') + expect(getSummary).toHaveBeenCalledTimes(2) + }) + + it('caches part summaries across calls', async () => { + const getPartSummary = vi.fn(async () => ({ + id: 'ps-1', + part_id: 'part-1', + summary: 'Part one recap.', + characters: null, + beats: null, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + })) as unknown as Parameters[0]['getPartSummary'] + const c = ctx({ getPartSummary }) + + await c.buildPriorPartSummaries('part-2') + await c.buildPriorPartSummaries('part-2') + expect((getPartSummary as ReturnType)).toHaveBeenCalledTimes(1) + }) + + it('falls back to book order, then created_at, when a part has no chapter order', async () => { + const parts: BookPart[] = [{ ...createParts()[0], chapter_order: '[]' }, createParts()[1]] + // Book order still lists the part-1 chapters, so this exercises the book-order fallback. + const bookOrder = ctx({ getParts: () => parts }) + const viaBook = await bookOrder.buildPriorPartSummaries('part-2') + expect(viaBook[0].summary).toContain('Arrival (chapter-1)') + + // Neither the part nor the book lists them -> created_at ordering fallback. + const emptyBook: Book = { ...createBook(), chapter_order: '[]' } + const viaCreatedAt = ctx({ getParts: () => parts, getCurrentBook: () => emptyBook }) + const result = await viaCreatedAt.buildPriorPartSummaries('part-2') + expect(result[0].summary).toContain('Arrival (chapter-1)') + }) + + it('uses the book order for prior chapters when the part order omits the current chapter', async () => { + const partMissingCurrent: BookPart = { ...createParts()[0], chapter_order: '["chapter-1"]' } + const summaries = await ctx().buildPriorChapterSummariesInPart(partMissingCurrent, 'chapter-2') + expect(summaries.map((s) => s.id)).toEqual(['chapter-1']) + }) + + it('treats malformed id arrays as empty', async () => { + const badBook: Book = { ...createBook(), chapter_order: '{not json' } + const summaries = await ctx({ getCurrentBook: () => badBook }).buildPriorChapterSummariesInBook( + 'chapter-3', + ) + expect(summaries).toEqual([]) + }) +}) diff --git a/src/__tests__/usePartSummaryContext.spec.ts b/src/__tests__/usePartSummaryContext.spec.ts index 439996a..50b1af8 100644 --- a/src/__tests__/usePartSummaryContext.spec.ts +++ b/src/__tests__/usePartSummaryContext.spec.ts @@ -136,3 +136,69 @@ describe('usePartSummaryContext', () => { expect(getPartSummary).toHaveBeenCalledWith('part-1') }) }) + +function ctx(overrides: Partial[0]> = {}) { + return usePartSummaryContext({ + book: computed(() => createBook()), + partId: computed(() => 'part-1'), + chapters: ref(createChapters()), + getPartSummary: async () => null, + getSummary: async () => null, + ...overrides, + }) +} + +describe('usePartSummaryContext — additional coverage', () => { + it('computes the part number and chapter title map', async () => { + const c = ctx() + expect(c.partNumber.value).toBe(1) + + await c.hydrateChapterEntries(createPart()) + expect(c.chapterTitleMap.value.get('chapter-2')).toBe('Second') + expect(c.summaryCoverage.value).toBe('0/2') + }) + + it('returns null part number when the part is not in the order', () => { + const c = ctx({ partId: computed(() => 'missing-part') }) + expect(c.partNumber.value).toBeNull() + }) + + it('resets the summary when none is stored', async () => { + const c = ctx() + await c.loadPartSummaryData('part-1') + expect(c.partSummary.value).toEqual({ summary: '', characters: [], beats: [], updatedAt: null }) + expect(c.hasPartSummary.value).toBe(false) + }) + + it('resets and logs when loading the part summary throws', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const c = ctx({ + getPartSummary: async () => { + throw new Error('db down') + }, + }) + await c.loadPartSummaryData('part-1') + expect(c.partSummary.value.summary).toBe('') + expect(error).toHaveBeenCalled() + }) + + it('appends chapters not in the part order using created_at ordering', async () => { + const part: BookPart = { ...createPart(), chapter_order: '[]' } + const chapters = createChapters().map((ch) => + ch.id === 'chapter-3' ? { ...ch, part_id: 'part-1' } : ch, + ) + const c = ctx({ chapters: ref(chapters) }) + await c.hydrateChapterEntries(part) + // Empty order -> all three sorted by created_at (chapter-2 < chapter-1 < chapter-3). + expect(c.chapterEntries.value.map((e) => e.id)).toEqual(['chapter-2', 'chapter-1', 'chapter-3']) + expect(c.totalWordCount.value).toBe(600) + }) + + it('parseJsonArray tolerates malformed and non-array input', () => { + const { parseJsonArray } = ctx() + expect(parseJsonArray('{not json')).toEqual([]) + expect(parseJsonArray('{"a":1}')).toEqual([]) + expect(parseJsonArray(null)).toEqual([]) + expect(parseJsonArray([' a ', 1, ''])).toEqual(['a']) + }) +}) From ca8f7df42fdeb1d46ee9f80582ea0412c922f905 Mon Sep 17 00:00:00 2001 From: Catherine Luse Date: Wed, 15 Jul 2026 22:30:07 -0700 Subject: [PATCH 9/9] Mop up coverage in platform, exportHelpers, and browserStorage - platform: the Capacitor-throws path of isDesktopAppRuntime (100%) - exportHelpers: single-file book export with part headings + uncategorized section, and parseJsonArray edge cases (100%) - browserStorage: remaining browserStorageError name branches, the persist-throws path, large/Infinity byte formatting, and the snapshot error path (~96%; the IndexedDB success path needs a real IDB) Co-Authored-By: Claude Opus 4.8 --- src/__tests__/browserStorage.spec.ts | 41 ++++++++++++++++++++++++++++ src/__tests__/exportHelpers.spec.ts | 34 ++++++++++++++++++++++- src/__tests__/platform.spec.ts | 8 ++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/__tests__/browserStorage.spec.ts b/src/__tests__/browserStorage.spec.ts index c2bbe4f..2a5b57d 100644 --- a/src/__tests__/browserStorage.spec.ts +++ b/src/__tests__/browserStorage.spec.ts @@ -26,9 +26,50 @@ describe('browser storage helpers', () => { expect(snapshot.error).toContain('IndexedDB is unavailable') }) + it('captures an error when the storage estimate/read fails', async () => { + const storage = { + estimate: async () => { + throw new Error('estimate failed') + }, + persisted: async () => false, + } as unknown as StorageManager + const factory = { open: () => ({}) } as unknown as IDBFactory + + const snapshot = await getBrowserStorageSnapshot(storage, factory) + expect(snapshot.available).toBe(false) + expect(snapshot.error).toBeTruthy() + }) + it('requests persistent storage when supported', async () => { const storage = { persist: async () => true } as StorageManager await expect(requestPersistentBrowserStorage(storage)).resolves.toBe(true) await expect(requestPersistentBrowserStorage(undefined)).resolves.toBeNull() }) + + it('returns false when requesting persistent storage throws', async () => { + const storage = { + persist: async () => { + throw new Error('denied') + }, + } as unknown as StorageManager + await expect(requestPersistentBrowserStorage(storage)).resolves.toBe(false) + }) + + it('formats large values and rounds appropriately', () => { + expect(formatStorageBytes(2048)).toBe('2.00 KB') + expect(formatStorageBytes(15 * 1024 * 1024)).toBe('15.0 MB') + expect(formatStorageBytes(3 * 1024 ** 4)).toBe('3.00 TB') + expect(formatStorageBytes(Infinity)).toBe('Unavailable') + }) + + it('maps the remaining storage error names to friendly messages', () => { + expect(browserStorageError({ name: 'InvalidStateError' }, 'saved').message) + .toContain('temporary in this browsing mode') + expect(browserStorageError({ name: 'AbortError' }, 'saved').message) + .toContain('interrupted') + expect(browserStorageError(new Error('boom'), 'saved').message) + .toContain('boom') + expect(browserStorageError('weird', 'saved').message) + .toContain('could not be saved in browser storage') + }) }) diff --git a/src/__tests__/exportHelpers.spec.ts b/src/__tests__/exportHelpers.spec.ts index 32b30c7..c19093a 100644 --- a/src/__tests__/exportHelpers.spec.ts +++ b/src/__tests__/exportHelpers.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildMarkdownExportFiles } from '@/lib/exportHelpers' +import { buildMarkdownExportFiles, parseJsonArray } from '@/lib/exportHelpers' import type { Book, BookPart, Chapter } from '@/lib/database' function createBook(): Book { @@ -99,6 +99,25 @@ describe('buildMarkdownExportFiles', () => { ]) }) + it('creates a single book file with part headings and an uncategorized section', () => { + const files = buildMarkdownExportFiles({ + book: createBook(), + chapters: createChapters(), + parts: createParts(), + chapterNotesById: {}, + granularity: 'book', + includeNotes: false, + }) + + expect(files).toHaveLength(1) + expect(files[0].path).toBe('Book___One.md') + const content = files[0].content + expect(content).toContain('## Second Part') + expect(content).toContain('## First Part') + expect(content).toContain('## Uncategorized Chapters') + expect(content).toContain('### Beta') + }) + it('creates a single ordered book file and omits notes when disabled', () => { const files = buildMarkdownExportFiles({ book: { @@ -123,3 +142,16 @@ describe('buildMarkdownExportFiles', () => { ]) }) }) + +describe('parseJsonArray', () => { + it('returns the string entries of a JSON array', () => { + expect(parseJsonArray('["a","b"]')).toEqual(['a', 'b']) + }) + + it('returns an empty array for nullish, malformed, or non-array input', () => { + expect(parseJsonArray(null)).toEqual([]) + expect(parseJsonArray(undefined)).toEqual([]) + expect(parseJsonArray('{bad')).toEqual([]) + expect(parseJsonArray('{"a":1}')).toEqual([]) + }) +}) diff --git a/src/__tests__/platform.spec.ts b/src/__tests__/platform.spec.ts index bb4ec15..a3d8da4 100644 --- a/src/__tests__/platform.spec.ts +++ b/src/__tests__/platform.spec.ts @@ -55,4 +55,12 @@ describe('isDesktopAppRuntime', () => { getPlatform.mockReturnValue('web') expect(isDesktopAppRuntime()).toBe(false) }) + + it('is false when Capacitor throws with a window present', () => { + vi.stubGlobal('window', {}) + getPlatform.mockImplementation(() => { + throw new Error('not available') + }) + expect(isDesktopAppRuntime()).toBe(false) + }) })