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