// test/vault_search_index_test.dart // // Phase 6: the search index is rebuilt by SCANNING the vault sidecars (the // source of truth), NOT the SQLite cache. These tests seed a real vault on disk // — a file-backed PDF notebook and a standalone free-ink notebook, each with a // sidecar carrying typed text and/or handwriting OCR text — then assert // VaultSearchIndex finds them by: // * the title / source filename, // * a typed text box (EditorStroke.textContent), // * the persisted handwriting OCR text (sidecar `ocrText`), // * the PDF document-body text captured at import (sidecar `pageText`): both // the embedded text layer AND the background-OCR result for a scanned PDF, // * a CJK substring (this user writes Chinese). import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:shared_preferences/shared_preferences.dart'; import 'package:badnote/editor/engine/stroke_model.dart'; import 'package:badnote/services/vault_search_index.dart'; import 'package:badnote/services/vault_service.dart'; import 'package:badnote/storage/badnote_sidecar.dart'; import 'package:badnote/storage/sidecar_store.dart'; EditorStroke _textStroke(String text) => EditorStroke( id: 't_$text', points: const [EditorPoint(x: 0.1, y: 0.2, pressure: 0.5)], tool: EditorTool.pen, color: 0xFF000000, width: 0.005, textContent: text, ); void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory vaultDir; late VaultService vault; setUp(() async { SharedPreferences.setMockInitialValues({}); vaultDir = await Directory.systemTemp.createTemp('vault_search_test'); final prefs = await SharedPreferences.getInstance(); vault = VaultService.forTest(prefs); await vault.setVaultRoot(vaultDir.path); }); tearDown(() async { if (await vaultDir.exists()) await vaultDir.delete(recursive: true); }); // Seed one file-backed PDF notebook: //.pdf + sidecar. Future seedDocNotebook({ required String folder, required String pdfName, List page0 = const [], String? ocrText, String? pageText, }) async { final dir = Directory(p.join(vaultDir.path, folder)); await dir.create(recursive: true); final pdfPath = p.join(dir.path, pdfName); await File(pdfPath).writeAsString('%PDF-1.7 fake'); final sidecar = BadnoteSidecar( sourceFile: pdfName, docType: 'pdf', strokes: page0.isEmpty ? null : {0: page0}, ocrText: ocrText, pageText: pageText, createdAt: DateTime.now().toUtc(), ); await SidecarStore.writeAtomic( File('$pdfPath$kVaultSidecarSuffix'), sidecar, ); } // Seed one standalone free-ink notebook: //notebook.badnote.json Future seedNote({ required String folder, required String title, List page0 = const [], String? ocrText, }) async { final dir = Directory(p.join(vaultDir.path, folder)); await dir.create(recursive: true); final sidecar = BadnoteSidecar( docType: 'notebook', title: title, strokes: page0.isEmpty ? null : {0: page0}, ocrText: ocrText, createdAt: DateTime.now().toUtc(), ); await SidecarStore.writeAtomic( File(p.join(dir.path, kNotebookSidecarName)), sidecar, ); } test('finds a file-backed PDF by its filename', () async { await seedDocNotebook(folder: 'Calculus Lecture 3', pdfName: 'Calculus.pdf'); final index = VaultSearchIndex(vault); final hits = await index.search('calculus'); expect(hits, hasLength(1)); expect(hits.single.entry.isNote, isFalse); expect(hits.single.entry.docType, 'pdf'); expect(hits.single.entry.openPath, endsWith('Calculus.pdf')); }); test('finds a typed text box inside a PDF sidecar', () async { await seedDocNotebook( folder: 'Notes', pdfName: 'doc.pdf', page0: [_textStroke('eigenvalue decomposition')], ); final index = VaultSearchIndex(vault); final hits = await index.search('eigenvalue'); expect(hits, hasLength(1)); expect(hits.single.entry.openPath, endsWith('doc.pdf')); }); test('finds a standalone note by handwriting OCR text', () async { await seedNote( folder: 'My freehand notes', title: 'Untitled', ocrText: 'remember the quadratic formula', ); final index = VaultSearchIndex(vault); final hits = await index.search('quadratic'); expect(hits, hasLength(1)); expect(hits.single.entry.isNote, isTrue); expect(hits.single.entry.docType, 'notebook'); // Opening a note re-keys its synthetic `/notebook` path. expect(hits.single.entry.openPath, endsWith(kNotebookBaseName)); }); test('finds a note by its title and a CJK substring', () async { await seedNote(folder: '数学笔记', title: '微积分笔记', ocrText: '导数与积分'); final index = VaultSearchIndex(vault); expect(await index.search('微积分'), hasLength(1)); // CJK OCR substring (no inter-word spaces) still matches. expect(await index.search('导数'), hasLength(1)); }); test('searches BOTH notes and docs in one query', () async { await seedDocNotebook( folder: 'Doc', pdfName: 'd.pdf', page0: [_textStroke('shared keyword apple')], ); await seedNote(folder: 'Note', title: 'n', ocrText: 'shared keyword apple'); final index = VaultSearchIndex(vault); final hits = await index.search('apple'); expect(hits, hasLength(2)); expect(hits.where((h) => h.entry.isNote), hasLength(1)); expect(hits.where((h) => !h.entry.isNote), hasLength(1)); }); test('empty query returns no hits', () async { await seedNote(folder: 'Note', title: 'anything'); final index = VaultSearchIndex(vault); expect(await index.search(' '), isEmpty); }); test('an empty/missing vault yields an empty index (never throws)', () async { final index = VaultSearchIndex(vault); await index.rebuild(); expect(index.entries, isEmpty); expect(await index.search('x'), isEmpty); }); test('finds a PDF by its embedded text layer (sidecar pageText)', () async { // The PDF body text captured at import lives in the sidecar's `pageText`. // No annotations at all — only the document body contains "bodytext". await seedDocNotebook( folder: 'Plain', pdfName: 'plain.pdf', pageText: 'introduction to bodytext and more printed content', ); final index = VaultSearchIndex(vault); final hits = await index.search('bodytext'); expect(hits, hasLength(1)); expect(hits.single.entry.openPath, endsWith('plain.pdf')); }); test('finds a SCANNED PDF by its background-OCR pageText (CJK)', () async { // A rasterized/scanned PDF has no text layer; the import-time OCR pass // writes the recognized text into the SAME `pageText` field, so search // covers scanned documents — including Chinese substrings. await seedDocNotebook( folder: '扫描讲义', pdfName: 'scanned.pdf', pageText: '微积分第三讲 导数的定义与几何意义', ); final index = VaultSearchIndex(vault); expect(await index.search('导数'), hasLength(1)); expect((await index.search('几何')).single.entry.openPath, endsWith('scanned.pdf')); }); test('a PDF with no pageText (un-indexed) is not found by body text', () async { // Back-compat: a PDF imported before the feature (or whose OCR backend was // unavailable) has no `pageText`; only its annotations are searchable. await seedDocNotebook(folder: 'Old', pdfName: 'old.pdf'); final index = VaultSearchIndex(vault); expect(await index.search('bodytext'), isEmpty); }); }