From 0ba80e9aad94cfcf5db4234ebf18297223a9a25a Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Fri, 12 Sep 2025 02:27:09 +0000 Subject: [PATCH] CodeRabbit Generated Unit Tests: Add Jest/JSDOM unit tests for book, library, main, indexedDB, style --- tests/unit/book.test.js | 390 +++++++++++++++++++++++++ tests/unit/indexedDB.test.js | 291 +++++++++++++++++++ tests/unit/library.test.js | 297 +++++++++++++++++++ tests/unit/main.test.js | 273 ++++++++++++++++++ tests/unit/style.test.js | 546 +++++++++++++++++++++++++++++++++++ 5 files changed, 1797 insertions(+) create mode 100644 tests/unit/book.test.js create mode 100644 tests/unit/indexedDB.test.js create mode 100644 tests/unit/library.test.js create mode 100644 tests/unit/main.test.js create mode 100644 tests/unit/style.test.js diff --git a/tests/unit/book.test.js b/tests/unit/book.test.js new file mode 100644 index 0000000..84d7744 --- /dev/null +++ b/tests/unit/book.test.js @@ -0,0 +1,390 @@ +/** + * Testing library/framework: Jest (expect/describe/test) with jsdom environment. + * These tests validate the public interfaces and DOM interactions for the book module. + * + * If your project uses Vitest, these tests are largely compatible (minor API tweaks may be needed). + */ + +jest.mock('epubjs', () => { + // Mock ePub constructor returning a predictable book object used by loadBook() + const mockLocations = { + generate: jest.fn(() => Promise.resolve()), + length: jest.fn(() => 42), + cfiFromLocation: jest.fn((loc) => `epubcfi(/6/${loc})`), + locationFromCfi: jest.fn(() => 3), + }; + + const mockNavigation = { + toc: Promise.resolve([{ label: 'Chapter 1', href: 'ch1.xhtml' }]), + }; + + const mockBook = { + ready: Promise.resolve(), + renderTo: jest.fn(() => ({ + display: jest.fn(() => Promise.resolve()), + on: jest.fn(), + prev: jest.fn(), + next: jest.fn(), + })), + loaded: { metadata: Promise.resolve({ title: 'Test Title' }) }, + locations: mockLocations, + navigation: mockNavigation, + }; + + const ePub = jest.fn(() => mockBook); + ePub.__mock = { mockBook, mockLocations }; + return { __esModule: true, default: ePub }; +}); + +jest.mock('../../src/main', () => ({ + showLoading: jest.fn(), + hideLoading: jest.fn(), + showError: jest.fn(), +})); +jest.mock('../../src/library', () => ({ + toggleLibrary: jest.fn(), +})); + +/** + * Helper to inject required DOM nodes before importing the module under test. + */ +function setupDomSkeleton() { + document.body.innerHTML = ` + + + + +
+ + + + + + `; +} + +// FileReader mock to control onload/onerror behavior in openBook() +class MockFileReader { + constructor() { + this.onload = null; + this.onerror = null; + } + readAsArrayBuffer(file) { + // If test toggled error path, trigger onerror; else trigger onload + if (file && file.__causeReadError) { + this.onerror && this.onerror({ target: { error: 'boom' } }); + } else { + const buf = new ArrayBuffer(8); + this.onload && this.onload({ target: { result: buf } }); + } + } +} + +describe('book module', () => { + let bookModule; + let ePub; + let mainMocks; + let libMocks; + + beforeEach(async () => { + jest.resetModules(); + setupDomSkeleton(); + global.FileReader = MockFileReader; + + // Re-require mocks to access instances + ePub = (await import('epubjs')).default; + mainMocks = await import('../../src/main'); + libMocks = await import('../../src/library'); + + // Import module under test after DOM/mocks are set + bookModule = await import('../../src/book.js'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('openBook', () => { + test('shows error for non-EPUB file and does not read', () => { + const file = new Blob(['not-epub'], { type: 'text/plain' }); + Object.defineProperty(file, 'name', { value: 'notes.txt' }); + const evt = { target: { files: [file] } }; + + bookModule.openBook(evt); + + expect(mainMocks.showError).toHaveBeenCalledWith('The selected file is not a valid EPUB file.'); + expect(mainMocks.showLoading).not.toHaveBeenCalled(); + }); + + test('no-op when no file is selected', () => { + const evt = { target: { files: [] } }; + bookModule.openBook(evt); + expect(mainMocks.showLoading).not.toHaveBeenCalled(); + expect(mainMocks.showError).not.toHaveBeenCalled(); + }); + + test('reads EPUB, calls load flow, and hides loading on success', async () => { + const file = new Blob([new Uint8Array([1, 2, 3])], { type: 'application/epub+zip' }); + Object.defineProperty(file, 'name', { value: 'book.epub' }); + const evt = { target: { files: [file] } }; + + bookModule.openBook(evt); + + // showLoading called before reading + expect(mainMocks.showLoading).toHaveBeenCalled(); + + // Allow microtasks to flush + await Promise.resolve(); + await new Promise(setImmediate); + + // hideLoading called after loadBook resolves + expect(mainMocks.hideLoading).toHaveBeenCalled(); + expect(mainMocks.showError).not.toHaveBeenCalled(); + }); + + test('handles FileReader error path', async () => { + const file = new Blob([new Uint8Array([9])], { type: 'application/epub+zip' }); + Object.defineProperty(file, 'name', { value: 'bad.epub' }); + // Signal our MockFileReader to emit an error + Object.defineProperty(file, '__causeReadError', { value: true }); + const evt = { target: { files: [file] } }; + + bookModule.openBook(evt); + + await Promise.resolve(); + + expect(mainMocks.hideLoading).toHaveBeenCalled(); + expect(mainMocks.showError).toHaveBeenCalledWith(expect.stringContaining('Error reading file:')); + }); + }); + + describe('openBookFromEntry', () => { + test('closes library, shows loading, loads and hides loading on success', async () => { + const fakeFile = new Blob([new Uint8Array([1, 2])], { type: 'application/epub+zip' }); + fakeFile.arrayBuffer = jest.fn(async () => new ArrayBuffer(4)); + const entry = { getFile: jest.fn(async () => fakeFile) }; + + await bookModule.openBookFromEntry(entry); + + expect(libMocks.toggleLibrary).toHaveBeenCalledWith(false); + expect(mainMocks.showLoading).toHaveBeenCalled(); + expect(entry.getFile).toHaveBeenCalled(); + expect(fakeFile.arrayBuffer).toHaveBeenCalled(); + expect(mainMocks.hideLoading).toHaveBeenCalled(); + expect(libMocks.toggleLibrary).not.toHaveBeenCalledWith(true); + expect(mainMocks.showError).not.toHaveBeenCalled(); + }); + + test('reopens library and shows error on failure', async () => { + const entry = { getFile: jest.fn(async () => { throw new Error('nope'); }) }; + + await bookModule.openBookFromEntry(entry); + + expect(libMocks.toggleLibrary).toHaveBeenCalledWith(false); + expect(libMocks.toggleLibrary).toHaveBeenCalledWith(true); + expect(mainMocks.showError).toHaveBeenCalledWith(expect.stringContaining('Error opening book:')); + expect(mainMocks.hideLoading).toHaveBeenCalled(); + }); + }); + + describe('navigation controls', () => { + test('prevPage and nextPage invoke rendition methods when initialized', async () => { + // Trigger a minimal load to set up rendition + const file = new Blob([new Uint8Array([1])], { type: 'application/epub+zip' }); + Object.defineProperty(file, 'name', { value: 'ok.epub' }); + bookModule.openBook({ target: { files: [file] } }); + await Promise.resolve(); + await new Promise(setImmediate); + + const { mockBook } = (await import('epubjs')).default.__mock; + const rendition = mockBook.renderTo.mock.results[0].value; + + bookModule.prevPage(); + bookModule.nextPage(); + + expect(rendition.prev).toHaveBeenCalled(); + expect(rendition.next).toHaveBeenCalled(); + }); + + test('prevPage and nextPage are no-ops without rendition', () => { + expect(() => bookModule.prevPage()).not.toThrow(); + expect(() => bookModule.nextPage()).not.toThrow(); + }); + }); + + describe('goToPage', () => { + test('no-op if no book or no locations', () => { + const viewer = document.getElementById('viewer'); + expect(viewer).toBeTruthy(); + // Without loading a book, should do nothing + expect(() => bookModule.goToPage()).not.toThrow(); + }); + + test('navigates to valid page index and ignores out-of-range/invalid inputs', async () => { + // Load book to initialize locations and rendition + const file = new Blob([new Uint8Array([1])], { type: 'application/epub+zip' }); + Object.defineProperty(file, 'name', { value: 'ok.epub' }); + bookModule.openBook({ target: { files: [file] } }); + await Promise.resolve(); + await new Promise(setImmediate); + + const { mockBook } = (await import('epubjs')).default.__mock; + const rendition = mockBook.renderTo.mock.results[0].value; + + // Valid page (1-based in input) + const input = document.getElementById('current-page'); + input.value = '4'; + bookModule.goToPage(); + expect(mockBook.locations.cfiFromLocation).toHaveBeenCalledWith(3); + expect(rendition.display).toHaveBeenCalledWith(expect.stringContaining('epubcfi(')); + + // Invalid: non-numeric + input.value = 'abc'; + rendition.display.mockClear(); + bookModule.goToPage(); + expect(rendition.display).not.toHaveBeenCalled(); + + // Out of range: 0 + input.value = '0'; + rendition.display.mockClear(); + bookModule.goToPage(); + expect(rendition.display).not.toHaveBeenCalled(); + + // Out of range: > length + input.value = '999'; + rendition.display.mockClear(); + bookModule.goToPage(); + expect(rendition.display).not.toHaveBeenCalled(); + }); + }); + + describe('TOC toggles', () => { + test('toggleToc toggles open class on container and overlay', () => { + const toc = document.getElementById('toc-container'); + const overlay = document.getElementById('overlay'); + expect(toc.classList.contains('open')).toBe(false); + expect(overlay.classList.contains('open')).toBe(false); + + bookModule.toggleToc(); + + expect(toc.classList.contains('open')).toBe(true); + expect(overlay.classList.contains('open')).toBe(true); + + bookModule.toggleToc(); + + expect(toc.classList.contains('open')).toBe(false); + expect(overlay.classList.contains('open')).toBe(false); + }); + + test('closeToc removes open class', () => { + const toc = document.getElementById('toc-container'); + const overlay = document.getElementById('overlay'); + toc.classList.add('open'); + overlay.classList.add('open'); + + bookModule.closeToc(); + + expect(toc.classList.contains('open')).toBe(false); + expect(overlay.classList.contains('open')).toBe(false); + }); + }); + + describe('load side-effects', () => { + test('enables navigation buttons and sets book title', async () => { + const file = new Blob([new Uint8Array([1])], { type: 'application/epub+zip' }); + Object.defineProperty(file, 'name', { value: 'ok.epub' }); + bookModule.openBook({ target: { files: [file] } }); + await Promise.resolve(); + await new Promise(setImmediate); + + const prev = document.getElementById('prev-button'); + const next = document.getElementById('next-button'); + const tocBtn = document.getElementById('toc-button'); + const title = document.getElementById('book-title'); + const totalPages = document.getElementById('total-pages'); + + expect(prev.disabled).toBe(false); + expect(next.disabled).toBe(false); + expect(tocBtn.disabled).toBe(false); + expect(title.textContent).toBe('Test Title'); + expect(totalPages.textContent).toBe('42'); + }); + + test('falls back to default titles on metadata errors', async () => { + // Reconfigure epubjs mock to reject metadata + jest.resetModules(); + setupDomSkeleton(); + global.FileReader = MockFileReader; + + jest.doMock('epubjs', () => { + const mockLocations = { + generate: jest.fn(() => Promise.resolve()), + length: jest.fn(() => 1), + cfiFromLocation: jest.fn((loc) => `epubcfi(/6/${loc})`), + locationFromCfi: jest.fn(() => 0), + }; + const mockBook = { + ready: Promise.resolve(), + renderTo: jest.fn(() => ({ + display: jest.fn(() => Promise.resolve()), + on: jest.fn(), + prev: jest.fn(), + next: jest.fn(), + })), + loaded: { metadata: Promise.reject(new Error('meta fail')) }, + locations: mockLocations, + navigation: { toc: Promise.resolve([]) }, + }; + const ePub = jest.fn(() => mockBook); + ePub.__mock = { mockBook, mockLocations }; + return { __esModule: true, default: ePub }; + }); + + const mainMocks2 = await import('../../src/main'); + await import('../../src/library'); + const mod = await import('../../src/book.js'); + + const file = new Blob([new Uint8Array([1])], { type: 'application/epub+zip' }); + Object.defineProperty(file, 'name', { value: 'ok.epub' }); + + mod.openBook({ target: { files: [file] } }); + await Promise.resolve(); + await new Promise(setImmediate); + + const title = document.getElementById('book-title'); + expect(title.textContent).toBe('EPUB Book'); + expect(mainMocks2.showLoading).toHaveBeenCalled(); + expect(mainMocks2.hideLoading).toHaveBeenCalled(); + }); + }); + + describe('TOC generation click behavior', () => { + test('clicking a TOC item displays the href and closes overlay', async () => { + // Load to trigger generateToc + const file = new Blob([new Uint8Array([1])], { type: 'application/epub+zip' }); + Object.defineProperty(file, 'name', { value: 'ok.epub' }); + bookModule.openBook({ target: { files: [file] } }); + await Promise.resolve(); + await new Promise(setImmediate); + + const { mockBook } = (await import('epubjs')).default.__mock; + const rendition = mockBook.renderTo.mock.results[0].value; + + const tocContent = document.getElementById('toc-content'); + expect(tocContent.children.length).toBeGreaterThanOrEqual(1); + + // Open overlay then click item to ensure closeToc is called + const tocContainer = document.getElementById('toc-container'); + const overlay = document.getElementById('overlay'); + tocContainer.classList.add('open'); + overlay.classList.add('open'); + + const first = tocContent.children[0]; + first.dispatchEvent(new Event('click', { bubbles: true })); + + expect(rendition.display).toHaveBeenCalledWith('ch1.xhtml'); + expect(tocContainer.classList.contains('open')).toBe(false); + expect(overlay.classList.contains('open')).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/indexedDB.test.js b/tests/unit/indexedDB.test.js new file mode 100644 index 0000000..1755f9a --- /dev/null +++ b/tests/unit/indexedDB.test.js @@ -0,0 +1,291 @@ +/** + * Tests for IndexedDB helpers: + * - storeLibraryHandle(handle) + * - getStoredLibraryHandle() + * + * Framework: Jest (describe/test/expect patterns). If your project uses Vitest or Mocha, the syntax is largely compatible, + * but update imports/globals accordingly. + * + * We provide a minimal IndexedDB mock sufficient for tested operations: + * - indexedDB.open(name, version) triggers onupgradeneeded once per db name/version and then onsuccess. + * - db.createObjectStore("handles", { keyPath: "name" }) + * - db.transaction(storeName, mode).objectStore(storeName).put({ name, handle }) / get(key) + * returning IDBRequest-like objects with onsuccess/onerror callbacks. + * + * The mock stores data per-db per-store in memory. All operations resolve asynchronously (queueMicrotask). + */ + +let moduleUnderTest; + +// Minimal IDB mock +class IDBRequestMock { + constructor(executor) { + this.onsuccess = null; + this.onerror = null; + // executor must call resolve(result) or reject(error) + executor( + (result) => queueMicrotask(() => this.onsuccess && this.onsuccess({ target: { result, source: undefined } })), + (error) => queueMicrotask(() => this.onerror && this.onerror({ target: { error } })) + ); + } +} + +class ObjectStoreMock { + constructor(storeName, storage, keyPath = "name") { + this._storeName = storeName; + this._storage = storage; // Map + this._keyPath = keyPath; + } + put(value) { + return new IDBRequestMock((resolve, reject) => { + try { + const key = value?.[this._keyPath]; + if (key === undefined) throw new Error("KeyPath missing"); + this._storage.set(String(key), value); + resolve(undefined); + } catch (e) { + reject(e); + } + }); + } + get(key) { + return new IDBRequestMock((resolve, reject) => { + try { + resolve(this._storage.has(String(key)) ? this._storage.get(String(key)) : undefined); + } catch (e) { + reject(e); + } + }); + } +} + +class TransactionMock { + constructor(storeName, mode, dbState) { + if (!dbState.objectStores.has(storeName)) throw new Error("NotFoundError"); + this._storeName = storeName; + this._mode = mode; + this._dbState = dbState; + } + objectStore(name) { + if (name !== this._storeName) throw new Error("NotFoundError"); + const storeState = this._dbState.objectStores.get(name); + return new ObjectStoreMock(name, storeState.storage, storeState.keyPath); + } +} + +class DBMock { + constructor(dbState) { + this._state = dbState; + } + createObjectStore(name, options = {}) { + if (!this._state.objectStores.has(name)) { + this._state.objectStores.set(name, { + keyPath: options.keyPath || "id", + storage: new Map() + }); + } + return true; + } + transaction(storeName, mode) { + return new TransactionMock(storeName, mode, this._state); + } +} + +const __idbDatabases = new Map(); // key: name@version -> { objectStores: Map } + +global.indexedDB = { + open(name, version) { + const request = new (class { + constructor() { + this.onupgradeneeded = null; + this.onsuccess = null; + this.onerror = null; + // emulate async open + queueMicrotask(() => { + try { + const key = `${name}@${version || 1}`; + let dbState = __idbDatabases.get(key); + const isNew = !dbState; + if (!dbState) { + dbState = { objectStores: new Map() }; + __idbDatabases.set(key, dbState); + } + const db = new DBMock(dbState); + if (isNew && this.onupgradeneeded) { + this.onupgradeneeded({ target: { result: db } }); + } + this.onsuccess && this.onsuccess({ target: { result: db } }); + } catch (e) { + this.onerror && this.onerror({ target: { error: e } }); + } + }); + } + })(); + return request; + } +}; + +describe("IndexedDB helpers (storeLibraryHandle/getStoredLibraryHandle)", () => { + // Dynamically import the module under test from its known path. + // Try common paths; adjust if your file lives elsewhere. + beforeAll(async () => { + // Attempt multiple likely module paths in priority order. + const candidates = [ + "src/indexedDB.js", + "src/utils/indexedDB.js", + "src/lib/indexedDB.js", + "indexedDB.js", + ]; + let lastErr; + for (const p of candidates) { + try { + // eslint-disable-next-line no-await-in-loop + moduleUnderTest = await import(require("path").isAbsolute(p) ? p : ("../".repeat(2) + p)); + break; + } catch (e) { + lastErr = e; + } + } + if (!moduleUnderTest) { + // Fallback to the file colocated under tests/unit if repository places code there (for the challenge context). + try { + moduleUnderTest = await import("./indexedDB.js"); + } catch (_) { + throw lastErr || new Error("Failed to locate module exporting storeLibraryHandle/getStoredLibraryHandle"); + } + } + }); + + beforeEach(() => { + // Clear database state for isolation between tests + __idbDatabases.clear(); + }); + + test("creates database and object store on first open (implicit via successful store)", async () => { + const { storeLibraryHandle, getStoredLibraryHandle } = moduleUnderTest; + const handle = { type: "fs", id: "abc" }; + await expect(storeLibraryHandle(handle)).resolves.toBeUndefined(); + await expect(getStoredLibraryHandle()).resolves.toEqual(handle); + }); + + test("overwrites existing 'library' handle on subsequent stores", async () => { + const { storeLibraryHandle, getStoredLibraryHandle } = moduleUnderTest; + await storeLibraryHandle({ type: "fs", id: "one" }); + await storeLibraryHandle({ type: "fs", id: "two" }); + await expect(getStoredLibraryHandle()).resolves.toEqual({ type: "fs", id: "two" }); + }); + + test("returns null when no 'library' entry exists", async () => { + const { getStoredLibraryHandle } = moduleUnderTest; + await expect(getStoredLibraryHandle()).resolves.toBeNull(); + }); + + test("handles arbitrary serializable handle objects", async () => { + const { storeLibraryHandle, getStoredLibraryHandle } = moduleUnderTest; + const complex = { nested: { arr: [1, { x: true }], date: new Date(0).toISOString() }, n: 42 }; + await storeLibraryHandle(complex); + await expect(getStoredLibraryHandle()).resolves.toEqual(complex); + }); + + test("propagates put error when keyPath is missing (simulated by tampering with store API)", async () => { + const { storeLibraryHandle } = moduleUnderTest; + + // Monkey-patch the objectStore to remove name field before put to simulate keyPath error. + const originalOpen = global.indexedDB.open; + global.indexedDB.open = function(name, version) { + const req = originalOpen(name, version); + const origOnSuccessSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(req), "onsuccess")?.set; + // Instead, wrap by overriding after creation; simpler approach: intercept onsuccess event: + const origThen = req.onsuccess; + req.onsuccess = null; + queueMicrotask(() => { + // After db creation, replace transaction.objectStore to a faulty one + const oldHandler = req.onsuccess; + req.onsuccess = (e) => { + const db = e.target.result; + const origTx = db.transaction.bind(db); + db.transaction = (storeName, mode) => { + const tx = origTx(storeName, mode); + const origOS = tx.objectStore.bind(tx); + tx.objectStore = (name) => { + const os = origOS(name); + const origPut = os.put.bind(os); + os.put = (val) => origPut({ ...val, name: undefined }); // remove keyPath to trigger error + return os; + }; + return tx; + }; + oldHandler && oldHandler(e); + }; + }); + return req; + }; + + await expect(storeLibraryHandle({ any: "thing", name: "will-be-removed" })).rejects.toBeInstanceOf(Error); + + // restore + global.indexedDB.open = originalOpen; + }); + + test("propagates get error (simulated by throwing inside get)", async () => { + const { storeLibraryHandle, getStoredLibraryHandle } = moduleUnderTest; + + await storeLibraryHandle({ name: "library", ok: true }); + + // Patch get to throw + const originalOpen = global.indexedDB.open; + global.indexedDB.open = function(name, version) { + const req = originalOpen(name, version); + const origThen = req.onsuccess; + req.onsuccess = null; + queueMicrotask(() => { + const prev = req.onsuccess; + req.onsuccess = (e) => { + const db = e.target.result; + const origTx = db.transaction.bind(db); + db.transaction = (storeName, mode) => { + const tx = origTx(storeName, mode); + const origOS = tx.objectStore.bind(tx); + tx.objectStore = (name) => { + const os = origOS(name); + os.get = () => new (class { + constructor() { + this.onsuccess = null; + this.onerror = null; + queueMicrotask(() => { + this.onerror && this.onerror({ target: { error: new Error("boom") } }); + }); + } + })(); + return os; + }; + return tx; + }; + prev && prev(e); + }; + }); + return req; + }; + + await expect(getStoredLibraryHandle()).rejects.toThrow("boom"); + + global.indexedDB.open = originalOpen; + }); + + test("rejects when opening DB fails", async () => { + const { storeLibraryHandle } = moduleUnderTest; + const originalOpen = global.indexedDB.open; + global.indexedDB.open = function() { + // Return a request that errors immediately + const r = { + onupgradeneeded: null, + onsuccess: null, + onerror: null + }; + queueMicrotask(() => r.onerror && r.onerror({ target: { error: new Error("open failed") } })); + return r; + }; + await expect(storeLibraryHandle({ x: 1 })).rejects.toThrow("open failed"); + global.indexedDB.open = originalOpen; + }); +}); \ No newline at end of file diff --git a/tests/unit/library.test.js b/tests/unit/library.test.js new file mode 100644 index 0000000..64e22ca --- /dev/null +++ b/tests/unit/library.test.js @@ -0,0 +1,297 @@ +/** @jest-environment jsdom */ +/** + * Unit tests for the EPUB library module. + * Testing framework: Jest + JSDOM. + * + * If using Vitest, replace jest.* with vi.* and keep the structure identical. + * These tests focus on exported public functions: openLibrary, handleLibraryFiles, toggleLibrary. + * Internal helpers (displayLibraryGrid, createLibraryItem) are covered indirectly via DOM effects. + */ + +const LIB_PATH = '../../src/library'; // Will be auto-adjusted by the script below if needed + +// Mock sibling modules used by the library (paths will be auto-adjusted by script if needed) +jest.mock('../../src/indexedDB', () => ({ + storeLibraryHandle: jest.fn(), + getStoredLibraryHandle: jest.fn(), +})); +jest.mock('../../src/book', () => ({ + openBookFromEntry: jest.fn(), +})); +jest.mock('../../src/main', () => ({ + showError: jest.fn(), +})); + +// Mock epubjs default export +jest.mock('epubjs', () => { + return jest.fn().mockImplementation(() => ({ + coverUrl: jest.fn().mockResolvedValue('blob:cover-url'), + loaded: { + metadata: Promise.resolve({ title: 'Mock EPUB Title' }), + }, + })); +}); + +function setupDOM() { + document.body.innerHTML = ` + + + + + `; +} + +function getEpub() { + const mod = require('epubjs'); + return mod.default || mod; +} + +function getMockedDeps() { + const idb = require('../../src/indexedDB'); + const book = require('../../src/book'); + const main = require('../../src/main'); + return { + storeLibraryHandle: idb.storeLibraryHandle, + getStoredLibraryHandle: idb.getStoredLibraryHandle, + openBookFromEntry: book.openBookFromEntry, + showError: main.showError, + }; +} + +function makeFileLike(name, content = 'dummy', type = 'application/epub+zip') { + return { + name, + type, + arrayBuffer: async () => new TextEncoder().encode(content).buffer, + }; +} + +function makeFSFileHandle(name, fileObj) { + return { + kind: 'file', + name, + getFile: async () => fileObj, + }; +} + +async function loadLibraryModule() { + jest.resetModules(); // ensure module reads the fresh DOM at import time + setupDOM(); + return await import(LIB_PATH); +} + +describe('library module', () => { + beforeEach(() => { + jest.clearAllMocks(); + // DOM is set in loadLibraryModule before import + }); + + describe('toggleLibrary', () => { + test('forces open when forceOpen === true', async () => { + const LibraryModule = await loadLibraryModule(); + LibraryModule.toggleLibrary(true); + expect(document.getElementById('library-container').classList.contains('open')).toBe(true); + expect(document.getElementById('overlay').classList.contains('open')).toBe(true); + }); + + test('forces closed when forceOpen === false', async () => { + const LibraryModule = await loadLibraryModule(); + // open first + LibraryModule.toggleLibrary(true); + LibraryModule.toggleLibrary(false); + expect(document.getElementById('library-container').classList.contains('open')).toBe(false); + expect(document.getElementById('overlay').classList.contains('open')).toBe(false); + }); + + test('toggles when forceOpen is undefined', async () => { + const LibraryModule = await loadLibraryModule(); + const container = document.getElementById('library-container'); + const overlay = document.getElementById('overlay'); + + expect(container.classList.contains('open')).toBe(false); + expect(overlay.classList.contains('open')).toBe(false); + + LibraryModule.toggleLibrary(); + expect(container.classList.contains('open')).toBe(true); + expect(overlay.classList.contains('open')).toBe(true); + + LibraryModule.toggleLibrary(); + expect(container.classList.contains('open')).toBe(false); + expect(overlay.classList.contains('open')).toBe(false); + }); + }); + + describe('handleLibraryFiles', () => { + test('renders selected EPUB files and opens library', async () => { + const LibraryModule = await loadLibraryModule(); + const { openBookFromEntry } = getMockedDeps(); + + const file1 = makeFileLike('book1.epub'); + const file2 = makeFileLike('book2.epub'); + const event = { target: { files: [file1, file2] } }; + + await LibraryModule.handleLibraryFiles(event); + + const container = document.getElementById('library-container'); + const overlay = document.getElementById('overlay'); + const grid = document.getElementById('library-content'); + + expect(container.classList.contains('open')).toBe(true); + expect(overlay.classList.contains('open')).toBe(true); + expect(grid.children.length).toBe(2); + + // clicking opens the book + grid.children[0].dispatchEvent(new window.Event('click')); + expect(openBookFromEntry).toHaveBeenCalledWith(file1); + }); + + test('shows "No EPUB files found." when given empty selection', async () => { + const LibraryModule = await loadLibraryModule(); + const event = { target: { files: [] } }; + + await LibraryModule.handleLibraryFiles(event); + + const grid = document.getElementById('library-content'); + expect(grid.textContent).toContain('No EPUB files found.'); + expect(grid.children.length).toBe(1); + // Library should still open + expect(document.getElementById('library-container').classList.contains('open')).toBe(true); + expect(document.getElementById('overlay').classList.contains('open')).toBe(true); + }); + }); + + describe('openLibrary', () => { + test('uses stored directory handle when available; renders only .epub files', async () => { + const LibraryModule = await loadLibraryModule(); + const { getStoredLibraryHandle } = getMockedDeps(); + const ePub = getEpub(); + + const file1 = makeFileLike('keep.epub'); + const file2 = makeFileLike('skip.txt'); + const file3 = makeFileLike('another.epub'); + + const handleKeep = makeFSFileHandle('keep.epub', file1); + const handleSkip = { kind: 'file', name: 'skip.txt' }; + const handleAnother = makeFSFileHandle('another.epub', file3); + + const dirHandle = { + async *values() { + yield handleKeep; + yield handleSkip; + yield handleAnother; + }, + }; + + getStoredLibraryHandle.mockResolvedValue(dirHandle); + + await LibraryModule.openLibrary(); + + const grid = document.getElementById('library-content'); + expect(grid.children.length).toBe(2); + expect(ePub).toHaveBeenCalledTimes(2); + const titles = Array.from(grid.querySelectorAll('.library-title')).map(n => n.textContent); + expect(titles).toEqual(['Mock EPUB Title', 'Mock EPUB Title']); + }); + + test('prompts for directory and stores handle when none was previously stored', async () => { + const LibraryModule = await loadLibraryModule(); + const { getStoredLibraryHandle, storeLibraryHandle } = getMockedDeps(); + + const file1 = makeFileLike('fresh.epub'); + const handleFresh = makeFSFileHandle('fresh.epub', file1); + + const dirHandle = { + async *values() { + yield handleFresh; + }, + }; + + getStoredLibraryHandle.mockResolvedValue(null); + window.showDirectoryPicker = jest.fn().mockResolvedValue(dirHandle); + + await LibraryModule.openLibrary(); + + expect(window.showDirectoryPicker).toHaveBeenCalled(); + expect(storeLibraryHandle).toHaveBeenCalledWith(dirHandle); + + const grid = document.getElementById('library-content'); + expect(grid.children.length).toBe(1); + }); + + test('handles errors gracefully and reports via showError without throwing', async () => { + const LibraryModule = await loadLibraryModule(); + const { getStoredLibraryHandle, showError } = getMockedDeps(); + + getStoredLibraryHandle.mockRejectedValue(new Error('boom')); + + await expect(LibraryModule.openLibrary()).resolves.toBeUndefined(); + expect(showError).toHaveBeenCalledWith(expect.stringContaining('Failed to open library: boom')); + }); + + test('continues rendering even if cover/metadata fetch fails for a file', async () => { + const LibraryModule = await loadLibraryModule(); + const { getStoredLibraryHandle } = getMockedDeps(); + const ePub = getEpub(); + + const badFile = makeFileLike('bad.epub'); + const goodFile = makeFileLike('good.epub'); + + const badHandle = makeFSFileHandle('bad.epub', badFile); + const goodHandle = makeFSFileHandle('good.epub', goodFile); + + // First ePub call: simulate failures; second: success + ePub.mockImplementationOnce(() => ({ + coverUrl: jest.fn().mockRejectedValue(new Error('cover fail')), + loaded: { metadata: Promise.reject(new Error('meta fail')) }, + })).mockImplementationOnce(() => ({ + coverUrl: jest.fn().mockResolvedValue('blob:ok'), + loaded: { metadata: Promise.resolve({ title: 'OK Title' }) }, + })); + + const dirHandle = { + async *values() { + yield badHandle; + yield goodHandle; + }, + }; + + getStoredLibraryHandle.mockResolvedValue(dirHandle); + + await LibraryModule.openLibrary(); + + const grid = document.getElementById('library-content'); + expect(grid.children.length).toBe(2); + const items = Array.from(grid.children); + // First item falls back to file name because metadata failed + expect(items[0].querySelector('.library-title').textContent).toBe('bad.epub'); + // Second item uses metadata title + expect(items[1].querySelector('.library-title').textContent).toBe('OK Title'); + // Image may remain empty on failure; ensure element exists (no crash) + expect(items[0].querySelector('.library-cover')).not.toBeNull(); + }); + }); + + describe('interaction', () => { + test('clicking a rendered item calls openBookFromEntry with its entry', async () => { + const LibraryModule = await loadLibraryModule(); + const { getStoredLibraryHandle, openBookFromEntry } = getMockedDeps(); + + const file = makeFileLike('clickable.epub'); + const handle = makeFSFileHandle('clickable.epub', file); + const dirHandle = { + async *values() { + yield handle; + }, + }; + getStoredLibraryHandle.mockResolvedValue(dirHandle); + + await LibraryModule.openLibrary(); + + const grid = document.getElementById('library-content'); + const item = grid.querySelector('.library-item'); + item.dispatchEvent(new window.Event('click')); + + expect(openBookFromEntry).toHaveBeenCalledWith(handle); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/main.test.js b/tests/unit/main.test.js new file mode 100644 index 0000000..4512efb --- /dev/null +++ b/tests/unit/main.test.js @@ -0,0 +1,273 @@ +/** + * Unit tests for main UI wiring and helpers. + * + * Framework: Jest (jsdom environment) + * These tests seed a minimal DOM, mock external modules, and then dynamically import the module-under-test + * so that event listeners attach to the seeded elements. + */ + +const resetDom = () => { + document.body.innerHTML = ` + + + + + + + + + + + + +