// test/pdf_text_indexer_test.dart // // Unit tests for the import-time PDF document-body indexer. The two native text // sources (pdfrx embedded-text loader + page-render OCR) are FAKED here — no // real pdfium render and no real OCR channel run in CI; those are exercised only // on-device (see PdfrxPageTextSource). These tests pin: // * the text-layer-vs-rasterized DECISION (empty text layer → OCR fallback), // * the background flow: embedded text OR OCR text lands in the sidecar's // `pageText`, // * IDEMPOTENCY: a sidecar that already has `pageText` is not re-indexed, // * graceful degradation: no OCR backend (empty OCR result) → no `pageText`, // no crash. import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:badnote/editor/persistence/sidecar_repository.dart'; import 'package:badnote/services/pdf_text_indexer.dart'; import 'package:badnote/storage/badnote_sidecar.dart'; import 'package:badnote/storage/sidecar_store.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory dir; late String pdfPath; setUp(() async { SidecarRepositoryRegistry.resetForTest(); dir = await Directory.systemTemp.createTemp('pdf_indexer_test'); pdfPath = p.join(dir.path, 'doc.pdf'); await File(pdfPath).writeAsString('%PDF-1.7 fake'); }); tearDown(() async { SidecarRepositoryRegistry.resetForTest(); if (await dir.exists()) await dir.delete(recursive: true); }); Future readSidecar() => SidecarStore.read(File('$pdfPath$kSidecarSuffix')); // A text-layer PDF: embedded loader returns real text, OCR should NOT run. test('a PDF with a usable text layer persists the embedded text', () async { var ocrCalled = false; final indexer = PdfTextIndexer( loadEmbeddedText: (_) async => ['Chapter one introduction', 'page two'], ocrPages: (_) async { ocrCalled = true; return ['should not be used']; }, ); final result = await indexer.indexPdf(pdfPath); expect(ocrCalled, isFalse, reason: 'text layer present → no OCR'); expect(result, contains('Chapter one introduction')); expect(result, contains('page two')); final sidecar = await readSidecar(); expect(sidecar?.pageText, contains('introduction')); }); // A rasterized/scanned PDF: empty embedded text → OCR fallback runs. test('a rasterized PDF (empty text layer) is OCR\'d into pageText', () async { var ocrCalled = false; final indexer = PdfTextIndexer( loadEmbeddedText: (_) async => ['', '', ''], // scanned: no text layer ocrPages: (_) async { ocrCalled = true; return ['扫描出的文字', 'recognized line two']; }, ); final result = await indexer.indexPdf(pdfPath); expect(ocrCalled, isTrue, reason: 'no text layer → OCR runs'); expect(result, contains('扫描出的文字')); final sidecar = await readSidecar(); expect(sidecar?.pageText, contains('扫描出的文字')); expect(sidecar?.pageText, contains('recognized line two')); }); test('the text-layer decision: empty/near-empty text → needs OCR', () { final indexer = PdfTextIndexer( loadEmbeddedText: (_) async => const [], ocrPages: (_) async => const [], textLayerThreshold: 16, ); // Empty pages → rasterized (needs OCR). expect(indexer.hasUsableTextLayer(['', '', '']), isFalse); // A few stray ligature chars below threshold → still rasterized. expect(indexer.hasUsableTextLayer([' ', 'fi', ' ']), isFalse); // Real text above threshold → usable text layer. expect( indexer.hasUsableTextLayer(['This is real printed body text']), isTrue, ); }); test('idempotent: a sidecar that already has pageText is not re-indexed', () async { // Pre-seed a sidecar with existing pageText. await SidecarStore.writeAtomic( File('$pdfPath$kSidecarSuffix'), BadnoteSidecar( sourceFile: 'doc.pdf', docType: 'pdf', pageText: 'already indexed body', ), ); var embeddedCalled = false; var ocrCalled = false; final indexer = PdfTextIndexer( loadEmbeddedText: (_) async { embeddedCalled = true; return ['new text']; }, ocrPages: (_) async { ocrCalled = true; return ['new ocr']; }, ); final result = await indexer.indexPdf(pdfPath); expect(result, isNull, reason: 'already indexed → no-op'); expect(embeddedCalled, isFalse); expect(ocrCalled, isFalse); // Existing text untouched. final sidecar = await readSidecar(); expect(sidecar?.pageText, 'already indexed body'); }); test('no OCR backend (empty OCR result) leaves pageText unset, no crash', () async { final indexer = PdfTextIndexer( loadEmbeddedText: (_) async => ['', ''], // rasterized ocrPages: (_) async => const [], // no OCR backend → clean no-op ); final result = await indexer.indexPdf(pdfPath); expect(result, isNull); final sidecar = await readSidecar(); // No sidecar written (nothing to index) — or, if present, no pageText. expect(sidecar?.pageText, isNull); }); test('writes THROUGH an open editor repo (no second on-disk race)', () async { // Open a repo for the same path: the indexer must write through it. final repo = await SidecarRepository.open(pdfPath, docType: 'pdf'); addTearDown(repo.dispose); final indexer = PdfTextIndexer( loadEmbeddedText: (_) async => ['printed body via open editor'], ocrPages: (_) async => const [], ); final result = await indexer.indexPdf(pdfPath); expect(result, contains('printed body')); // The in-memory sidecar held by the editor now carries the pageText. expect(repo.loadedPageText, contains('printed body')); // And it was flushed to disk. final sidecar = await readSidecar(); expect(sidecar?.pageText, contains('printed body')); }); test('preserves existing annotations when merging pageText on disk', () async { // A sidecar with annotations but no pageText (e.g. user annotated before the // background OCR finished). Indexing must not clobber the annotations. await SidecarStore.writeAtomic( File('$pdfPath$kSidecarSuffix'), BadnoteSidecar( sourceFile: 'doc.pdf', docType: 'pdf', ocrText: 'handwriting note', ), ); final indexer = PdfTextIndexer( loadEmbeddedText: (_) async => ['printed document body text here'], ocrPages: (_) async => const [], ); await indexer.indexPdf(pdfPath); final sidecar = await readSidecar(); expect(sidecar?.ocrText, 'handwriting note'); // preserved expect(sidecar?.pageText, contains('printed document body')); }); }