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