Skip to content

Commit 31f1e1f

Browse files
authored
Merge pull request #7 from HTMLToolkit/coderabbitai/utg/8d9fd92
CodeRabbit Generated Unit Tests: Add Jest/JSDOM unit tests for book, library, main, indexedDB, style
2 parents 8d9fd92 + 0ba80e9 commit 31f1e1f

File tree

5 files changed

+1797
-0
lines changed

5 files changed

+1797
-0
lines changed

tests/unit/book.test.js

Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
/**
2+
* Testing library/framework: Jest (expect/describe/test) with jsdom environment.
3+
* These tests validate the public interfaces and DOM interactions for the book module.
4+
*
5+
* If your project uses Vitest, these tests are largely compatible (minor API tweaks may be needed).
6+
*/
7+
8+
jest.mock('epubjs', () => {
9+
// Mock ePub constructor returning a predictable book object used by loadBook()
10+
const mockLocations = {
11+
generate: jest.fn(() => Promise.resolve()),
12+
length: jest.fn(() => 42),
13+
cfiFromLocation: jest.fn((loc) => `epubcfi(/6/${loc})`),
14+
locationFromCfi: jest.fn(() => 3),
15+
};
16+
17+
const mockNavigation = {
18+
toc: Promise.resolve([{ label: 'Chapter 1', href: 'ch1.xhtml' }]),
19+
};
20+
21+
const mockBook = {
22+
ready: Promise.resolve(),
23+
renderTo: jest.fn(() => ({
24+
display: jest.fn(() => Promise.resolve()),
25+
on: jest.fn(),
26+
prev: jest.fn(),
27+
next: jest.fn(),
28+
})),
29+
loaded: { metadata: Promise.resolve({ title: 'Test Title' }) },
30+
locations: mockLocations,
31+
navigation: mockNavigation,
32+
};
33+
34+
const ePub = jest.fn(() => mockBook);
35+
ePub.__mock = { mockBook, mockLocations };
36+
return { __esModule: true, default: ePub };
37+
});
38+
39+
jest.mock('../../src/main', () => ({
40+
showLoading: jest.fn(),
41+
hideLoading: jest.fn(),
42+
showError: jest.fn(),
43+
}));
44+
jest.mock('../../src/library', () => ({
45+
toggleLibrary: jest.fn(),
46+
}));
47+
48+
/**
49+
* Helper to inject required DOM nodes before importing the module under test.
50+
*/
51+
function setupDomSkeleton() {
52+
document.body.innerHTML = `
53+
<button id="toc-button" disabled></button>
54+
<button id="prev-button" disabled></button>
55+
<button id="next-button" disabled></button>
56+
<input id="current-page" value="1" />
57+
<div id="overlay"></div>
58+
<span id="total-pages"></span>
59+
<span id="book-title"></span>
60+
<div id="toc-container"></div>
61+
<div id="toc-content"></div>
62+
<div id="viewer"></div>
63+
`;
64+
}
65+
66+
// FileReader mock to control onload/onerror behavior in openBook()
67+
class MockFileReader {
68+
constructor() {
69+
this.onload = null;
70+
this.onerror = null;
71+
}
72+
readAsArrayBuffer(file) {
73+
// If test toggled error path, trigger onerror; else trigger onload
74+
if (file && file.__causeReadError) {
75+
this.onerror && this.onerror({ target: { error: 'boom' } });
76+
} else {
77+
const buf = new ArrayBuffer(8);
78+
this.onload && this.onload({ target: { result: buf } });
79+
}
80+
}
81+
}
82+
83+
describe('book module', () => {
84+
let bookModule;
85+
let ePub;
86+
let mainMocks;
87+
let libMocks;
88+
89+
beforeEach(async () => {
90+
jest.resetModules();
91+
setupDomSkeleton();
92+
global.FileReader = MockFileReader;
93+
94+
// Re-require mocks to access instances
95+
ePub = (await import('epubjs')).default;
96+
mainMocks = await import('../../src/main');
97+
libMocks = await import('../../src/library');
98+
99+
// Import module under test after DOM/mocks are set
100+
bookModule = await import('../../src/book.js');
101+
});
102+
103+
afterEach(() => {
104+
jest.clearAllMocks();
105+
});
106+
107+
describe('openBook', () => {
108+
test('shows error for non-EPUB file and does not read', () => {
109+
const file = new Blob(['not-epub'], { type: 'text/plain' });
110+
Object.defineProperty(file, 'name', { value: 'notes.txt' });
111+
const evt = { target: { files: [file] } };
112+
113+
bookModule.openBook(evt);
114+
115+
expect(mainMocks.showError).toHaveBeenCalledWith('The selected file is not a valid EPUB file.');
116+
expect(mainMocks.showLoading).not.toHaveBeenCalled();
117+
});
118+
119+
test('no-op when no file is selected', () => {
120+
const evt = { target: { files: [] } };
121+
bookModule.openBook(evt);
122+
expect(mainMocks.showLoading).not.toHaveBeenCalled();
123+
expect(mainMocks.showError).not.toHaveBeenCalled();
124+
});
125+
126+
test('reads EPUB, calls load flow, and hides loading on success', async () => {
127+
const file = new Blob([new Uint8Array([1, 2, 3])], { type: 'application/epub+zip' });
128+
Object.defineProperty(file, 'name', { value: 'book.epub' });
129+
const evt = { target: { files: [file] } };
130+
131+
bookModule.openBook(evt);
132+
133+
// showLoading called before reading
134+
expect(mainMocks.showLoading).toHaveBeenCalled();
135+
136+
// Allow microtasks to flush
137+
await Promise.resolve();
138+
await new Promise(setImmediate);
139+
140+
// hideLoading called after loadBook resolves
141+
expect(mainMocks.hideLoading).toHaveBeenCalled();
142+
expect(mainMocks.showError).not.toHaveBeenCalled();
143+
});
144+
145+
test('handles FileReader error path', async () => {
146+
const file = new Blob([new Uint8Array([9])], { type: 'application/epub+zip' });
147+
Object.defineProperty(file, 'name', { value: 'bad.epub' });
148+
// Signal our MockFileReader to emit an error
149+
Object.defineProperty(file, '__causeReadError', { value: true });
150+
const evt = { target: { files: [file] } };
151+
152+
bookModule.openBook(evt);
153+
154+
await Promise.resolve();
155+
156+
expect(mainMocks.hideLoading).toHaveBeenCalled();
157+
expect(mainMocks.showError).toHaveBeenCalledWith(expect.stringContaining('Error reading file:'));
158+
});
159+
});
160+
161+
describe('openBookFromEntry', () => {
162+
test('closes library, shows loading, loads and hides loading on success', async () => {
163+
const fakeFile = new Blob([new Uint8Array([1, 2])], { type: 'application/epub+zip' });
164+
fakeFile.arrayBuffer = jest.fn(async () => new ArrayBuffer(4));
165+
const entry = { getFile: jest.fn(async () => fakeFile) };
166+
167+
await bookModule.openBookFromEntry(entry);
168+
169+
expect(libMocks.toggleLibrary).toHaveBeenCalledWith(false);
170+
expect(mainMocks.showLoading).toHaveBeenCalled();
171+
expect(entry.getFile).toHaveBeenCalled();
172+
expect(fakeFile.arrayBuffer).toHaveBeenCalled();
173+
expect(mainMocks.hideLoading).toHaveBeenCalled();
174+
expect(libMocks.toggleLibrary).not.toHaveBeenCalledWith(true);
175+
expect(mainMocks.showError).not.toHaveBeenCalled();
176+
});
177+
178+
test('reopens library and shows error on failure', async () => {
179+
const entry = { getFile: jest.fn(async () => { throw new Error('nope'); }) };
180+
181+
await bookModule.openBookFromEntry(entry);
182+
183+
expect(libMocks.toggleLibrary).toHaveBeenCalledWith(false);
184+
expect(libMocks.toggleLibrary).toHaveBeenCalledWith(true);
185+
expect(mainMocks.showError).toHaveBeenCalledWith(expect.stringContaining('Error opening book:'));
186+
expect(mainMocks.hideLoading).toHaveBeenCalled();
187+
});
188+
});
189+
190+
describe('navigation controls', () => {
191+
test('prevPage and nextPage invoke rendition methods when initialized', async () => {
192+
// Trigger a minimal load to set up rendition
193+
const file = new Blob([new Uint8Array([1])], { type: 'application/epub+zip' });
194+
Object.defineProperty(file, 'name', { value: 'ok.epub' });
195+
bookModule.openBook({ target: { files: [file] } });
196+
await Promise.resolve();
197+
await new Promise(setImmediate);
198+
199+
const { mockBook } = (await import('epubjs')).default.__mock;
200+
const rendition = mockBook.renderTo.mock.results[0].value;
201+
202+
bookModule.prevPage();
203+
bookModule.nextPage();
204+
205+
expect(rendition.prev).toHaveBeenCalled();
206+
expect(rendition.next).toHaveBeenCalled();
207+
});
208+
209+
test('prevPage and nextPage are no-ops without rendition', () => {
210+
expect(() => bookModule.prevPage()).not.toThrow();
211+
expect(() => bookModule.nextPage()).not.toThrow();
212+
});
213+
});
214+
215+
describe('goToPage', () => {
216+
test('no-op if no book or no locations', () => {
217+
const viewer = document.getElementById('viewer');
218+
expect(viewer).toBeTruthy();
219+
// Without loading a book, should do nothing
220+
expect(() => bookModule.goToPage()).not.toThrow();
221+
});
222+
223+
test('navigates to valid page index and ignores out-of-range/invalid inputs', async () => {
224+
// Load book to initialize locations and rendition
225+
const file = new Blob([new Uint8Array([1])], { type: 'application/epub+zip' });
226+
Object.defineProperty(file, 'name', { value: 'ok.epub' });
227+
bookModule.openBook({ target: { files: [file] } });
228+
await Promise.resolve();
229+
await new Promise(setImmediate);
230+
231+
const { mockBook } = (await import('epubjs')).default.__mock;
232+
const rendition = mockBook.renderTo.mock.results[0].value;
233+
234+
// Valid page (1-based in input)
235+
const input = document.getElementById('current-page');
236+
input.value = '4';
237+
bookModule.goToPage();
238+
expect(mockBook.locations.cfiFromLocation).toHaveBeenCalledWith(3);
239+
expect(rendition.display).toHaveBeenCalledWith(expect.stringContaining('epubcfi('));
240+
241+
// Invalid: non-numeric
242+
input.value = 'abc';
243+
rendition.display.mockClear();
244+
bookModule.goToPage();
245+
expect(rendition.display).not.toHaveBeenCalled();
246+
247+
// Out of range: 0
248+
input.value = '0';
249+
rendition.display.mockClear();
250+
bookModule.goToPage();
251+
expect(rendition.display).not.toHaveBeenCalled();
252+
253+
// Out of range: > length
254+
input.value = '999';
255+
rendition.display.mockClear();
256+
bookModule.goToPage();
257+
expect(rendition.display).not.toHaveBeenCalled();
258+
});
259+
});
260+
261+
describe('TOC toggles', () => {
262+
test('toggleToc toggles open class on container and overlay', () => {
263+
const toc = document.getElementById('toc-container');
264+
const overlay = document.getElementById('overlay');
265+
expect(toc.classList.contains('open')).toBe(false);
266+
expect(overlay.classList.contains('open')).toBe(false);
267+
268+
bookModule.toggleToc();
269+
270+
expect(toc.classList.contains('open')).toBe(true);
271+
expect(overlay.classList.contains('open')).toBe(true);
272+
273+
bookModule.toggleToc();
274+
275+
expect(toc.classList.contains('open')).toBe(false);
276+
expect(overlay.classList.contains('open')).toBe(false);
277+
});
278+
279+
test('closeToc removes open class', () => {
280+
const toc = document.getElementById('toc-container');
281+
const overlay = document.getElementById('overlay');
282+
toc.classList.add('open');
283+
overlay.classList.add('open');
284+
285+
bookModule.closeToc();
286+
287+
expect(toc.classList.contains('open')).toBe(false);
288+
expect(overlay.classList.contains('open')).toBe(false);
289+
});
290+
});
291+
292+
describe('load side-effects', () => {
293+
test('enables navigation buttons and sets book title', async () => {
294+
const file = new Blob([new Uint8Array([1])], { type: 'application/epub+zip' });
295+
Object.defineProperty(file, 'name', { value: 'ok.epub' });
296+
bookModule.openBook({ target: { files: [file] } });
297+
await Promise.resolve();
298+
await new Promise(setImmediate);
299+
300+
const prev = document.getElementById('prev-button');
301+
const next = document.getElementById('next-button');
302+
const tocBtn = document.getElementById('toc-button');
303+
const title = document.getElementById('book-title');
304+
const totalPages = document.getElementById('total-pages');
305+
306+
expect(prev.disabled).toBe(false);
307+
expect(next.disabled).toBe(false);
308+
expect(tocBtn.disabled).toBe(false);
309+
expect(title.textContent).toBe('Test Title');
310+
expect(totalPages.textContent).toBe('42');
311+
});
312+
313+
test('falls back to default titles on metadata errors', async () => {
314+
// Reconfigure epubjs mock to reject metadata
315+
jest.resetModules();
316+
setupDomSkeleton();
317+
global.FileReader = MockFileReader;
318+
319+
jest.doMock('epubjs', () => {
320+
const mockLocations = {
321+
generate: jest.fn(() => Promise.resolve()),
322+
length: jest.fn(() => 1),
323+
cfiFromLocation: jest.fn((loc) => `epubcfi(/6/${loc})`),
324+
locationFromCfi: jest.fn(() => 0),
325+
};
326+
const mockBook = {
327+
ready: Promise.resolve(),
328+
renderTo: jest.fn(() => ({
329+
display: jest.fn(() => Promise.resolve()),
330+
on: jest.fn(),
331+
prev: jest.fn(),
332+
next: jest.fn(),
333+
})),
334+
loaded: { metadata: Promise.reject(new Error('meta fail')) },
335+
locations: mockLocations,
336+
navigation: { toc: Promise.resolve([]) },
337+
};
338+
const ePub = jest.fn(() => mockBook);
339+
ePub.__mock = { mockBook, mockLocations };
340+
return { __esModule: true, default: ePub };
341+
});
342+
343+
const mainMocks2 = await import('../../src/main');
344+
await import('../../src/library');
345+
const mod = await import('../../src/book.js');
346+
347+
const file = new Blob([new Uint8Array([1])], { type: 'application/epub+zip' });
348+
Object.defineProperty(file, 'name', { value: 'ok.epub' });
349+
350+
mod.openBook({ target: { files: [file] } });
351+
await Promise.resolve();
352+
await new Promise(setImmediate);
353+
354+
const title = document.getElementById('book-title');
355+
expect(title.textContent).toBe('EPUB Book');
356+
expect(mainMocks2.showLoading).toHaveBeenCalled();
357+
expect(mainMocks2.hideLoading).toHaveBeenCalled();
358+
});
359+
});
360+
361+
describe('TOC generation click behavior', () => {
362+
test('clicking a TOC item displays the href and closes overlay', async () => {
363+
// Load to trigger generateToc
364+
const file = new Blob([new Uint8Array([1])], { type: 'application/epub+zip' });
365+
Object.defineProperty(file, 'name', { value: 'ok.epub' });
366+
bookModule.openBook({ target: { files: [file] } });
367+
await Promise.resolve();
368+
await new Promise(setImmediate);
369+
370+
const { mockBook } = (await import('epubjs')).default.__mock;
371+
const rendition = mockBook.renderTo.mock.results[0].value;
372+
373+
const tocContent = document.getElementById('toc-content');
374+
expect(tocContent.children.length).toBeGreaterThanOrEqual(1);
375+
376+
// Open overlay then click item to ensure closeToc is called
377+
const tocContainer = document.getElementById('toc-container');
378+
const overlay = document.getElementById('overlay');
379+
tocContainer.classList.add('open');
380+
overlay.classList.add('open');
381+
382+
const first = tocContent.children[0];
383+
first.dispatchEvent(new Event('click', { bubbles: true }));
384+
385+
expect(rendition.display).toHaveBeenCalledWith('ch1.xhtml');
386+
expect(tocContainer.classList.contains('open')).toBe(false);
387+
expect(overlay.classList.contains('open')).toBe(false);
388+
});
389+
});
390+
});

0 commit comments

Comments
 (0)