test(unit): migrate batch2 document and parser suites to vitest

This commit is contained in:
Richard R 2026-05-30 11:15:19 -06:00
parent 7570181b8a
commit 22a1b5de57
14 changed files with 161 additions and 52 deletions

View file

@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import {
canonicalizeEpubSegmentAgainstSpineText,
@ -6,7 +6,7 @@ import {
} from '../../src/lib/client/epub/canonicalize-epub-segment';
import { planCanonicalTtsSegments } from '../../src/lib/shared/tts-segment-plan';
test.describe('canonicalizeEpubSegmentAgainstSpineText', () => {
describe('canonicalizeEpubSegmentAgainstSpineText', () => {
test('maps an exact sentence to the canonical segment identity', () => {
const spineText = [
'First section sentence with enough words to stand alone.',
@ -95,7 +95,7 @@ test.describe('canonicalizeEpubSegmentAgainstSpineText', () => {
});
});
test.describe('canonicalizeEpubSegmentsAgainstSpineText', () => {
describe('canonicalizeEpubSegmentsAgainstSpineText', () => {
test('maps overlap-boundary drift sentences to forward canonical segments', () => {
const sourceSentences = [
'The star was particularly bright when the station lights switched off for cycle night.',

View file

@ -1,10 +1,10 @@
import { test, expect } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { getMigratedDocumentFileName } from '../../src/lib/server/storage/docstore-legacy';
test.describe('Docstore Filename Safety', () => {
describe('docstore filename safety', () => {
const id = 'a'.repeat(64); // Simulate sha256 hex ID
test('should generate standard filename for short names', () => {
test('generates stable file names for short inputs', () => {
const name = 'test-file.pdf';
const result = getMigratedDocumentFileName(id, name);
expect(result).toBe(`${id}__test-file.pdf`);
@ -50,4 +50,13 @@ test.describe('Docstore Filename Safety', () => {
expect(result.length).toBe(240);
expect(result).not.toContain('truncated-');
});
test('drops path traversal and null-byte fragments from migrated file names', () => {
const dirtyName = '../nested/\u0000financial-report.pdf';
const result = getMigratedDocumentFileName(id, dirtyName);
expect(result).toBe(`${id}__financial-report.pdf`);
expect(result).not.toContain('..');
expect(result).not.toContain('\u0000');
});
});

View file

@ -1,16 +1,17 @@
import { test, expect } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import type { BaseDocument, EPUBDocument, HTMLDocument, PDFDocument } from '../../src/types/documents';
import { ensureCachedDocumentCore } from '../../src/lib/client/cache/documents';
import { makeBaseDocument } from './support/document-fixtures';
test.describe('document-cache-core', () => {
describe('document-cache-core', () => {
test('returns cached PDF without downloading', async () => {
const meta: BaseDocument = {
const meta: BaseDocument = makeBaseDocument({
id: 'pdf1',
name: 'a.pdf',
size: 10,
lastModified: Date.now(),
lastModified: 1_700_000_000_001,
type: 'pdf',
};
});
let downloads = 0;
const cached: PDFDocument = { ...meta, type: 'pdf', data: new ArrayBuffer(1) };
@ -32,13 +33,13 @@ test.describe('document-cache-core', () => {
});
test('downloads and stores on cache miss (EPUB)', async () => {
const meta: BaseDocument = {
const meta: BaseDocument = makeBaseDocument({
id: 'epub1',
name: 'b.epub',
size: 10,
lastModified: Date.now(),
lastModified: 1_700_000_000_002,
type: 'epub',
};
});
const store = new Map<string, PDFDocument | EPUBDocument | HTMLDocument>();
let downloads = 0;
@ -63,13 +64,13 @@ test.describe('document-cache-core', () => {
});
test('downloads, decodes, and stores HTML on cache miss', async () => {
const meta: BaseDocument = {
const meta: BaseDocument = makeBaseDocument({
id: 'html1',
name: 'c.txt',
size: 5,
lastModified: Date.now(),
lastModified: 1_700_000_000_003,
type: 'html',
};
});
const store = new Map<string, PDFDocument | EPUBDocument | HTMLDocument>();
let decodedCalls = 0;
@ -92,4 +93,22 @@ test.describe('document-cache-core', () => {
expect(result.type).toBe('html');
expect((result as HTMLDocument).data).toBe('hello');
});
test('throws deterministic cache error when download succeeds but backend misses persisted PDF', async () => {
const meta = makeBaseDocument({
id: 'pdf-cache-miss',
type: 'pdf',
});
await expect(
ensureCachedDocumentCore(meta, {
get: async () => null,
putPdf: async () => { /* simulate write path without persisted row */ },
putEpub: async () => { /* unused */ },
putHtml: async () => { /* unused */ },
download: async () => new Uint8Array([1, 2, 3]).buffer,
decodeText: () => '',
}),
).rejects.toThrow('Failed to cache PDF');
});
});

View file

@ -1,11 +1,11 @@
import { expect, test } from '@playwright/test';
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);
test.describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', () => {
describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', () => {
test('starts a new chapter at each h1/h2 heading by default', async () => {
const blocks = blocksFromMd(
[
@ -95,7 +95,7 @@ test.describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', (
});
});
test.describe('createHtmlAudiobookSourceAdapter (txt chapter splitting)', () => {
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'),
@ -130,7 +130,7 @@ test.describe('createHtmlAudiobookSourceAdapter (txt chapter splitting)', () =>
});
});
test.describe('createHtmlAudiobookSourceAdapter — prepareChapter', () => {
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({

View file

@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import {
mdToPlainText,
parseHtmlBlocks,
@ -6,7 +6,7 @@ import {
splitTxtBlocks,
} from '../../src/lib/client/html/blocks';
test.describe('parseHtmlBlocks (markdown)', () => {
describe('parseHtmlBlocks (markdown)', () => {
test('splits headings, paragraphs, and lists into separate blocks', () => {
const src = [
'# Title',
@ -55,7 +55,7 @@ test.describe('parseHtmlBlocks (markdown)', () => {
});
});
test.describe('parseHtmlBlocks (txt)', () => {
describe('parseHtmlBlocks (txt)', () => {
test('splits on blank-line boundaries and preserves intra-block whitespace', () => {
const src = 'First block\nmore.\n\nSecond block.\n\n\nThird block.';
const blocks = parseHtmlBlocks(src, true);
@ -73,7 +73,7 @@ test.describe('parseHtmlBlocks (txt)', () => {
});
});
test.describe('mdToPlainText badge/image stripping', () => {
describe('mdToPlainText badge/image stripping', () => {
// The bug: badge alt text was being kept in plainText. Since the rendered
// DOM is just an <img> with no text node, the sentence-highlight pattern
// matcher couldn't find those words and the WHOLE first-segment match
@ -124,7 +124,7 @@ test.describe('mdToPlainText badge/image stripping', () => {
});
});
test.describe('buildFullDocumentText-style integration (badge-only blocks)', () => {
describe('buildFullDocumentText-style integration (badge-only blocks)', () => {
// If a paragraph is composed only of badges, its plainText is empty after
// stripping. The reader filters empty plainText out of the TTS source
// (`useHtmlDocument#buildFullDocumentText`), so badge blocks don't generate

View file

@ -1,15 +1,15 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { createPdfAudiobookSourceAdapter } from '../../src/lib/client/audiobooks/adapters/pdf';
import type { ParsedPdfDocument } from '../../src/types/parsed-pdf';
import type { DocumentSettings } from '../../src/types/document-settings';
test.describe('pdf audiobook adapter', () => {
describe('pdf audiobook adapter', () => {
test('builds chapters from paragraph titles and filters skipped kinds', async () => {
const parsed: ParsedPdfDocument = {
schemaVersion: 1,
documentId: 'doc-1',
parserVersion: 'test',
parsedAt: Date.now(),
parsedAt: 1_700_000_000_000,
pages: [
{
pageNumber: 1,
@ -77,7 +77,7 @@ test.describe('pdf audiobook adapter', () => {
schemaVersion: 1,
documentId: 'doc-2',
parserVersion: 'test',
parsedAt: Date.now(),
parsedAt: 1_700_000_000_001,
pages: [
{
pageNumber: 1,

View file

@ -1,8 +1,8 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { buildPageTextFromBlocks } from '../../src/lib/client/pdf-block-text';
import type { ParsedPdfPage } from '../../src/types/parsed-pdf';
test.describe('buildPageTextFromBlocks', () => {
describe('buildPageTextFromBlocks', () => {
test('filters skipped kinds and preserves reading order', () => {
const page: ParsedPdfPage = {
pageNumber: 1,

View file

@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import {
FORCE_REPARSE_CONFIRM_MESSAGE,
FORCE_REPARSE_CONFIRM_TEXT,
@ -6,7 +6,7 @@ import {
isForceReparseDisabled,
} from '../../src/lib/client/pdf/force-reparse';
test.describe('pdf force reparse controls', () => {
describe('pdf force reparse controls', () => {
test('disables action while parse is pending or running', () => {
expect(isForceReparseDisabled('pending')).toBeTruthy();
expect(isForceReparseDisabled('running')).toBeTruthy();

View file

@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { mergeTextWithRegions } from '@openreader/compute-core';
test.describe('mergeTextWithRegions', () => {
describe('mergeTextWithRegions', () => {
test('assigns text items to containing regions by centroid and joins in reading order', () => {
const regions = [
{ bbox: [0, 0, 100, 50] as [number, number, number, number], label: 'text' as const },
@ -20,4 +20,18 @@ test.describe('mergeTextWithRegions', () => {
expect(merged[0].text).toBe('hello world');
expect(merged[1].text).toBe('Figure 1.2');
});
test('drops text whose centroid is outside every region', () => {
const regions = [
{ bbox: [0, 0, 50, 50] as [number, number, number, number], label: 'text' as const },
];
const textItems = [
{ text: 'inside', x: 10, y: 10, width: 10, height: 8 },
{ text: 'outside', x: 80, y: 80, width: 12, height: 8 },
];
const merged = mergeTextWithRegions(regions, textItems);
expect(merged).toHaveLength(1);
expect(merged[0].text).toBe('inside');
});
});

View file

@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { normalizeTextItemsForLayout } from '@openreader/compute-core';
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
@ -19,7 +19,7 @@ function makeTextItem(
} as unknown as TextItem;
}
test.describe('normalizeTextItemsForLayout', () => {
describe('normalizeTextItemsForLayout', () => {
test('keeps horizontal body text and drops rotated/skewed margin text', () => {
const horizontal = makeTextItem(
'Powered by large language models',
@ -36,4 +36,12 @@ test.describe('normalizeTextItemsForLayout', () => {
expect(normalized).toHaveLength(1);
expect(normalized[0]?.text).toBe('Powered by large language models');
});
test('drops malformed/vertical-only runs so downstream layout planning sees no body text', () => {
const vertical = makeTextItem('Side label', [0, 10, -10, 0, 30, 200]);
const skewed = makeTextItem('Watermark', [10, 5, 2, 10, 200, 500]);
const normalized = normalizeTextItemsForLayout([vertical, skewed], 800);
expect(normalized).toEqual([]);
});
});

View file

@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { stitchCrossPageBlocks } from '@openreader/compute-core';
import type { ParsedPdfBlock, ParsedPdfDocument, ParsedPdfBlockKind } from '../../src/types/parsed-pdf';
import { makeParsedPdfDocument, makeParsedPdfPage } from './support/document-fixtures';
function makeBlock(
id: string,
@ -23,19 +24,13 @@ function makeBlock(
}
function makeDoc(page1Blocks: ParsedPdfBlock[], page2Blocks: ParsedPdfBlock[]): ParsedPdfDocument {
return {
schemaVersion: 1,
documentId: 'doc',
parserVersion: 'test',
parsedAt: 0,
pages: [
{ pageNumber: 1, width: 100, height: 100, blocks: page1Blocks },
{ pageNumber: 2, width: 100, height: 100, blocks: page2Blocks },
],
};
return makeParsedPdfDocument([
makeParsedPdfPage(1, page1Blocks),
makeParsedPdfPage(2, page2Blocks),
]);
}
test.describe('stitchCrossPageBlocks', () => {
describe('stitchCrossPageBlocks', () => {
test('stitches paragraph continuation across footer/header noise', () => {
const doc = makeDoc(
[

View file

@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { buildPdfPageSourceUnits, buildPdfPrefetchPayload } from '../../src/lib/client/pdf-tts-planning';
import type { ParsedPdfPage } from '../../src/types/parsed-pdf';
@ -39,7 +39,7 @@ function buildPage(pageNumber: number): ParsedPdfPage {
};
}
test.describe('pdf tts planning helpers', () => {
describe('pdf tts planning helpers', () => {
test('buildPdfPageSourceUnits uses parsed blocks, honors skip kinds, and pins locator page', () => {
const page = buildPage(2);
const units = buildPdfPageSourceUnits(page, 2, ['header']);

View file

@ -0,0 +1,54 @@
import type { BaseDocument } from '../../../src/types/documents';
import type { ParsedPdfBlock, ParsedPdfBlockKind, ParsedPdfDocument, ParsedPdfPage } from '../../../src/types/parsed-pdf';
export function makeBaseDocument(overrides: Partial<BaseDocument> = {}): BaseDocument {
return {
id: 'doc-1',
name: 'document.pdf',
size: 1_024,
lastModified: 1_700_000_000_000,
type: 'pdf',
...overrides,
};
}
export function makeParsedPdfBlock(input: {
id: string;
kind: ParsedPdfBlockKind;
text: string;
page: number;
readingOrder: number;
}): ParsedPdfBlock {
return {
id: input.id,
kind: input.kind,
text: input.text,
fragments: [
{
page: input.page,
bbox: [0, 0, 100, 10],
text: input.text,
readingOrder: input.readingOrder,
},
],
};
}
export function makeParsedPdfPage(pageNumber: number, blocks: ParsedPdfBlock[]): ParsedPdfPage {
return {
pageNumber,
width: 800,
height: 1_200,
blocks,
};
}
export function makeParsedPdfDocument(pages: ParsedPdfPage[]): ParsedPdfDocument {
return {
schemaVersion: 1,
documentId: 'doc-fixture',
parserVersion: 'test',
parsedAt: 1_700_000_000_000,
pages,
};
}

View file

@ -2,7 +2,17 @@ import { defineConfig } from 'vitest/config';
import { fileURLToPath } from 'node:url';
const srcDir = fileURLToPath(new URL('./src/', import.meta.url));
const computeCoreIndex = fileURLToPath(new URL('./compute/core/src/index.ts', import.meta.url));
const computeCoreApiContracts = fileURLToPath(new URL('./compute/core/src/api-contracts/index.ts', import.meta.url));
const computeCoreControlPlane = fileURLToPath(new URL('./compute/core/src/control-plane/index.ts', import.meta.url));
const computeCoreLocalRuntime = fileURLToPath(new URL('./compute/core/src/local-runtime.ts', import.meta.url));
const computeCoreTypes = fileURLToPath(new URL('./compute/core/src/types/index.ts', import.meta.url));
const alias = [
{ find: /^@openreader\/compute-core$/, replacement: computeCoreIndex },
{ find: /^@openreader\/compute-core\/api-contracts$/, replacement: computeCoreApiContracts },
{ find: /^@openreader\/compute-core\/control-plane$/, replacement: computeCoreControlPlane },
{ find: /^@openreader\/compute-core\/local-runtime$/, replacement: computeCoreLocalRuntime },
{ find: /^@openreader\/compute-core\/types$/, replacement: computeCoreTypes },
{ find: /^@\//, replacement: `${srcDir}` },
{ find: '@', replacement: srcDir },
];