openreader/tests/unit/html-audiobook-adapter.vitest.spec.ts

154 lines
5.7 KiB
TypeScript

import { describe, expect, test } from 'vitest';
import { createHtmlAudiobookSourceAdapter } from '../../src/lib/client/audiobooks/adapters/html';
import { parseHtmlBlocks, type HtmlBlock } from '../../src/lib/client/html/blocks';
const blocksFromMd = (src: string): HtmlBlock[] => parseHtmlBlocks(src, false);
const blocksFromTxt = (src: string): HtmlBlock[] => parseHtmlBlocks(src, true);
describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', () => {
test('starts a new chapter at each h1/h2 heading by default', async () => {
const blocks = blocksFromMd(
[
'# Alpha',
'',
'First chapter body.',
'',
'## Beta',
'',
'Second chapter body.',
'',
'### Gamma',
'',
'Subsection — should NOT begin a new chapter (h3 by default).',
'',
'## Delta',
'',
'Third chapter body.',
].join('\n'),
);
const adapter = createHtmlAudiobookSourceAdapter({
blocks,
isTxt: false,
});
const chapters = await adapter.prepareChapters();
expect(chapters.map((c) => c.title)).toEqual(['Alpha', 'Beta', 'Delta']);
// Subsection content lands inside its parent chapter (Beta).
expect(chapters[1].text).toContain('Subsection');
});
test('treats blocks before the first heading as an "Introduction" chapter', async () => {
const blocks = blocksFromMd(
['Top-of-doc paragraph.', '', '# First Heading', '', 'Body.'].join('\n'),
);
const adapter = createHtmlAudiobookSourceAdapter({
blocks,
isTxt: false,
});
const chapters = await adapter.prepareChapters();
expect(chapters.map((c) => c.title)).toEqual(['Introduction', 'First Heading']);
expect(chapters[0].text).toContain('Top-of-doc paragraph.');
expect(chapters[1].text).toContain('Body.');
});
test('falls back to "Part N" chunks when markdown has no chapter-level headings', async () => {
// No #/## headings → fallback path. Use a small fallback size for the
// test so we don't have to generate 50+ blocks.
const blocks = blocksFromMd(
Array.from({ length: 7 }, (_, i) => `Paragraph ${i + 1}.`).join('\n\n'),
);
const adapter = createHtmlAudiobookSourceAdapter({
blocks,
isTxt: false,
fallbackBlocksPerChapter: 3,
});
const chapters = await adapter.prepareChapters();
expect(chapters.map((c) => c.title)).toEqual(['Part 1', 'Part 2', 'Part 3']);
// 7 blocks / 3 per chapter → 3, 3, 1.
expect(chapters[0].text).toContain('Paragraph 1.');
expect(chapters[0].text).toContain('Paragraph 3.');
expect(chapters[1].text).toContain('Paragraph 4.');
expect(chapters[1].text).toContain('Paragraph 6.');
expect(chapters[2].text).toContain('Paragraph 7.');
expect(chapters[2].text).not.toContain('Paragraph 6.');
});
test('honors a custom chapterHeadingLevel (e.g. h1 only)', async () => {
const blocks = blocksFromMd(
['# Alpha', '', 'A body.', '', '## Beta', '', 'B body.', '', '# Gamma', '', 'G body.'].join(
'\n',
),
);
const adapter = createHtmlAudiobookSourceAdapter({
blocks,
isTxt: false,
chapterHeadingLevel: 1,
});
const chapters = await adapter.prepareChapters();
// h2 (Beta) is absorbed into the Alpha chapter when only h1 starts chapters.
expect(chapters.map((c) => c.title)).toEqual(['Alpha', 'Gamma']);
expect(chapters[0].text).toContain('Beta');
expect(chapters[0].text).toContain('B body.');
});
});
describe('createHtmlAudiobookSourceAdapter (txt chapter splitting)', () => {
test('chunks TXT documents into "Part N" of 50 blocks by default', async () => {
const blocks = blocksFromTxt(
Array.from({ length: 120 }, (_, i) => `Block ${i + 1}.`).join('\n\n'),
);
expect(blocks.length).toBe(120);
const adapter = createHtmlAudiobookSourceAdapter({
blocks,
isTxt: true,
});
const chapters = await adapter.prepareChapters();
expect(chapters.map((c) => c.title)).toEqual(['Part 1', 'Part 2', 'Part 3']);
// First chapter contains blocks 1..50, last contains 101..120.
expect(chapters[0].text).toContain('Block 1.');
expect(chapters[0].text).toContain('Block 50.');
expect(chapters[0].text).not.toContain('Block 51.');
expect(chapters[2].text).toContain('Block 101.');
expect(chapters[2].text).toContain('Block 120.');
});
test('ignores headings in TXT mode (everything goes through the Part-N path)', async () => {
// A line that LOOKS like a markdown heading in a .txt file is still
// just text — we shouldn't carve a chapter at it.
const blocks = blocksFromTxt('# Looks like a heading\n\nBut TXT mode treats it as a paragraph.');
const adapter = createHtmlAudiobookSourceAdapter({
blocks,
isTxt: true,
});
const chapters = await adapter.prepareChapters();
expect(chapters.length).toBe(1);
expect(chapters[0].title).toBe('Part 1');
});
});
describe('createHtmlAudiobookSourceAdapter — prepareChapter', () => {
test('returns the same chapter by index that prepareChapters lists', async () => {
const blocks = blocksFromMd(['# A', '', 'one', '', '## B', '', 'two'].join('\n'));
const adapter = createHtmlAudiobookSourceAdapter({
blocks,
isTxt: false,
});
const list = await adapter.prepareChapters();
const second = await adapter.prepareChapter(1);
expect(second.title).toBe(list[1].title);
expect(second.text).toBe(list[1].text);
});
test('throws on out-of-range chapter index', async () => {
const blocks = blocksFromMd('# Only\n\nbody.');
const adapter = createHtmlAudiobookSourceAdapter({
blocks,
isTxt: false,
});
await expect(adapter.prepareChapter(42)).rejects.toThrow(/invalid chapter index/i);
});
});