All checks were successful
CI / Windows build (push) Successful in 15m50s
Search now covers handwriting, the PDF text layer, AND scanned (rasterized) PDFs. - PdfTextIndexer runs at import: sums the embedded text layer across pages; if present it stores that as the document body, otherwise the PDF is rasterized and its rendered pages are OCR'd in the background. The result lands in the sidecar `pageText` field (distinct from `ocrText`, the handwriting OCR). Idempotent (skips a sidecar that already has pageText); degrades gracefully with no OCR engine. - pdfrx_page_text_source abstracts text/render so it's testable. - VaultSearchIndex now harvests title + typed text + handwriting OCR + PDF pageText, so search finds notes, typed PDFs and scanned PDFs. analyze clean, 409 tests green.
216 lines
7.7 KiB
Dart
216 lines
7.7 KiB
Dart
// 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: <vault>/<folder>/<file>.pdf + sidecar.
|
|
Future<void> seedDocNotebook({
|
|
required String folder,
|
|
required String pdfName,
|
|
List<EditorStroke> 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: <vault>/<folder>/notebook.badnote.json
|
|
Future<void> seedNote({
|
|
required String folder,
|
|
required String title,
|
|
List<EditorStroke> 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 `<folder>/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);
|
|
});
|
|
}
|