import { describe, it, expect, vi } from 'vitest'; import { PageProgress } from 'foliate-js/progress.js'; const createHTML = (html: string): Document => { const parser = new DOMParser(); return parser.parseFromString(`
${html}`, 'text/html'); }; const makeBook = (sections: { html: string }[]) => ({ sections: sections.map(({ html }) => ({ createDocument: () => Promise.resolve(createHTML(html)), })), }); describe('PageProgress', () => { describe('getProgress', () => { it('should return fraction 0 when anchor targets the start of a section', async () => { const book = makeBook([{ html: 'Hello world
' }]); const resolveNavigation = () => ({ index: 0, anchor: (doc: Document) => { const text = doc.body.querySelector('p')!.firstChild!; const range = doc.createRange(); range.setStart(text, 0); range.setEnd(text, 0); return range; }, }); const pp = new PageProgress(book, resolveNavigation); const result = await pp.getProgress('cfi-start'); expect(result).not.toBeNull(); expect(result!.fraction).toBe(0); expect(result!.index).toBe(0); }); it('should return fraction 1 when anchor targets the end of a section', async () => { const book = makeBook([{ html: 'Hello world
' }]); const resolveNavigation = () => ({ index: 0, anchor: (doc: Document) => { const text = doc.body.querySelector('p')!.firstChild!; const range = doc.createRange(); range.setStart(text, text.textContent!.length); range.setEnd(text, text.textContent!.length); return range; }, }); const pp = new PageProgress(book, resolveNavigation); const result = await pp.getProgress('cfi-end'); expect(result).not.toBeNull(); expect(result!.fraction).toBe(1); expect(result!.index).toBe(0); }); it('should return correct fraction for a midpoint in a section', async () => { const book = makeBook([{ html: 'abcdefghij
' }]); const resolveNavigation = () => ({ index: 0, anchor: (doc: Document) => { const text = doc.body.querySelector('p')!.firstChild!; const range = doc.createRange(); range.setStart(text, 5); range.setEnd(text, 5); return range; }, }); const pp = new PageProgress(book, resolveNavigation); const result = await pp.getProgress('cfi-mid'); expect(result).not.toBeNull(); expect(result!.fraction).toBe(0.5); expect(result!.index).toBe(0); }); it('should handle multiple text nodes across elements', async () => { const book = makeBook([{ html: 'aaaa
bbbb
cccc
' }]); const resolveNavigation = () => ({ index: 0, anchor: (doc: Document) => { // target start of second, which is at offset 4 of 12 total chars const text = doc.body.querySelectorAll('p')[1]!.firstChild!; const range = doc.createRange(); range.setStart(text, 0); range.setEnd(text, 0); return range; }, }); const pp = new PageProgress(book, resolveNavigation); const result = await pp.getProgress('cfi-multi'); expect(result).not.toBeNull(); expect(result!.fraction).toBeCloseTo(4 / 12); expect(result!.index).toBe(0); }); it('should handle anchor returning an Element instead of a Range', async () => { const book = makeBook([{ html: '
first
second
third
' }]); const resolveNavigation = () => ({ index: 0, anchor: (doc: Document) => doc.getElementById('target')!, }); const pp = new PageProgress(book, resolveNavigation); const result = await pp.getProgress('cfi-element'); expect(result).not.toBeNull(); // "first" is 5 chars, "second" starts at offset 5 of 16 total expect(result!.fraction).toBeCloseTo(5 / 16); expect(result!.index).toBe(0); }); it('should return correct index for non-zero section', async () => { const book = makeBook([ { html: 'chapter one
' }, { html: 'chapter two content
' }, ]); const resolveNavigation = () => ({ index: 1, anchor: (doc: Document) => { const text = doc.body.querySelector('p')!.firstChild!; const range = doc.createRange(); range.setStart(text, 0); range.setEnd(text, 0); return range; }, }); const pp = new PageProgress(book, resolveNavigation); const result = await pp.getProgress('cfi-section1'); expect(result).not.toBeNull(); expect(result!.fraction).toBe(0); expect(result!.index).toBe(1); }); it('should return null when resolveNavigation returns no index', async () => { const book = makeBook([{ html: 'text
' }]); const resolveNavigation = (): { index: undefined; anchor: undefined } => ({ index: undefined, anchor: undefined, }); const pp = new PageProgress(book, resolveNavigation); const result = await pp.getProgress('bad-cfi'); expect(result).toBeNull(); }); it('should return null when resolveNavigation returns no anchor', async () => { const book = makeBook([{ html: 'text
' }]); const resolveNavigation = (): { index: number; anchor: undefined } => ({ index: 0, anchor: undefined, }); const pp = new PageProgress(book, resolveNavigation); const result = await pp.getProgress('no-anchor'); expect(result).toBeNull(); }); it('should return null when section has no createDocument', async () => { const book = { sections: [{}] }; const resolveNavigation = () => ({ index: 0, anchor: () => null, }); const pp = new PageProgress(book, resolveNavigation); const result = await pp.getProgress('no-doc'); expect(result).toBeNull(); }); it('should return fraction 0 for an empty document', async () => { const book = makeBook([{ html: '' }]); const resolveNavigation = () => ({ index: 0, anchor: (doc: Document) => { const range = doc.createRange(); range.selectNodeContents(doc.body); return range; }, }); const pp = new PageProgress(book, resolveNavigation); const result = await pp.getProgress('cfi-empty'); expect(result).not.toBeNull(); expect(result!.fraction).toBe(0); }); it('should return null and log error when resolveNavigation throws', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const book = makeBook([{ html: 'text
' }]); const resolveNavigation = () => { throw new Error('bad cfi'); }; const pp = new PageProgress(book, resolveNavigation); const result = await pp.getProgress('invalid'); expect(result).toBeNull(); expect(consoleSpy).toHaveBeenCalled(); consoleSpy.mockRestore(); }); it('should handle nested elements correctly', async () => { const book = makeBook([{ html: 'ef
' }]); const resolveNavigation = () => ({ index: 0, anchor: (doc: Document) => { // target the text node "cd" at offset 0, which is at char 2 of 6 const text = doc.body.querySelector('em')!.firstChild!; const range = doc.createRange(); range.setStart(text, 0); range.setEnd(text, 2); return range; }, }); const pp = new PageProgress(book, resolveNavigation); const result = await pp.getProgress('cfi-nested'); expect(result).not.toBeNull(); expect(result!.fraction).toBeCloseTo(2 / 6); }); }); });