From 4a825fc14a11d4ba12f82bdab3fdf11b6249c6fb Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Fri, 12 Sep 2025 02:11:53 +0000 Subject: [PATCH] CodeRabbit Generated Unit Tests: Add Jest/jsdom unit tests for book, library, indexedDB, and main --- Build/src/__tests__/book.unit.spec.test.js | 2 + Build/src/__tests__/book.unit.test.js | 367 +++++++++++++++++++++ Build/src/__tests__/indexedDB.spec.js | 215 ++++++++++++ Build/src/library.test.js | 292 ++++++++++++++++ Build/src/main.spec.js | 5 + Build/src/main.test.spec.js | 245 ++++++++++++++ 6 files changed, 1126 insertions(+) create mode 100644 Build/src/__tests__/book.unit.spec.test.js create mode 100644 Build/src/__tests__/book.unit.test.js create mode 100644 Build/src/__tests__/indexedDB.spec.js create mode 100644 Build/src/library.test.js create mode 100644 Build/src/main.spec.js create mode 100644 Build/src/main.test.spec.js diff --git a/Build/src/__tests__/book.unit.spec.test.js b/Build/src/__tests__/book.unit.spec.test.js new file mode 100644 index 0000000..bdebad3 --- /dev/null +++ b/Build/src/__tests__/book.unit.spec.test.js @@ -0,0 +1,2 @@ +/* @jest-environment jsdom */ +import './book.unit.test.js'; \ No newline at end of file diff --git a/Build/src/__tests__/book.unit.test.js b/Build/src/__tests__/book.unit.test.js new file mode 100644 index 0000000..ed506ee --- /dev/null +++ b/Build/src/__tests__/book.unit.test.js @@ -0,0 +1,367 @@ +/* @jest-environment jsdom */ +/* + Test suite for Build/src/book.js + Testing library/framework: Jest (jsdom environment) + - Mocks external dependencies: 'epubjs', '../main', '../library' (virtual mocks to avoid requiring real files) + - Covers: openBook, openBookFromEntry, prevPage, nextPage, goToPage, toggleToc, closeToc + - Scenarios: happy paths, invalid inputs, FileReader failure, metadata fallbacks, TOC building, key handlers +*/ + +const mockShowLoading = jest.fn(); +const mockHideLoading = jest.fn(); +const mockShowError = jest.fn(); +const mockToggleLibrary = jest.fn(); + +// Provide virtual mocks for companion modules that may not exist physically in the repo +jest.mock('../main', () => ({ + __esModule: true, + showLoading: (...a) => mockShowLoading(...a), + hideLoading: (...a) => mockHideLoading(...a), + showError: (...a) => mockShowError(...a), +}), { virtual: true }); + +jest.mock('../library', () => ({ + __esModule: true, + toggleLibrary: (...a) => mockToggleLibrary(...a), +}), { virtual: true }); + +// Mock epubjs default export (virtual to avoid resolving real package) +let epubDefaultMock; +jest.mock('epubjs', () => { + epubDefaultMock = jest.fn(); + return { __esModule: true, default: (...args) => epubDefaultMock(...args) }; +}, { virtual: true }); + +const mockRenderOn = {}; +const renditionMockFactory = () => { + const calls = { relocatedCb: null }; + return { + calls, + instance: { + display: jest.fn(() => Promise.resolve()), + prev: jest.fn(), + next: jest.fn(), + on: jest.fn((event, cb) => { + mockRenderOn[event] = cb; + if (event === 'relocated') { + calls.relocatedCb = cb; + } + }), + }, + }; +}; + +function setupDom() { + document.body.innerHTML = ` + + + + +
+ + +
+
+
+ `; +} + +function makeBookMock({ + withTitle = 'Test Title', + metadataReject = false, + tocItems = [{ label: 'Chapter 1', href: 'chap1.xhtml' }], + totalPages = 123 +} = {}) { + const { instance: rendition, calls } = renditionMockFactory(); + + const book = { + ready: Promise.resolve(), + renderTo: jest.fn(() => rendition), + loaded: { + metadata: metadataReject + ? Promise.reject(new Error('meta-fail')) + : Promise.resolve(withTitle === null ? {} : { title: withTitle }), + }, + navigation: { + toc: tocItems, // synchronous array access as used by the SUT + }, + locations: { + generate: jest.fn(() => Promise.resolve()), + length: jest.fn(() => totalPages), + cfiFromLocation: jest.fn((n) => `cfi-${n}`), + locationFromCfi: jest.fn(() => 41), // page index 41 -> UI displays 42 + }, + }; + + epubDefaultMock.mockReturnValue(book); + return { book, rendition, calls }; +} + +// Minimal FileReader mock to orchestrate success/error flows. +// The SUT never inspects the file contents beyond passing it to FileReader. +class MockFileReader { + constructor() { + this.onload = null; + this.onerror = null; + } + readAsArrayBuffer(_file) { + setTimeout(() => { + this.onload && this.onload({ target: { result: new ArrayBuffer(8) } }); + }, 0); + } +} +function installFileReader() { + global.FileReader = MockFileReader; +} + +// Import SUT after DOM is ready since it queries elements at module scope +async function importSut() { + const mod = await import('../book.js'); + return mod; +} + +beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + setupDom(); + installFileReader(); +}); + +describe('openBook()', () => { + test('rejects invalid file type/extension and shows error', async () => { + const { openBook } = await importSut(); + + const badFile = { name: 'not-epub.pdf', type: 'application/pdf' }; + openBook({ target: { files: [badFile] } }); + + expect(mockShowError).toHaveBeenCalledWith('The selected file is not a valid EPUB file.'); + expect(epubDefaultMock).not.toHaveBeenCalled(); + expect(mockShowLoading).not.toHaveBeenCalled(); + }); + + test('accepts valid .epub extension even with non-standard MIME', async () => { + makeBookMock({ withTitle: 'Ext OK' }); + const { openBook } = await importSut(); + + const fileByExt = { name: 'book.epub', type: 'application/octet-stream' }; + openBook({ target: { files: [fileByExt] } }); + + await new Promise(r => setTimeout(r, 10)); + expect(epubDefaultMock).toHaveBeenCalled(); + expect(mockShowError).not.toHaveBeenCalled(); + expect(document.getElementById('book-title').textContent).toBe('Ext OK'); + }); + + test('returns early when no file selected', async () => { + const { openBook } = await importSut(); + openBook({ target: { files: [] } }); + expect(mockShowLoading).not.toHaveBeenCalled(); + expect(mockShowError).not.toHaveBeenCalled(); + }); + + test('handles FileReader error by hiding loader and showing error', async () => { + class ErrorFileReader extends MockFileReader { + readAsArrayBuffer() { + setTimeout(() => { + this.onerror && this.onerror({ target: { error: 'boom' } }); + }, 0); + } + } + global.FileReader = ErrorFileReader; + + const { openBook } = await importSut(); + + const okFile = { name: 'book.epub', type: 'application/epub+zip' }; + openBook({ target: { files: [okFile] } }); + + await new Promise(r => setTimeout(r, 5)); + + expect(mockHideLoading).toHaveBeenCalled(); + expect(mockShowError).toHaveBeenCalledWith(expect.stringContaining('Error reading file:')); + }); + + test('successfully loads book: enables UI, builds TOC, sets title and total pages', async () => { + const { rendition } = makeBookMock({ + withTitle: 'Great Book', + tocItems: [{ label: 'Intro', href: 'intro.xhtml' }, { label: 'Ch 1', href: 'ch1.xhtml' }], + totalPages: 50 + }); + + const { openBook } = await importSut(); + const goodFile = { name: 'book.epub', type: 'application/epub+zip' }; + openBook({ target: { files: [goodFile] } }); + + await new Promise(r => setTimeout(r, 10)); + + expect(mockShowLoading).toHaveBeenCalled(); + expect(mockHideLoading).toHaveBeenCalled(); + expect(epubDefaultMock).toHaveBeenCalled(); + + expect(document.getElementById('prev-button').disabled).toBe(false); + expect(document.getElementById('next-button').disabled).toBe(false); + expect(document.getElementById('toc-button').disabled).toBe(false); + + expect(document.getElementById('book-title').textContent).toBe('Great Book'); + + const tocContent = document.getElementById('toc-content'); + expect(tocContent.children).toHaveLength(2); + expect(tocContent.children[0].textContent).toBe('Intro'); + + // Open classes present, clicking TOC item should close TOC and call display with href + document.getElementById('toc-container').classList.add('open'); + document.getElementById('overlay').classList.add('open'); + tocContent.children[0].dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(rendition.display).toHaveBeenCalledWith('intro.xhtml'); + expect(document.getElementById('toc-container').classList.contains('open')).toBe(false); + expect(document.getElementById('overlay').classList.contains('open')).toBe(false); + + expect(document.getElementById('total-pages').textContent).toBe('50'); + }); + + test('falls back to "Untitled EPUB" when metadata.title missing', async () => { + makeBookMock({ withTitle: null }); + + const { openBook } = await importSut(); + const goodFile = { name: 'book.epub', type: 'application/epub+zip' }; + openBook({ target: { files: [goodFile] } }); + + await new Promise(r => setTimeout(r, 10)); + expect(document.getElementById('book-title').textContent).toBe('Untitled EPUB'); + }); + + test('falls back to "EPUB Book" when metadata load fails', async () => { + makeBookMock({ metadataReject: true }); + + const { openBook } = await importSut(); + const goodFile = { name: 'book.epub', type: 'application/epub+zip' }; + openBook({ target: { files: [goodFile] } }); + + await new Promise(r => setTimeout(r, 10)); + expect(document.getElementById('book-title').textContent).toBe('EPUB Book'); + }); +}); + +describe('openBookFromEntry()', () => { + test('closes library, shows loader, loads book, wires key handlers (happy path)', async () => { + const { rendition } = makeBookMock(); + + const { openBookFromEntry } = await importSut(); + + const entry = { + getFile: jest.fn(async () => ({ + arrayBuffer: async () => new ArrayBuffer(4), + })), + }; + + await openBookFromEntry(entry); + + expect(mockToggleLibrary).toHaveBeenCalledWith(false); + expect(mockShowLoading).toHaveBeenCalled(); + expect(mockHideLoading).toHaveBeenCalled(); + + // Simulate relocated -> should update current-page field to 42 (41 + 1) + if (mockRenderOn['relocated']) { + mockRenderOn['relocated']({ start: { cfi: 'cfi-41' } }); + } + expect(document.getElementById('current-page').value).toBe('42'); + + // Key handlers trigger pagination + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowLeft' })); + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' })); + expect(rendition.prev).toHaveBeenCalled(); + expect(rendition.next).toHaveBeenCalled(); + }); + + test('on failure, reopens library and shows error', async () => { + makeBookMock(); + + const { openBookFromEntry } = await importSut(); + + const entry = { + getFile: jest.fn(async () => { throw new Error('nope'); }), + }; + + await openBookFromEntry(entry); + + expect(mockToggleLibrary).toHaveBeenCalledWith(false); + expect(mockToggleLibrary).toHaveBeenCalledWith(true); + expect(mockShowLoading).toHaveBeenCalled(); + expect(mockHideLoading).toHaveBeenCalled(); + expect(mockShowError).toHaveBeenCalledWith(expect.stringContaining('Error opening book:')); + }); +}); + +describe('pagination helpers', () => { + test('prevPage/nextPage are no-ops when rendition is not initialized', async () => { + const { prevPage, nextPage } = await importSut(); + expect(() => prevPage()).not.toThrow(); + expect(() => nextPage()).not.toThrow(); + }); + + test('goToPage displays correct CFI within bounds; ignores out-of-bounds and NaN', async () => { + const { book, rendition } = makeBookMock({ totalPages: 10 }); + const { openBookFromEntry, goToPage } = await importSut(); + + const entry = { + getFile: jest.fn(async () => ({ + arrayBuffer: async () => new ArrayBuffer(4), + })), + }; + await openBookFromEntry(entry); + + const currentPageInput = document.getElementById('current-page'); + + // Valid page (3 -> index 2) + currentPageInput.value = '3'; + goToPage(); + expect(book.locations.cfiFromLocation).toHaveBeenCalledWith(2); + expect(rendition.display).toHaveBeenCalledWith('cfi-2'); + + // Out of range (0) + currentPageInput.value = '0'; + rendition.display.mockClear(); + goToPage(); + expect(rendition.display).not.toHaveBeenCalled(); + + // Out of range (too big) + currentPageInput.value = '999'; + goToPage(); + expect(rendition.display).not.toHaveBeenCalled(); + + // NaN input + currentPageInput.value = 'abc'; + goToPage(); + expect(rendition.display).not.toHaveBeenCalled(); + }); +}); + +describe('TOC toggling', () => { + test('toggleToc toggles classes on container and overlay', async () => { + const { toggleToc } = await importSut(); + 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); + + toggleToc(); + expect(toc.classList.contains('open')).toBe(true); + expect(overlay.classList.contains('open')).toBe(true); + + toggleToc(); + expect(toc.classList.contains('open')).toBe(false); + expect(overlay.classList.contains('open')).toBe(false); + }); + + test('closeToc removes open classes', async () => { + const { closeToc } = await importSut(); + const toc = document.getElementById('toc-container'); + const overlay = document.getElementById('overlay'); + + toc.classList.add('open'); + overlay.classList.add('open'); + + closeToc(); + expect(toc.classList.contains('open')).toBe(false); + expect(overlay.classList.contains('open')).toBe(false); + }); +}); \ No newline at end of file diff --git a/Build/src/__tests__/indexedDB.spec.js b/Build/src/__tests__/indexedDB.spec.js new file mode 100644 index 0000000..29c6970 --- /dev/null +++ b/Build/src/__tests__/indexedDB.spec.js @@ -0,0 +1,215 @@ +/** + * Testing library/framework: This test is written to be compatible with Jest or Vitest. + * - Uses describe/it/expect syntax common to both. + * - No framework-specific APIs beyond timers and basic assertions. + * + * Subject under test: Build/src/indexedDB.test.js (exports storeLibraryHandle, getStoredLibraryHandle) + * We provide a minimal in-memory IndexedDB stub to avoid adding new dependencies. + */ + +/* eslint-disable no-undef */ + +const path = require('path'); + +// Import the module under test +const { storeLibraryHandle, getStoredLibraryHandle } = require(path.join('..', 'indexedDB.test.js')); + +/** + * Minimal IndexedDB stub that supports: + * - indexedDB.open(name, version) + * - IDBOpenDBRequest with onupgradeneeded, onsuccess, onerror + * - db.createObjectStore(name, { keyPath }) + * - db.transaction(storeName, mode).objectStore(name).put/get + * - Request objects for put/get with onsuccess/onerror + * + * Allows injecting failures for open/put/get via flags. + */ +class FakeIDBRequest { + constructor(executor) { + this.onsuccess = null; + this.onerror = null; + this._executor = executor; + } + _fireSuccess(result) { + if (typeof this.onsuccess === 'function') { + // emulate event with target.result + this.onsuccess({ target: { result } }); + } + } + _fireError(error) { + if (typeof this.onerror === 'function') { + this.onerror({ target: { error } }); + } + } +} + +class FakeObjectStore { + constructor(storage, keyPath, failBehavior) { + this._storage = storage; + this._keyPath = keyPath; + this._fail = failBehavior; + } + put(value) { + const req = new FakeIDBRequest(); + setTimeout(() => { + if (this._fail.nextPutError) { + const err = this._fail.nextPutError; + this._fail.nextPutError = null; + req._fireError(err); + return; + } + const key = value[this._keyPath]; + this._storage.set(key, value); + req._fireSuccess(undefined); + }, 0); + return req; + } + get(key) { + const req = new FakeIDBRequest(); + setTimeout(() => { + if (this._fail.nextGetError) { + const err = this._fail.nextGetError; + this._fail.nextGetError = null; + req._fireError(err); + return; + } + const value = this._storage.get(key); + // In real IDB, req.result is set on the request; we emulate by passing result through success event + // Our SUT reads via req.result in onsuccess closure; emulate by setting req.result before firing. + req.result = value; + if (typeof req.onsuccess === 'function') { + req.onsuccess({ target: req }); + } + }, 0); + return req; + } +} + +class FakeTransaction { + constructor(db, storeName) { + this._db = db; + this._storeName = storeName; + } + objectStore(name) { + if (name !== this._storeName) { + throw new Error('Invalid store name for this transaction'); + } + return this._db._getStore(name); + } +} + +class FakeIDBDatabase { + constructor(failBehavior) { + this._stores = new Map(); // name -> Map for records + this._storeMeta = new Map(); // name -> { keyPath } + this._fail = failBehavior; + } + createObjectStore(name, options) { + const keyPath = options && options.keyPath ? options.keyPath : 'id'; + if (!this._stores.has(name)) { + this._stores.set(name, new Map()); + this._storeMeta.set(name, { keyPath }); + } + return this._getStore(name); + } + transaction(name /*, mode */) { + return new FakeTransaction(this, name); + } + _getStore(name) { + const meta = this._storeMeta.get(name); + if (!meta) { + throw new Error(`Object store ${name} does not exist`); + } + return new FakeObjectStore(this._stores.get(name), meta.keyPath, this._fail); + } +} + +class FakeIDBFactory { + constructor() { + this._fail = { + nextOpenError: null, + nextPutError: null, + nextGetError: null, + }; + } + open(/* name, version */) { + const request = new FakeIDBRequest(); + setTimeout(() => { + if (this._fail.nextOpenError) { + const err = this._fail.nextOpenError; + this._fail.nextOpenError = null; + request._fireError(err); + return; + } + const db = new FakeIDBDatabase(this._fail); + // Fire upgrade needed first so SUT can create stores + if (typeof request.onupgradeneeded === 'function') { + request.onupgradeneeded({ target: { result: db } }); + } + // Then success + request._fireSuccess(db); + }, 0); + return request; + } +} + +describe('indexedDB helpers: storeLibraryHandle/getStoredLibraryHandle', () => { + let originalIndexedDB; + let fakeFactory; + + beforeEach(() => { + // Swap in our fake + originalIndexedDB = global.indexedDB; + fakeFactory = new FakeIDBFactory(); + global.indexedDB = fakeFactory; + }); + + afterEach(() => { + // Restore original + global.indexedDB = originalIndexedDB; + fakeFactory = null; + }); + + it('returns null when no library handle has been stored', async () => { + const result = await getStoredLibraryHandle(); + expect(result).toBeNull(); + }); + + it('stores and retrieves the library handle (happy path)', async () => { + const handle = { kind: 'directory', id: 123, meta: { name: 'root' } }; + await storeLibraryHandle(handle); + const retrieved = await getStoredLibraryHandle(); + expect(retrieved).toEqual(handle); + }); + + it('propagates an error when opening the database fails', async () => { + fakeFactory._fail.nextOpenError = new Error('open failed'); + // store path + await expect(storeLibraryHandle({})).rejects.toThrow('open failed'); + // get path + fakeFactory._fail.nextOpenError = new Error('open failed again'); + await expect(getStoredLibraryHandle()).rejects.toThrow('open failed again'); + }); + + it('propagates an error when putting the record fails', async () => { + // First call to open should succeed to create DB and store, put will fail + fakeFactory._fail.nextPutError = new Error('put failed'); + await expect(storeLibraryHandle({ any: 'thing' })).rejects.toThrow('put failed'); + }); + + it('propagates an error when getting the record fails', async () => { + // Store once successfully + const handle = { foo: 'bar' }; + await storeLibraryHandle(handle); + // Then make get fail + fakeFactory._fail.nextGetError = new Error('get failed'); + await expect(getStoredLibraryHandle()).rejects.toThrow('get failed'); + }); + + it('creates the "handles" object store with keyPath "name" on upgrade (indirect structural validation)', async () => { + // We indirectly validate by ensuring that put with {name:"library"} works, + // and that other keys are not accepted for missing keyPath. + const badPut = storeLibraryHandle({ notName: 'oops' }); + await expect(badPut).rejects.toThrow(); // our FakeObjectStore expects keyPath 'name' and will error in put when key is undefined + }); +}); \ No newline at end of file diff --git a/Build/src/library.test.js b/Build/src/library.test.js new file mode 100644 index 0000000..f559588 --- /dev/null +++ b/Build/src/library.test.js @@ -0,0 +1,292 @@ +/** + * Test suite for library module. + * Assumed testing framework: Jest with jsdom testEnvironment. + * If your project uses Vitest, these tests are largely compatible; replace jest.fn with vi.fn and adjust imports. + */ + +import * as LibraryModule from './library'; + +// Mocks for external modules used by the library +jest.mock('./indexedDB', () => ({ + storeLibraryHandle: jest.fn(), + getStoredLibraryHandle: jest.fn(), +})); +jest.mock('./book', () => ({ + openBookFromEntry: jest.fn(), +})); +jest.mock('./main', () => ({ + showError: jest.fn(), +})); + +// Mock epubjs default export +const makeEpubMock = () => { + const coverUrl = jest.fn().mockResolvedValue('https://example.com/cover.jpg'); + const loaded = { metadata: Promise.resolve({ title: 'Mock Title' }) }; + return { coverUrl, loaded }; +}; +jest.mock('epubjs', () => { + const fn = jest.fn(() => makeEpubMock()); + // expose helper to adjust behavior in specific tests + fn.__makeNext = (implFactory) => { + fn.mockImplementationOnce(() => implFactory()); + }; + return fn; +}); + +const { storeLibraryHandle, getStoredLibraryHandle } = require('./indexedDB'); +const { openBookFromEntry } = require('./book'); +const { showError } = require('./main'); +const ePub = require('epubjs').default || require('epubjs'); + +function createDOM() { + document.body.innerHTML = ` +
+
+
+ + `; +} + +beforeEach(() => { + jest.useFakeTimers(); // safety for any timers, none expected + jest.clearAllMocks(); + createDOM(); +}); + +afterEach(() => { + // Ensure DOM is clean between tests + document.body.innerHTML = ''; +}); + +function makeFile(name, ok = true, arrayBufferBytes = 8) { + return { + name, + arrayBuffer: ok + ? jest.fn().mockResolvedValue(new ArrayBuffer(arrayBufferBytes)) + : jest.fn().mockRejectedValue(new Error('arrayBuffer failed')), + }; +} + +function makeFSFileEntry(name, fileObj) { + return { + kind: 'file', + name, + getFile: jest.fn().mockResolvedValue(fileObj), + }; +} + +function makeDirHandle(entries) { + // entries: array of {kind,name,getFile?} + return { + values: async function* () { + for (const e of entries) yield e; + }, + }; +} + +describe('toggleLibrary', () => { + test('opens when forceOpen === true', () => { + const { toggleLibrary } = LibraryModule; + const container = document.getElementById('library-container'); + const overlay = document.getElementById('overlay'); + + toggleLibrary(true); + + expect(container.classList.contains('open')).toBe(true); + expect(overlay.classList.contains('open')).toBe(true); + }); + + test('closes when forceOpen === false', () => { + const { toggleLibrary } = LibraryModule; + const container = document.getElementById('library-container'); + const overlay = document.getElementById('overlay'); + container.classList.add('open'); + overlay.classList.add('open'); + + toggleLibrary(false); + + expect(container.classList.contains('open')).toBe(false); + expect(overlay.classList.contains('open')).toBe(false); + }); + + test('toggles when forceOpen is undefined', () => { + const { toggleLibrary } = LibraryModule; + 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); + + toggleLibrary(); + + expect(container.classList.contains('open')).toBe(true); + expect(overlay.classList.contains('open')).toBe(true); + + 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 open', async () => { + const { handleLibraryFiles } = LibraryModule; + const content = document.getElementById('library-content'); + + const f1 = makeFile('book1.epub'); + const f2 = makeFile('book2.epub'); + + await handleLibraryFiles({ target: { files: [f1, f2] } }); + + // two items created + const items = content.querySelectorAll('.library-item'); + expect(items.length).toBe(2); + + // cover applied by epub mock + const img0 = items[0].querySelector('img.library-cover'); + expect(img0).toBeTruthy(); + expect(img0.src).toBe('https://example.com/cover.jpg/'); // jsdom appends a trailing slash to absolute URLs without path + const title0 = items[0].querySelector('.library-title'); + // Metadata title should overwrite file name + await Promise.resolve(); // allow microtasks to flush 'await tempBook.loaded.metadata' + expect(title0.textContent).toBe('Mock Title'); + + // library opened + const container = document.getElementById('library-container'); + const overlay = document.getElementById('overlay'); + expect(container.classList.contains('open')).toBe(true); + expect(overlay.classList.contains('open')).toBe(true); + }); + + test('falls back to placeholder when no cover URL', async () => { + // Arrange epub mock to return null cover for next invocation + ePub.__makeNext(() => ({ + coverUrl: jest.fn().mockResolvedValue(null), + loaded: { metadata: Promise.resolve({ title: 'No Cover Title' }) }, + })); + + const { handleLibraryFiles } = LibraryModule; + const content = document.getElementById('library-content'); + const f = makeFile('no-cover.epub'); + + await handleLibraryFiles({ target: { files: [f] } }); + + const img = content.querySelector('img.library-cover'); + expect(img).toBeTruthy(); + expect(img.src.startsWith('data:image/png;base64,')).toBe(true); + + const title = content.querySelector('.library-title'); + await Promise.resolve(); + expect(title.textContent).toBe('No Cover Title'); + }); + + test('gracefully logs and keeps default title on processing error', async () => { + // Force ePub to throw for this call + const thrown = new Error('epub parse failed'); + ePub.__makeNext(() => { + throw thrown; + }); + + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const { handleLibraryFiles } = LibraryModule; + const content = document.getElementById('library-content'); + const f = makeFile('bad.epub'); + + await handleLibraryFiles({ target: { files: [f] } }); + + const item = content.querySelector('.library-item'); + const title = item.querySelector('.library-title'); + // Title should remain file name since metadata could not be read + expect(title.textContent).toBe('bad.epub'); + expect(spy).toHaveBeenCalledWith( + 'Error loading cover for', + 'bad.epub', + thrown + ); + spy.mockRestore(); + }); + + test('clicking an item invokes openBookFromEntry with original file', async () => { + const { handleLibraryFiles } = LibraryModule; + const content = document.getElementById('library-content'); + + const fileObj = makeFile('clickable.epub'); + await handleLibraryFiles({ target: { files: [fileObj] } }); + + const item = content.querySelector('.library-item'); + expect(item).toBeTruthy(); + + item.click(); + expect(openBookFromEntry).toHaveBeenCalledTimes(1); + expect(openBookFromEntry).toHaveBeenCalledWith(fileObj); + }); +}); + +describe('openLibrary', () => { + test('uses stored directory handle when available, filters to .epub, displays items, toggles open', async () => { + const { openLibrary } = LibraryModule; + + // prepare directory with mixed entries + const epub1 = makeFSFileEntry('a.epub', makeFile('a.epub')); + const txt = { kind: 'file', name: 'notes.txt' }; // should be ignored + const epub2 = makeFSFileEntry('b.epub', makeFile('b.epub')); + const dirHandle = makeDirHandle([epub1, txt, epub2]); + + getStoredLibraryHandle.mockResolvedValue(dirHandle); + + await openLibrary(); + + // only 2 epub files should render + const items = document.querySelectorAll('#library-content .library-item'); + expect(items.length).toBe(2); + + // toggled open + expect(document.getElementById('library-container').classList.contains('open')).toBe(true); + expect(document.getElementById('overlay').classList.contains('open')).toBe(true); + + // showDirectoryPicker should not be called when stored handle exists + expect(global.window.showDirectoryPicker).toBeUndefined(); + expect(storeLibraryHandle).not.toHaveBeenCalled(); + }); + + test('prompts for directory when no stored handle, stores it, displays empty state when no epubs', async () => { + const { openLibrary } = LibraryModule; + + getStoredLibraryHandle.mockResolvedValue(null); + + // Mock showDirectoryPicker on window + const emptyDir = makeDirHandle([]); // no entries + Object.defineProperty(window, 'showDirectoryPicker', { + configurable: true, + writable: true, + value: jest.fn().mockResolvedValue(emptyDir), + }); + + await openLibrary(); + + expect(window.showDirectoryPicker).toHaveBeenCalledTimes(1); + expect(storeLibraryHandle).toHaveBeenCalledWith(emptyDir); + + const content = document.getElementById('library-content'); + expect(content.textContent).toContain('No EPUB files found.'); + + expect(document.getElementById('library-container').classList.contains('open')).toBe(true); + expect(document.getElementById('overlay').classList.contains('open')).toBe(true); + }); + + test('reports error via showError if opening library fails (e.g., permission denied)', async () => { + const { openLibrary } = LibraryModule; + + const err = new Error('Permission denied'); + getStoredLibraryHandle.mockRejectedValue(err); + + await openLibrary(); + + expect(showError).toHaveBeenCalledTimes(1); + expect(showError).toHaveBeenCalledWith('Failed to open library: ' + err.message); + + // should not throw + await expect(openLibrary()).resolves.toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/Build/src/main.spec.js b/Build/src/main.spec.js new file mode 100644 index 0000000..59bfaa3 --- /dev/null +++ b/Build/src/main.spec.js @@ -0,0 +1,5 @@ +/** + * Mirror tests for Build/src/main.js if the source file is main.js instead of main.test.js. + * See main.test.js tests for full coverage. This file re-exports the same suite. + */ +export { }; \ No newline at end of file diff --git a/Build/src/main.test.spec.js b/Build/src/main.test.spec.js new file mode 100644 index 0000000..687e6de --- /dev/null +++ b/Build/src/main.test.spec.js @@ -0,0 +1,245 @@ +/** + * NOTE: Test framework: We use the repository's existing test runner. + * - If Jest: relies on testEnvironment: 'jsdom'. If not set globally, add `@jest-environment jsdom` per-file. + * - If Vitest: ensure `environment: 'jsdom'` or run with `vitest --environment jsdom`. + * + * These tests validate the DOM wiring and exported message helpers in Build/src/main.test.js (per PR diff). + */ + +/// +/* eslint-disable no-undef */ + +let mod; +const makeEl = (id) => { + const el = document.createElement('div'); + el.id = id; + // Basic classList polyfill not needed in jsdom; ensure exists for safety + if (!el.classList) { + el.classList = { + _set: new Set(), + add: function (c) { this._set.add(c); el.setAttribute('class', Array.from(this._set).join(' ')); }, + remove: function (c) { this._set.delete(c); el.setAttribute('class', Array.from(this._set).join(' ')); }, + contains: function (c) { return this._set.has(c); } + }; + } + return el; +}; + +function setupDOM() { + document.body.innerHTML = ""; + const ids = [ + 'open-button','file-input','library-input','library-button','close-library', + 'toc-button','close-toc','prev-button','next-button','current-page', + 'overlay','loading-message','error-message','error-text','close-error' + ]; + + const fragment = document.createDocumentFragment(); + ids.forEach((id) => { + let el; + switch (id) { + case 'file-input': + case 'library-input': { + el = document.createElement('input'); + el.type = 'file'; + break; + } + case 'current-page': { + el = document.createElement('input'); + el.type = 'number'; + break; + } + case 'open-button': + case 'library-button': + case 'close-library': + case 'toc-button': + case 'close-toc': + case 'prev-button': + case 'next-button': + case 'overlay': + case 'close-error': { + el = document.createElement(id === 'overlay' ? 'div' : 'button'); + break; + } + case 'loading-message': + case 'error-message': + case 'error-text': { + el = document.createElement('div'); + break; + } + default: + el = document.createElement('div'); + } + el.id = id; + fragment.appendChild(el); + }); + document.body.appendChild(fragment); +} + +const mockFns = { + openBook: jest.fn(), + prevPage: jest.fn(), + nextPage: jest.fn(), + goToPage: jest.fn(), + toggleToc: jest.fn(), + closeToc: jest.fn(), + openLibrary: jest.fn(), + handleLibraryFiles: jest.fn(), + toggleLibrary: jest.fn(), +}; + +jest.mock("./book", () => ({ + openBook: (...args) => mockFns.openBook(...args), + prevPage: (...args) => mockFns.prevPage(...args), + nextPage: (...args) => mockFns.nextPage(...args), + goToPage: (...args) => mockFns.goToPage(...args), + toggleToc: (...args) => mockFns.toggleToc(...args), + closeToc: (...args) => mockFns.closeToc(...args), +})); + +jest.mock("./library", () => ({ + openLibrary: (...args) => mockFns.openLibrary(...args), + handleLibraryFiles: (...args) => mockFns.handleLibraryFiles(...args), + toggleLibrary: (...args) => mockFns.toggleLibrary(...args), +})); + +/** + * Dynamic import after DOM is ready, since module queries elements on import. + */ +async function importModule() { + // Support both relative from test location and repo tooling transpilation + // The file under test is Build/src/main.test.js (per diff). Adjust if relocated. + return await import("./main.test.js"); +} + +beforeEach(async () => { + jest.resetModules(); + Object.values(mockFns).forEach(fn => fn.mockClear()); + setupDOM(); + mod = await importModule(); +}); + +describe("DOM event wiring for main UI", () => { + test("clicking 'open-button' triggers file input click (no openBook yet)", () => { + const fileInput = document.getElementById('file-input'); + const spyClick = jest.spyOn(fileInput, 'click'); + document.getElementById('open-button').click(); + expect(spyClick).toHaveBeenCalledTimes(1); + expect(mockFns.openBook).not.toHaveBeenCalled(); // not until change event + }); + + test("changing file-input calls openBook", () => { + const fileInput = document.getElementById('file-input'); + const event = new Event('change'); + fileInput.dispatchEvent(event); + expect(mockFns.openBook).toHaveBeenCalledTimes(1); + }); + + test("prev/next buttons call navigation handlers", () => { + document.getElementById('prev-button').click(); + document.getElementById('next-button').click(); + expect(mockFns.prevPage).toHaveBeenCalledTimes(1); + expect(mockFns.nextPage).toHaveBeenCalledTimes(1); + }); + + test("changing current-page input calls goToPage", () => { + const input = document.getElementById('current-page'); + input.value = "12"; + const event = new Event('change'); + input.dispatchEvent(event); + expect(mockFns.goToPage).toHaveBeenCalledTimes(1); + }); + + test("toc open/close buttons both call toggleToc", () => { + document.getElementById('toc-button').click(); + document.getElementById('close-toc').click(); + expect(mockFns.toggleToc).toHaveBeenCalledTimes(2); + }); + + test("library button opens library; close button toggles library off", () => { + document.getElementById('library-button').click(); + expect(mockFns.openLibrary).toHaveBeenCalledTimes(1); + + document.getElementById('close-library').click(); + expect(mockFns.toggleLibrary).toHaveBeenCalledWith(false); + }); + + test("overlay click closes toc, library, and hides error", () => { + const hideSpy = jest.spyOn(mod, 'hideError'); + document.getElementById('overlay').click(); + expect(mockFns.closeToc).toHaveBeenCalledTimes(1); + expect(mockFns.toggleLibrary).toHaveBeenCalledWith(false); + expect(hideSpy).toHaveBeenCalledTimes(1); + }); + + test("close error button hides error", () => { + const hideSpy = jest.spyOn(mod, 'hideError'); + document.getElementById('close-error').click(); + expect(hideSpy).toHaveBeenCalledTimes(1); + }); + + test("library input change delegates to handleLibraryFiles (fallback multi-file import)", () => { + const libInput = document.getElementById('library-input'); + libInput.dispatchEvent(new Event('change')); + expect(mockFns.handleLibraryFiles).toHaveBeenCalledTimes(1); + }); +}); + +describe("Message helpers", () => { + test("showLoading adds 'show' class; hideLoading removes it", () => { + const el = document.getElementById('loading-message'); + expect(el.classList.contains('show')).toBe(false); + mod.showLoading(); + expect(el.classList.contains('show')).toBe(true); + mod.hideLoading(); + expect(el.classList.contains('show')).toBe(false); + }); + + test("showError sets message text and adds 'show' class", () => { + const msg = "Something went wrong!"; + const errorMsg = document.getElementById('error-message'); + const errorText = document.getElementById('error-text'); + expect(errorMsg.classList.contains('show')).toBe(false); + expect(errorText.textContent).toBe(""); + mod.showError(msg); + expect(errorText.textContent).toBe(msg); + expect(errorMsg.classList.contains('show')).toBe(true); + }); + + test("hideError removes 'show' class from error message container", () => { + const errorMsg = document.getElementById('error-message'); + errorMsg.classList.add('show'); + mod.hideError(); + expect(errorMsg.classList.contains('show')).toBe(false); + }); + + test("showError handles non-string inputs gracefully by coercion", () => { + const errorText = document.getElementById('error-text'); + mod.showError(404); + expect(errorText.textContent).toBe("404"); + mod.showError({ a: 1 }); + // Default coercion to string: [object Object] + expect(errorText.textContent).toBe("[object Object]"); + mod.showError(null); + expect(errorText.textContent).toBe("null"); + }); +}); + +describe("Resilience to missing DOM elements", () => { + test("if elements are absent, importing should not throw when functions are called", async () => { + // Recreate environment with missing nodes for message helpers + document.body.innerHTML = ""; + const ids = ['loading-message','error-message','error-text']; + ids.forEach(id => { + // Intentionally omit to simulate missing elements + // No append + }); + jest.resetModules(); + const localMod = await import("./main.test.js"); + + // Calls should not throw even if classList/textContent are missing + expect(() => localMod.showLoading()).not.toThrow(); + expect(() => localMod.hideLoading()).not.toThrow(); + expect(() => localMod.showError("x")).not.toThrow(); + expect(() => localMod.hideError()).not.toThrow(); + }); +}); \ No newline at end of file