diff --git a/tests/unit/book.test.js b/tests/unit/book.test.js new file mode 100644 index 0000000..d599a31 --- /dev/null +++ b/tests/unit/book.test.js @@ -0,0 +1,520 @@ +/** + * Tests for book module behaviors. + * + * Note on framework: These tests are written for Jest running in a JSDOM environment, + * which is typical for frontend DOM-based testing. If this repository uses Vitest, + * these tests should also work with minimal changes (replace jest.fn with vi.fn and + * adjust mocking calls). We purposefully avoid non-portable APIs. + */ + +const ORIGINAL_ENV = { ...process.env }; + +// Helper to build DOM skeleton before importing the module (since the module queries DOM at import time) +function buildDom() { + document.body.innerHTML = ` +
+ + + + + + +
+
+
+
+
+ `; +} + +function mockEpubAndRendition({ readyResolve = true, navigationToc = [], locationsLen = 5, metadata = { title: "Sample Book" } } = {}) { + const listeners = {}; + const mockRendition = { + display: jest.fn().mockResolvedValue(true), + prev: jest.fn(), + next: jest.fn(), + on: jest.fn((evt, cb) => { + listeners[evt] = cb; + }), + __emit: (evt, payload) => { + if (listeners[evt]) listeners[evt](payload); + }, + }; + + const mockBook = { + ready: readyResolve ? Promise.resolve() : Promise.reject(new Error("not ready")), + renderTo: jest.fn(() => mockRendition), + loaded: { + metadata: Promise.resolve(metadata), + }, + navigation: { + toc: Promise.resolve(navigationToc), + }, + locations: { + generate: jest.fn().mockResolvedValue(true), + length: jest.fn(() => locationsLen), + locationFromCfi: jest.fn(() => 0), + cfiFromLocation: jest.fn(idx => `cfi-${idx}`), + }, + }; + + const epubFactory = jest.fn(() => mockBook); + + // Mock epubjs default export + jest.doMock("epubjs", () => ({ + __esModule: true, + default: epubFactory, + })); + + return { epubFactory, mockBook, mockRendition }; +} + +function mockMainModule() { + const showLoading = jest.fn(); + const hideLoading = jest.fn(); + const showError = jest.fn(); + jest.doMock("./main", () => ({ + __esModule: true, + showLoading, + hideLoading, + showError, + })); + return { showLoading, hideLoading, showError }; +} + +function mockLibraryModule() { + const toggleLibrary = jest.fn(); + jest.doMock("./library", () => ({ + __esModule: true, + toggleLibrary, + })); + return { toggleLibrary }; +} + +describe("book module", () => { + let cleanup; + + beforeEach(() => { + jest.resetModules(); + jest.useFakeTimers(); + buildDom(); + cleanup = () => { + document.body.innerHTML = ""; + jest.clearAllMocks(); + jest.useRealTimers(); + process.env = { ...ORIGINAL_ENV }; + }; + }); + + afterEach(() => { + cleanup(); + }); + + test("toggleToc toggles classes on tocContainer and overlay", async () => { + mockEpubAndRendition(); // not required for toggle but harmless + mockMainModule(); + mockLibraryModule(); + const mod = await import(getBookModulePath()); + const tocContainer = document.getElementById("toc-container"); + const overlay = document.getElementById("overlay"); + expect(tocContainer.classList.contains("open")).toBe(false); + expect(overlay.classList.contains("open")).toBe(false); + + mod.toggleToc(); + + expect(tocContainer.classList.contains("open")).toBe(true); + expect(overlay.classList.contains("open")).toBe(true); + }); + + test("closeToc removes open classes", async () => { + mockEpubAndRendition(); + mockMainModule(); + mockLibraryModule(); + const mod = await import(getBookModulePath()); + const tocContainer = document.getElementById("toc-container"); + const overlay = document.getElementById("overlay"); + + tocContainer.classList.add("open"); + overlay.classList.add("open"); + + mod.closeToc(); + expect(tocContainer.classList.contains("open")).toBe(false); + expect(overlay.classList.contains("open")).toBe(false); + }); + + test("openBook: rejects non-epub files and shows error without calling FileReader", async () => { + const { showError, showLoading, hideLoading } = mockMainModule(); + mockLibraryModule(); + mockEpubAndRendition(); + + const mod = await import(getBookModulePath()); + + const file = new File(["content"], "image.png", { type: "image/png" }); + const input = { target: { files: [file] } }; + + // Spy on FileReader + const fileReaderSpy = jest.spyOn(global, "FileReader"); + + mod.openBook(input); + + expect(showError).toHaveBeenCalledWith("The selected file is not a valid EPUB file."); + expect(showLoading).not.toHaveBeenCalled(); + expect(hideLoading).not.toHaveBeenCalled(); + expect(fileReaderSpy).not.toHaveBeenCalled(); + }); + + test("openBook: loads epub, hides loading on success, updates title and enables controls", async () => { + const { showLoading, hideLoading, showError } = mockMainModule(); + mockLibraryModule(); + const { epubFactory, mockBook } = mockEpubAndRendition({ + navigationToc: [], + locationsLen: 10, + metadata: { title: "My EPUB" }, + }); + + const mod = await import(getBookModulePath()); + + // Mock FileReader behavior + const readers = []; + jest.spyOn(global, "FileReader").mockImplementation(function () { + const inst = { + onload: null, + onerror: null, + readAsArrayBuffer: function () { + // simulate async load + setTimeout(() => { + inst.onload && inst.onload({ target: { result: new ArrayBuffer(8) } }); + }, 0); + }, + }; + readers.push(inst); + return inst; + }); + + const file = new File(["content"], "book.epub", { type: "application/epub+zip" }); + const input = { target: { files: [file] } }; + + mod.openBook(input); + + // Allow timers and microtasks to run + await Promise.resolve(); + jest.runAllTimers(); + + // Await internal async chain completion + await mockBook.ready; + await Promise.resolve(); + + expect(showLoading).toHaveBeenCalled(); + expect(hideLoading).toHaveBeenCalled(); + expect(showError).not.toHaveBeenCalled(); + expect(epubFactory).toHaveBeenCalled(); + + // Controls become enabled after loadBook completes + expect(document.getElementById("prev-button").disabled).toBe(false); + expect(document.getElementById("next-button").disabled).toBe(false); + expect(document.getElementById("toc-button").disabled).toBe(false); + + // Title reflects metadata.title + expect(document.getElementById("book-title").textContent).toBe("My EPUB"); + + // Total pages reflect locations length + expect(document.getElementById("total-pages").textContent).toBe("10"); + }); + + test("openBook: when file reader errors, hides loading and shows error", async () => { + const { showLoading, hideLoading, showError } = mockMainModule(); + mockLibraryModule(); + mockEpubAndRendition(); + + const mod = await import(getBookModulePath()); + + jest.spyOn(global, "FileReader").mockImplementation(function () { + const inst = { + onload: null, + onerror: null, + readAsArrayBuffer: function () { + setTimeout(() => { + inst.onerror && inst.onerror({ target: { error: "bad file" } }); + }, 0); + }, + }; + return inst; + }); + + const file = new File(["content"], "bad.epub", { type: "application/epub+zip" }); + const input = { target: { files: [file] } }; + + mod.openBook(input); + + await Promise.resolve(); + jest.runAllTimers(); + + expect(showLoading).toHaveBeenCalled(); + expect(hideLoading).toHaveBeenCalled(); + expect(showError).toHaveBeenCalledWith("Error reading file: bad file"); + }); + + test("openBookFromEntry: success path loads book and keeps library closed", async () => { + const { showLoading, hideLoading, showError } = mockMainModule(); + const { toggleLibrary } = mockLibraryModule(); + const { mockBook } = mockEpubAndRendition(); + + const mod = await import(getBookModulePath()); + + const arrayBuffer = new ArrayBuffer(12); + const fakeFile = { + arrayBuffer: jest.fn().mockResolvedValue(arrayBuffer), + }; + const entry = { + getFile: jest.fn().mockResolvedValue(fakeFile), + }; + + await mod.openBookFromEntry(entry); + + expect(toggleLibrary).toHaveBeenCalledWith(false); + expect(showLoading).toHaveBeenCalled(); + expect(hideLoading).toHaveBeenCalled(); + expect(showError).not.toHaveBeenCalled(); + await mockBook.ready; + }); + + test("openBookFromEntry: on error, reopens library and shows error", async () => { + const { showLoading, hideLoading, showError } = mockMainModule(); + const { toggleLibrary } = mockLibraryModule(); + mockEpubAndRendition(); + + const mod = await import(getBookModulePath()); + + const entry = { + getFile: jest.fn().mockRejectedValue(new Error("no access")), + }; + + await mod.openBookFromEntry(entry); + + expect(toggleLibrary).toHaveBeenCalledWith(false); // closed immediately + expect(toggleLibrary).toHaveBeenCalledWith(true); // reopened on error + expect(showLoading).toHaveBeenCalled(); + expect(hideLoading).toHaveBeenCalled(); + expect(showError).toHaveBeenCalledWith(expect.stringContaining("Error opening book: no access")); + }); + + test("goToPage: does nothing when no book or locations", async () => { + mockMainModule(); + mockLibraryModule(); + mockEpubAndRendition(); + + const mod = await import(getBookModulePath()); + + // Without opening book, there is no locations + const renditionDisplaySpy = jest.fn(); + // try goToPage + mod.goToPage(); + expect(renditionDisplaySpy).not.toHaveBeenCalled(); + }); + + test("goToPage: displays CFI for valid page within range", async () => { + mockMainModule(); + mockLibraryModule(); + const { mockBook, mockRendition } = mockEpubAndRendition({ locationsLen: 3 }); + + const mod = await import(getBookModulePath()); + + // Simulate opening by calling internal openBook through openBook API path using FileReader + // Instead, call the private flow via public openBook by faking FileReader for brevity: + jest.spyOn(global, "FileReader").mockImplementation(function () { + const inst = { + onload: null, + onerror: null, + readAsArrayBuffer: function () { + setTimeout(() => inst.onload && inst.onload({ target: { result: new ArrayBuffer(1) } }), 0); + }, + }; + return inst; + }); + const file = new File(["x"], "b.epub", { type: "application/epub+zip" }); + mod.openBook({ target: { files: [file] } }); + + await Promise.resolve(); + jest.runAllTimers(); + await mockBook.ready; + + const currentInput = document.getElementById("current-page"); + currentInput.value = "2"; // 1-based index; will translate to location 1 + mod.goToPage(); + + expect(mockBook.locations.cfiFromLocation).toHaveBeenCalledWith(1); + expect(mockRendition.display).toHaveBeenCalledWith("cfi-1"); + }); + + test("keyboard events call prev/next when book is loaded", async () => { + mockMainModule(); + mockLibraryModule(); + const { mockBook, mockRendition } = mockEpubAndRendition(); + + const mod = await import(getBookModulePath()); + + // Load a book quickly + jest.spyOn(global, "FileReader").mockImplementation(function () { + const inst = { onload: null, onerror: null, readAsArrayBuffer() { setTimeout(() => inst.onload && inst.onload({ target: { result: new ArrayBuffer(1) } }), 0);} }; + return inst; + }); + const file = new File(["x"], "b.epub", { type: "application/epub+zip" }); + mod.openBook({ target: { files: [file] } }); + await Promise.resolve(); + jest.runAllTimers(); + await mockBook.ready; + + // Dispatch keyup events + window.dispatchEvent(new KeyboardEvent("keyup", { key: "ArrowLeft" })); + window.dispatchEvent(new KeyboardEvent("keyup", { key: "ArrowRight" })); + + expect(mockRendition.prev).toHaveBeenCalled(); + expect(mockRendition.next).toHaveBeenCalled(); + }); + + test("title fallbacks: Untitled EPUB when metadata.title missing; EPUB Book when metadata throws", async () => { + const { showLoading, hideLoading } = mockMainModule(); + mockLibraryModule(); + + // Case 1: metadata without title + let ctx = mockEpubAndRendition({ metadata: {} }); + let mod = await import(getBookModulePath()); + jest.spyOn(global, "FileReader").mockImplementation(function () { + const inst = { onload: null, onerror: null, readAsArrayBuffer() { setTimeout(() => inst.onload && inst.onload({ target: { result: new ArrayBuffer(1) } }), 0);} }; + return inst; + }); + let file = new File(["x"], "a.epub", { type: "application/epub+zip" }); + mod.openBook({ target: { files: [file] } }); + await Promise.resolve(); + jest.runAllTimers(); + await ctx.mockBook.ready; + + expect(document.getElementById("book-title").textContent).toBe("Untitled EPUB"); + + // Reset and test metadata rejection path + jest.resetModules(); + buildDom(); + mockMainModule(); + mockLibraryModule(); + ctx = mockEpubAndRendition(); + // Make metadata throw + ctx.mockBook.loaded.metadata = Promise.reject(new Error("meta fail")); + mod = await import(getBookModulePath()); + jest.spyOn(global, "FileReader").mockImplementation(function () { + const inst = { onload: null, onerror: null, readAsArrayBuffer() { setTimeout(() => inst.onload && inst.onload({ target: { result: new ArrayBuffer(1) } }), 0);} }; + return inst; + }); + file = new File(["x"], "b.epub", { type: "application/epub+zip" }); + mod.openBook({ target: { files: [file] } }); + await Promise.resolve(); + jest.runAllTimers(); + await ctx.mockBook.ready; + + expect(document.getElementById("book-title").textContent).toBe("EPUB Book"); + + // Ensure loading shown/hidden flow still occurs + expect(showLoading).toHaveBeenCalled(); + expect(hideLoading).toHaveBeenCalled(); + }); + + test("TOC generation creates clickable items that display chapters and close TOC", async () => { + mockMainModule(); + mockLibraryModule(); + const toc = [ + { label: "Chapter 1", href: "ch1.xhtml" }, + { label: "Chapter 2", href: "ch2.xhtml" }, + ]; + const { mockBook, mockRendition } = mockEpubAndRendition({ navigationToc: toc }); + + const mod = await import(getBookModulePath()); + + // Load + jest.spyOn(global, "FileReader").mockImplementation(function () { + const inst = { onload: null, onerror: null, readAsArrayBuffer() { setTimeout(() => inst.onload && inst.onload({ target: { result: new ArrayBuffer(1) } }), 0);} }; + return inst; + }); + const file = new File(["x"], "book.epub", { type: "application/epub+zip" }); + mod.openBook({ target: { files: [file] } }); + await Promise.resolve(); + jest.runAllTimers(); + await mockBook.ready; + + const items = Array.from(document.querySelectorAll("#toc-content .toc-item")); + expect(items.map(n => n.textContent)).toEqual(["Chapter 1", "Chapter 2"]); + + // Click Chapter 2 + const tocContainer = document.getElementById("toc-container"); + const overlay = document.getElementById("overlay"); + tocContainer.classList.add("open"); + overlay.classList.add("open"); + + items[1].click(); + + expect(mockRendition.display).toHaveBeenCalledWith("ch2.xhtml"); + // closeToc should remove classes + expect(tocContainer.classList.contains("open")).toBe(false); + expect(overlay.classList.contains("open")).toBe(false); + }); + + test("relocated listener updates current page input from locations", async () => { + mockMainModule(); + mockLibraryModule(); + const { mockBook, mockRendition } = mockEpubAndRendition({ locationsLen: 20 }); + + const mod = await import(getBookModulePath()); + + // Open + jest.spyOn(global, "FileReader").mockImplementation(function () { + const inst = { onload: null, onerror: null, readAsArrayBuffer() { setTimeout(() => inst.onload && inst.onload({ target: { result: new ArrayBuffer(1) } }), 0);} }; + return inst; + }); + const file = new File(["x"], "book.epub", { type: "application/epub+zip" }); + mod.openBook({ target: { files: [file] } }); + await Promise.resolve(); + jest.runAllTimers(); + await mockBook.ready; + + // Simulate rendition relocation to cfi of page 4 (locationFromCfi mocked returns 0 by default; override once) + mockBook.locations.locationFromCfi.mockReturnValueOnce(3); + mockRendition.__emit("relocated", { start: { cfi: "cfi-test" } }); + + expect(document.getElementById("current-page").value).toBe("4"); + }); + + test("prevPage/nextPage guards against no rendition", async () => { + mockMainModule(); + mockLibraryModule(); + mockEpubAndRendition(); + + const mod = await import(getBookModulePath()); + + // No book opened yet, should not throw + expect(() => mod.prevPage()).not.toThrow(); + expect(() => mod.nextPage()).not.toThrow(); + }); +}); + +/** + * Resolve path to the module under test relative to test file. + * Adjust this function if the module path differs in your repo. + */ +function getBookModulePath() { + // Prefer src/book.js, else fallback to book.js in project root + const candidates = [ + "../..//src/book.js", + "../../book.js", + "../../public/book.js", + "../../app/book.js", + "../../client/book.js", + "../../scripts/book.js", + ]; + for (const p of candidates) { + try { + // eslint-disable-next-line node/no-missing-require, global-require + require.resolve(p, { paths: [__dirname] }); + return p; + } catch (_) {} + } + // Default guess: module lives next to main.js + return "../../src/book.js"; +} \ 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..9beb425 --- /dev/null +++ b/tests/unit/indexedDB.test.js @@ -0,0 +1,283 @@ +/** + * Test framework: Jest (jsdom environment assumed). + * If using Vitest, this file should still run as-is in a compatible environment. + */ + +// We will import the module under test. Adjust the path if implementation file differs. +let mod; +const importModule = async () => { + // Try common paths; the repository may place the implementation in src/indexedDB.js or similar. + try { + mod = await import('../../src/indexedDB.js'); + } catch (e1) { + try { + mod = await import('../../indexedDB.js'); + } catch (e2) { + try { + mod = await import('../../src/utils/indexedDB.js'); + } catch (e3) { + // Last resort: path used in tests matches PR context + mod = await import('../../src/lib/indexedDB.js'); + } + } + } +}; + +const createMockIDB = () => { + // Minimal IndexedDB mock graph with evented IDBRequests + class IDBRequest { + constructor() { + this.result = undefined; + this.error = undefined; + this.onsuccess = null; + this.onerror = null; + } + succeed(result) { + this.result = result; + if (typeof this.onsuccess === 'function') { + this.onsuccess({ target: { result: this.result } }); + } + } + fail(err) { + this.error = err; + if (typeof this.onerror === 'function') { + this.onerror({ target: { error: this.error } }); + } + } + } + + class ObjectStore { + constructor(map) { + this.map = map; + } + put(value) { + const req = new IDBRequest(); + // emulate async + queueMicrotask(() => { + try { + if (!value || typeof value.name === 'undefined') { + throw new Error('Invalid record'); + } + this.map.set(value.name, value); + req.succeed(undefined); + } catch (e) { + req.fail(e); + } + }); + return req; + } + get(key) { + const req = new IDBRequest(); + queueMicrotask(() => { + try { + req.succeed(this.map.has(key) ? this.map.get(key) : undefined); + } catch (e) { + req.fail(e); + } + }); + return req; + } + } + + class Transaction { + constructor(map, mode) { + this.mode = mode; + this._store = new ObjectStore(map); + } + objectStore(name) { + if (name !== 'handles') { + throw new Error('Unknown store: ' + name); + } + return this._store; + } + } + + class DB { + constructor() { + this.stores = new Map(); // name -> Map() + } + createObjectStore(name, _opts) { + if (!this.stores.has(name)) { + this.stores.set(name, new Map()); + } + return this.stores.get(name); + } + transaction(name, mode) { + if (!this.stores.has(name)) { + throw new Error('Store does not exist: ' + name); + } + return new Transaction(this.stores.get(name), mode); + } + } + + const db = new DB(); + + const indexedDB = { + open: jest.fn((_name, _version) => { + const req = new IDBRequest(); + // Attach a "result" carrying the DB on success + // Triggering of onupgradeneeded/onsuccess is controlled by tests via helpers below + // Expose a handle so tests can drive the lifecycle + req._driveUpgrade = () => { + const event = { target: { result: db } }; + if (typeof req.onupgradeneeded === 'function') { + req.onupgradeneeded(event); + } + }; + req._succeedOpen = () => { + const event = { target: { result: db } }; + if (typeof req.onsuccess === 'function') { + req.onsuccess(event); + } + }; + req._failOpen = (err) => { + req.fail(err); + }; + return req; + }), + }; + + return { indexedDB, DB, ObjectStore, Transaction, IDBRequest }; +}; + +describe('indexedDB helpers: storeLibraryHandle and getStoredLibraryHandle', () => { + let restoreIndexedDB; + let mock; + + beforeEach(async () => { + jest.useFakeTimers(); // in case timers are used + mock = createMockIDB(); + restoreIndexedDB = global.indexedDB; + global.indexedDB = mock.indexedDB; + await importModule(); + }); + + afterEach(() => { + jest.useRealTimers(); + global.indexedDB = restoreIndexedDB; + // reset module cache between tests to isolate state if needed + jest.resetModules(); + }); + + test('getDB creates "handles" store on upgrade and resolves db on success', async () => { + // Arrange + const openReq = global.indexedDB.open.mock.results[0]?.value || global.indexedDB.open('htmlreader-db', 1); + // Act: trigger upgrade then success + openReq._driveUpgrade(); + openReq._succeedOpen(); + + // Assert via calling a public API that awaits getDB internally + await expect(mod.storeLibraryHandle({ foo: 'bar' })).resolves.toBeUndefined(); + + // Validate that put indeed landed in the store by reading back + await expect(mod.getStoredLibraryHandle()).resolves.toEqual({ foo: 'bar' }); + }); + + test('storeLibraryHandle: writes the "library" handle successfully', async () => { + const openReq = global.indexedDB.open('htmlreader-db', 1); + openReq._driveUpgrade(); + openReq._succeedOpen(); + + const handle = { id: 1, name: 'lib' }; + await expect(mod.storeLibraryHandle(handle)).resolves.toBeUndefined(); + + await expect(mod.getStoredLibraryHandle()).resolves.toEqual(handle); + }); + + test('storeLibraryHandle: rejects when objectStore.put fails', async () => { + const openReq = global.indexedDB.open('htmlreader-db', 1); + openReq._driveUpgrade(); + openReq._succeedOpen(); + + // Cause failure by passing a value lacking required "name" in record builder + // store.put({ name: "library", handle }) always sets name, so we need to sabotage the ObjectStore.put. + // Replace put to throw. + const db = (openReq.onsuccess && { result: null }) || null; // noop line for clarity + // Monkey-patch the underlying object store to throw + const origOpen = global.indexedDB.open; + global.indexedDB.open = jest.fn((_n, _v) => { + const req = origOpen(_n, _v); + req._driveUpgrade = () => { + if (typeof req.onupgradeneeded === 'function') { + const upgradeEvent = { target: { result: new (class extends (new (function(){})().constructor){})() } }; // dummy + } + }; + req._succeedOpen = () => { + // Provide a db with a failing store.put + const failingDb = { + createObjectStore: () => {}, + transaction: (_name, _mode) => ({ + objectStore: (_nm) => ({ + put: () => { + const r = new mock.IDBRequest(); + queueMicrotask(() => r.fail(new Error('put failed'))); + return r; + }, + }), + }), + }; + if (typeof req.onsuccess === 'function') { + req.onsuccess({ target: { result: failingDb } }); + } + }; + return req; + }); + + const req2 = global.indexedDB.open('htmlreader-db', 1); + + req2._succeedOpen(); + + await expect(mod.storeLibraryHandle({})).rejects.toThrow('put failed'); + + // restore + global.indexedDB.open = origOpen; + }); + + test('getStoredLibraryHandle: returns null when no value stored', async () => { + const openReq = global.indexedDB.open('htmlreader-db', 1); + openReq._driveUpgrade(); + openReq._succeedOpen(); + + await expect(mod.getStoredLibraryHandle()).resolves.toBeNull(); + }); + + test('getStoredLibraryHandle: rejects when objectStore.get fails', async () => { + const origOpen = global.indexedDB.open; + global.indexedDB.open = jest.fn((_n, _v) => { + const req = origOpen(_n, _v); + req._driveUpgrade = () => {}; + req._succeedOpen = () => { + const failingDb = { + transaction: () => ({ + objectStore: () => ({ + get: () => { + const r = new mock.IDBRequest(); + queueMicrotask(() => r.fail(new Error('get failed'))); + return r; + }, + }), + }), + }; + if (typeof req.onsuccess === 'function') { + req.onsuccess({ target: { result: failingDb } }); + } + }; + return req; + }); + + const req2 = global.indexedDB.open('htmlreader-db', 1); + req2._succeedOpen(); + + await expect(mod.getStoredLibraryHandle()).rejects.toThrow('get failed'); + + global.indexedDB.open = origOpen; + }); + + test('getDB rejects when opening the DB fails', async () => { + const req = global.indexedDB.open('htmlreader-db', 1); + req._failOpen(new Error('open error')); + + // Invoke a public method that relies on getDB to surface the rejection + await expect(mod.storeLibraryHandle({})).rejects.toThrow('open error'); + }); +}); \ 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..125529a --- /dev/null +++ b/tests/unit/library.test.js @@ -0,0 +1,362 @@ +/** + * @jest-environment jsdom + * + * Framework: Jest + jsdom + * These tests focus on: + * - openLibrary: directory handle retrieval, filtering .epub, error handling + * - handleLibraryFiles: rendering grid items, toggling UI, click-to-open + * - toggleLibrary: class add/remove/toggle logic + * + * Implementation notes: + * - The test dynamically resolves the library module path; optionally set LIBRARY_MODULE env var to the exact file path. + * - Relative imports used by the library module (./indexedDB, ./book, ./main) are mocked by resolving to absolute paths. + * - The epubjs package is mocked as a virtual module. + */ + +const fs = require('fs'); +const path = require('path'); + +/***** Test utilities *****/ +function setupDOM() { + document.body.innerHTML = ` +
+
+
+ `; +} + +const flush = () => new Promise((res) => setTimeout(res, 0)); + +const IGNORED_DIRS = new Set([ + 'node_modules', '.git', '.hg', '.svn', 'tests', '__tests__', 'coverage', 'dist', 'build', '.next', '.turbo', '.cache' +]); + +function isFile(p) { + try { return fs.statSync(p).isFile(); } catch { return false; } +} +function isDir(p) { + try { return fs.statSync(p).isDirectory(); } catch { return false; } +} + +function tryCandidates(list) { + for (const p of list) { + if (isFile(p)) return p; + } + return null; +} + +function walkFind(base, names, depth = 0, maxDepth = 5) { + try { + if (!fs.statSync(base).isDirectory() || depth > maxDepth) { + return null; + } + } catch { + return null; + } + + const entries = fs.readdirSync(base, { withFileTypes: true }); + for (const ent of entries) { + if (IGNORED_DIRS.has(ent.name)) { + continue; + } + const full = path.join(base, ent.name); + if (ent.isFile() && names.includes(ent.name)) { + return full; + } + } + for (const ent of entries) { + if (!ent.isDirectory() || IGNORED_DIRS.has(ent.name)) { + continue; + } + const found = walkFind(path.join(base, ent.name), names, depth + 1, maxDepth); + if (found) { + return found; + } + } + return null; +} + +function findLibraryModulePath() { + if (process.env.LIBRARY_MODULE && isFile(process.env.LIBRARY_MODULE)) { + return process.env.LIBRARY_MODULE; + } + const names = ['library.js', 'library.mjs', 'library.ts']; + const roots = ['src', 'app', 'web', 'client', 'public', '.']; + // Try direct candidates first + const direct = tryCandidates( + roots.flatMap(r => names.map(n => path.resolve(r, n))) + ); + if (direct) return direct; + // Walk search + for (const root of roots) { + const found = walkFind(path.resolve(root), names); + if (found) return found; + } + throw new Error('Unable to resolve library module path for tests. Set LIBRARY_MODULE env variable if necessary.'); +} + +function resolveSibling(modulePath, request) { + const basedir = path.dirname(modulePath); + try { + // Resolve using Node's resolver but with the module's directory as base + return require.resolve(request, { paths: [basedir] }); + } catch { + // Fallback common extensions + const base = path.join(basedir, request); + const choices = ['.js', '.mjs', '.ts', '/index.js', '/index.mjs', '/index.ts']; + for (const ext of choices) { + if (isFile(base + ext)) return base + ext; + } + // If still not found, return a likely path to allow virtual mocking + return base + '.js'; + } +} + +function createDirHandle(entries) { + return { + values() { + async function* gen() { + for (const e of entries) yield e; + } + return gen(); + } + }; +} + +function makeCreateFakeFile(ePubFactory) { + return function createFakeFile(name, { hasGetFile = false, title, cover = 'data:image/png;base64,cover' } = {}) { + const file = { + name, + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(0)) + }; + // One-off behavior per file: cover URL + metadata + ePubFactory.mockImplementationOnce(() => ({ + coverUrl: jest.fn().mockResolvedValue(cover), + loaded: { + metadata: Promise.resolve(title ? { title } : {}) + } + })); + if (hasGetFile) { + return { + kind: 'file', + name, + getFile: jest.fn().mockResolvedValue(file) + }; + } + return file; + }; +} + +async function importWithMocks() { + jest.resetModules(); + setupDOM(); + + const modulePath = findLibraryModulePath(); + const idxPath = resolveSibling(modulePath, './indexedDB'); + const bookPath = resolveSibling(modulePath, './book'); + const mainPath = resolveSibling(modulePath, './main'); + + const mocks = { + storeLibraryHandle: jest.fn(), + getStoredLibraryHandle: jest.fn(), + openBookFromEntry: jest.fn(), + showError: jest.fn() + }; + + const ePubFactory = jest.fn(() => ({ + coverUrl: jest.fn().mockResolvedValue('data:image/png;base64,cover'), + loaded: { metadata: Promise.resolve({ title: 'Mock Book Title' }) } + })); + + // Virtual mock for package import + jest.doMock('epubjs', () => ePubFactory, { virtual: true }); + + // Mock relative siblings using absolute resolved paths + jest.doMock(idxPath, () => ({ + storeLibraryHandle: mocks.storeLibraryHandle, + getStoredLibraryHandle: mocks.getStoredLibraryHandle + })); + jest.doMock(bookPath, () => ({ + openBookFromEntry: mocks.openBookFromEntry + })); + jest.doMock(mainPath, () => ({ + showError: mocks.showError + })); + + const mod = await import(modulePath); + return { mod, mocks, ePubFactory, modulePath }; +} + +/***** Tests *****/ +describe('library module', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('toggleLibrary', () => { + test('adds open class when forceOpen === true', async () => { + const { mod } = await importWithMocks(); + const container = document.getElementById('library-container'); + const overlay = document.getElementById('overlay'); + + mod.toggleLibrary(true); + + expect(container.classList.contains('open')).toBe(true); + expect(overlay.classList.contains('open')).toBe(true); + }); + + test('removes open class when forceOpen === false', async () => { + const { mod } = await importWithMocks(); + const container = document.getElementById('library-container'); + const overlay = document.getElementById('overlay'); + + container.classList.add('open'); + overlay.classList.add('open'); + + mod.toggleLibrary(false); + + expect(container.classList.contains('open')).toBe(false); + expect(overlay.classList.contains('open')).toBe(false); + }); + + test('toggles classes when forceOpen is undefined', async () => { + const { mod } = await importWithMocks(); + 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); + + mod.toggleLibrary(); + + expect(container.classList.contains('open')).toBe(true); + expect(overlay.classList.contains('open')).toBe(true); + + mod.toggleLibrary(); + + expect(container.classList.contains('open')).toBe(false); + expect(overlay.classList.contains('open')).toBe(false); + }); + }); + + describe('handleLibraryFiles', () => { + test('renders items for provided files and toggles library open', async () => { + const { mod, mocks, ePubFactory } = await importWithMocks(); + const createFakeFile = makeCreateFakeFile(ePubFactory); + + const fileWithTitle = createFakeFile('first.epub', { title: 'First Title' }); + const fileNoTitle = createFakeFile('second.epub', { title: undefined }); + const evt = { target: { files: [fileWithTitle, fileNoTitle] } }; + + mod.handleLibraryFiles(evt); + await flush(); + + const content = document.getElementById('library-content'); + expect(content.children.length).toBe(2); + + const titles = Array.from(content.querySelectorAll('.library-title')).map(n => n.textContent); + expect(titles).toEqual(['First Title', 'second.epub']); + + const imgs = Array.from(content.querySelectorAll('.library-cover')); + expect(imgs.length).toBe(2); + expect(imgs[0].getAttribute('src')).toContain('data:image/png;base64'); + expect(imgs[1].getAttribute('src')).toContain('data:image/png;base64'); + + // Verify click wiring + content.children[0].dispatchEvent(new window.Event('click')); + expect(mocks.openBookFromEntry).toHaveBeenCalledTimes(1); + expect(mocks.openBookFromEntry).toHaveBeenCalledWith(fileWithTitle); + + // UI toggled open + expect(document.getElementById('library-container').classList.contains('open')).toBe(true); + expect(document.getElementById('overlay').classList.contains('open')).toBe(true); + }); + + test('shows "No EPUB files found." message when list empty', async () => { + const { mod } = await importWithMocks(); + + mod.handleLibraryFiles({ target: { files: [] } }); + await flush(); + + const content = document.getElementById('library-content'); + expect(content.textContent).toMatch(/No EPUB files found\./); + }); + + test('falls back to filename when metadata title missing; placeholder cover when coverUrl null', async () => { + const { mod, ePubFactory } = await importWithMocks(); + const createFakeFile = makeCreateFakeFile(ePubFactory); + + // Simulate no cover and no title + const noMetaNoCover = createFakeFile('untitled.epub', { title: undefined, cover: null }); + mod.handleLibraryFiles({ target: { files: [noMetaNoCover] } }); + await flush(); + + const content = document.getElementById('library-content'); + expect(content.children.length).toBe(1); + + expect(content.querySelector('.library-title').textContent).toBe('untitled.epub'); + + const src = content.querySelector('.library-cover').getAttribute('src'); + expect(typeof src).toBe('string'); + expect(src).toMatch(/^data:image\/png;base64,/); // placeholder data URI used + }); + }); + + describe('openLibrary', () => { + test('uses stored directory handle when available and filters only .epub', async () => { + const { mod, mocks, ePubFactory } = await importWithMocks(); + const createFakeFile = makeCreateFakeFile(ePubFactory); + + const epubEntry = createFakeFile('only.epub', { hasGetFile: true, title: 'Stored Book' }); + const txtEntry = { kind: 'file', name: 'notes.txt' }; + const subdir = { kind: 'directory', name: 'sub' }; + mocks.getStoredLibraryHandle.mockResolvedValue(createDirHandle([epubEntry, txtEntry, subdir])); + + await mod.openLibrary(); + await flush(); + + const content = document.getElementById('library-content'); + expect(content.children.length).toBe(1); + expect(content.querySelector('.library-title')?.textContent).toBe('Stored Book'); + + expect(document.getElementById('library-container').classList.contains('open')).toBe(true); + expect(document.getElementById('overlay').classList.contains('open')).toBe(true); + }); + + test('prompts user via showDirectoryPicker when no stored handle, then stores it', async () => { + const { mod, mocks, ePubFactory } = await importWithMocks(); + const createFakeFile = makeCreateFakeFile(ePubFactory); + + mocks.getStoredLibraryHandle.mockResolvedValue(null); + + const epubEntry = createFakeFile('picked.epub', { hasGetFile: true, title: 'Picked Book' }); + const handle = createDirHandle([epubEntry]); + + Object.defineProperty(window, 'showDirectoryPicker', { + configurable: true, + value: jest.fn().mockResolvedValue(handle) + }); + + await mod.openLibrary(); + await flush(); + + expect(window.showDirectoryPicker).toHaveBeenCalledTimes(1); + expect(mocks.storeLibraryHandle).toHaveBeenCalledWith(handle); + + const content = document.getElementById('library-content'); + expect(content.children.length).toBe(1); + expect(content.querySelector('.library-title')?.textContent).toBe('Picked Book'); + }); + + test('reports errors via showError and does not throw', async () => { + const { mod, mocks } = await importWithMocks(); + + mocks.getStoredLibraryHandle.mockRejectedValue(new Error('boom')); + + await expect(mod.openLibrary()).resolves.toBeUndefined(); + + expect(mocks.showError).toHaveBeenCalledTimes(1); + expect(mocks.showError).toHaveBeenCalledWith(expect.stringMatching(/^Failed to open library: boom/)); + }); + }); +}); \ No newline at end of file