Initial commit
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { zipSync } from 'fflate';
|
||||
import { Cache } from '../src/services/cache.ts';
|
||||
import { fetchEpub } from '../src/services/epub.ts';
|
||||
|
||||
const enc = new TextEncoder();
|
||||
|
||||
function buildEpub(chapters: { title: string; content: string }[]): Uint8Array {
|
||||
const files: Record<string, Uint8Array> = {};
|
||||
|
||||
files['META-INF/container.xml'] = enc.encode(
|
||||
`<?xml version="1.0"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>`,
|
||||
);
|
||||
|
||||
const manifestItems = chapters
|
||||
.map((_, i) => `<item id="ch${i}" href="ch${i}.xhtml" media-type="application/xhtml+xml"/>`)
|
||||
.join('\n ');
|
||||
const spineItems = chapters.map((_, i) => `<itemref idref="ch${i}"/>`).join('\n ');
|
||||
|
||||
files['content.opf'] = enc.encode(
|
||||
`<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<manifest>
|
||||
${manifestItems}
|
||||
</manifest>
|
||||
<spine>
|
||||
${spineItems}
|
||||
</spine>
|
||||
</package>`,
|
||||
);
|
||||
|
||||
for (let i = 0; i < chapters.length; i++) {
|
||||
const ch = chapters[i]!;
|
||||
files[`ch${i}.xhtml`] = enc.encode(
|
||||
`<html><body><div>${ch.content}</div></body></html>`,
|
||||
);
|
||||
}
|
||||
|
||||
return zipSync(files);
|
||||
}
|
||||
|
||||
let tmpDir: string;
|
||||
let cache: Cache;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'qa-epub-test-'));
|
||||
cache = new Cache(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function mockFetch(data: Uint8Array) {
|
||||
return spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(data, { status: 200 }),
|
||||
);
|
||||
}
|
||||
|
||||
describe('epub — parsing', () => {
|
||||
test('extracts the correct number of chapters', async () => {
|
||||
const epub = buildEpub([
|
||||
{ title: 'Ch 1', content: 'First chapter content here.' },
|
||||
{ title: 'Ch 2', content: 'Second chapter content here.' },
|
||||
{ title: 'Ch 3', content: 'Third chapter content here.' },
|
||||
]);
|
||||
const spy = mockFetch(epub);
|
||||
|
||||
let rangeStart = -1, rangeEnd = -1;
|
||||
const view = await fetchEpub('http://fake/book.epub', 1, 0, 2, cache, (s, e) => {
|
||||
rangeStart = s; rangeEnd = e;
|
||||
});
|
||||
|
||||
expect(view.chapters).toHaveLength(3);
|
||||
expect(rangeStart).toBe(0);
|
||||
expect(rangeEnd).toBe(2);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('serves from cache on second call without re-fetching', async () => {
|
||||
const epub = buildEpub([
|
||||
{ title: 'Ch 1', content: 'Chapter one long enough content.' },
|
||||
]);
|
||||
const spy = mockFetch(epub);
|
||||
|
||||
await fetchEpub('http://fake/book.epub', 2, 0, 0, cache, () => {});
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call — should hit cache
|
||||
await fetchEpub('http://fake/book.epub', 2, 0, 0, cache, () => {});
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('skips blank chapters', async () => {
|
||||
const epub = buildEpub([
|
||||
{ title: 'Cover', content: ' ' }, // blank — should be skipped
|
||||
{ title: 'Ch 1', content: 'Real content here for this chapter.' },
|
||||
{ title: 'Ch 2', content: 'More real content for the next chapter.' },
|
||||
]);
|
||||
const spy = mockFetch(epub);
|
||||
|
||||
let rangeEnd = -1;
|
||||
await fetchEpub('http://fake/book.epub', 3, 0, 10, cache, (_, e) => { rangeEnd = e; });
|
||||
expect(rangeEnd).toBe(1); // only 2 non-blank chapters (indices 0 and 1)
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('throws on HTTP error', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('Not Found', { status: 404 }),
|
||||
);
|
||||
await expect(fetchEpub('http://fake/missing.epub', 4, 0, 0, cache, () => {})).rejects.toThrow(
|
||||
'HTTP 404',
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('slices to the requested range', async () => {
|
||||
const epub = buildEpub([
|
||||
{ title: 'Ch 0', content: 'Chapter zero has enough content here.' },
|
||||
{ title: 'Ch 1', content: 'Chapter one has enough content here.' },
|
||||
{ title: 'Ch 2', content: 'Chapter two has enough content here.' },
|
||||
{ title: 'Ch 3', content: 'Chapter three has enough content here.' },
|
||||
]);
|
||||
const spy = mockFetch(epub);
|
||||
|
||||
const view = await fetchEpub('http://fake/book.epub', 5, 1, 2, cache, () => {});
|
||||
expect(view.min).toBe(1);
|
||||
expect(view.max).toBe(2);
|
||||
expect(view.chapters).toHaveLength(2);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user