test(unit): migrate batch2 document and parser suites to vitest
This commit is contained in:
parent
7570181b8a
commit
22a1b5de57
14 changed files with 161 additions and 52 deletions
|
|
@ -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.',
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
|
|
@ -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();
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
[
|
||||
|
|
@ -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']);
|
||||
54
tests/unit/support/document-fixtures.ts
Normal file
54
tests/unit/support/document-fixtures.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 },
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in a new issue