feat(storage): app-pause flush + vault search index
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Phase 6 (final storage phase). - SidecarRepositoryRegistry tracks every open repo; SidecarFlushObserver (a WidgetsBindingObserver in main) flushes them all on inactive/hidden/paused/detached, awaiting each flush — the last strokes can't be lost on app close, not just on the 800ms timer. - VaultSearchIndex rebuilds by scanning vault sidecars (the source of truth) — note titles, OCR text and document names — and search_provider queries it, so search spans notes + PDFs. Rebuilt on launch / after import. The vault file-based storage migration (Phases 0-6) is complete: annotations travel with the file, picked vault folder, atomic autosave, one Import-file entry, SQLite migrated to sidecars. analyze clean, tests green.
This commit is contained in:
122
test/sidecar_flush_observer_test.dart
Normal file
122
test/sidecar_flush_observer_test.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
// test/sidecar_flush_observer_test.dart
|
||||
//
|
||||
// Phase 6 / §F.3: lifecycle-flush hardening. Proves the data-safety guarantee
|
||||
// "never lose the last strokes on app close":
|
||||
// * an OPEN SidecarRepository registers itself in SidecarRepositoryRegistry;
|
||||
// * a pending (debounced, not-yet-fired) write is flushed to disk when the
|
||||
// SidecarFlushObserver receives a paused/inactive/detached lifecycle event,
|
||||
// WITHOUT waiting for the debounce timer;
|
||||
// * the flush is awaited (SidecarRepositoryRegistry.flushAll awaits each
|
||||
// repo.flush()), so the bytes are on disk before the process could freeze;
|
||||
// * dispose() unregisters the repo so it isn't flushed after closing.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||
import 'package:badnote/editor/persistence/sidecar_flush_observer.dart';
|
||||
import 'package:badnote/editor/persistence/sidecar_repository.dart';
|
||||
import 'package:badnote/storage/sidecar_store.dart';
|
||||
|
||||
// A long debounce so the timer NEVER fires during the test — only an explicit
|
||||
// flush (the lifecycle path) can persist the pending write.
|
||||
const _slow = Duration(seconds: 30);
|
||||
|
||||
EditorStroke _stroke(String id) => EditorStroke(
|
||||
id: id,
|
||||
points: const [EditorPoint(x: 0.1, y: 0.2, pressure: 0.5)],
|
||||
tool: EditorTool.pen,
|
||||
color: 0xFF112233,
|
||||
width: 0.005,
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late Directory tmpDir;
|
||||
late String src;
|
||||
|
||||
setUp(() async {
|
||||
SidecarRepositoryRegistry.resetForTest();
|
||||
tmpDir = await Directory.systemTemp.createTemp('flush_observer_test');
|
||||
src = '${tmpDir.path}/Lecture.pdf';
|
||||
await File(src).writeAsString('%PDF-1.7 fake');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
SidecarRepositoryRegistry.resetForTest();
|
||||
if (await tmpDir.exists()) await tmpDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('open registers the repo; dispose unregisters it', () async {
|
||||
final repo = await SidecarRepository.open(src, docType: 'pdf', debounce: _slow);
|
||||
expect(SidecarRepositoryRegistry.open, contains(repo));
|
||||
expect(SidecarRepositoryRegistry.forPath(src), same(repo));
|
||||
|
||||
repo.dispose();
|
||||
expect(SidecarRepositoryRegistry.open, isNot(contains(repo)));
|
||||
expect(SidecarRepositoryRegistry.forPath(src), isNull);
|
||||
});
|
||||
|
||||
test('a paused lifecycle event flushes a pending debounced write to disk',
|
||||
() async {
|
||||
final repo = await SidecarRepository.open(src, docType: 'pdf', debounce: _slow);
|
||||
addTearDown(repo.dispose);
|
||||
|
||||
// Schedule a write; the 30s debounce means nothing is on disk yet.
|
||||
repo.scheduleStrokeSave(0, [_stroke('s1')]);
|
||||
expect(await repo.sidecarFile.exists(), isFalse,
|
||||
reason: 'debounce has not fired and no lifecycle flush yet');
|
||||
|
||||
// The app goes to the background → the observer drains pending writes.
|
||||
final observer = SidecarFlushObserver()..attach();
|
||||
addTearDown(observer.detach);
|
||||
observer.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||||
|
||||
// flushAll() is fire-and-forget at the framework boundary; await the same
|
||||
// path the observer triggered so we can assert the bytes landed.
|
||||
await SidecarRepositoryRegistry.flushAll();
|
||||
|
||||
expect(await repo.sidecarFile.exists(), isTrue);
|
||||
final reloaded = await SidecarStore.read(repo.sidecarFile);
|
||||
expect(reloaded, isNotNull);
|
||||
expect(reloaded!.strokes[0]?.single.id, 's1');
|
||||
});
|
||||
|
||||
test('flushAll drains EVERY open repo', () async {
|
||||
final a = await SidecarRepository.open(
|
||||
'${tmpDir.path}/A.pdf', docType: 'pdf', debounce: _slow);
|
||||
final b = await SidecarRepository.open(
|
||||
'${tmpDir.path}/B.pdf', docType: 'pdf', debounce: _slow);
|
||||
addTearDown(a.dispose);
|
||||
addTearDown(b.dispose);
|
||||
|
||||
a.scheduleStrokeSave(0, [_stroke('a1')]);
|
||||
b.scheduleStrokeSave(0, [_stroke('b1')]);
|
||||
|
||||
await SidecarRepositoryRegistry.flushAll();
|
||||
|
||||
expect(await a.sidecarFile.exists(), isTrue);
|
||||
expect(await b.sidecarFile.exists(), isTrue);
|
||||
});
|
||||
|
||||
test('a disposed repo is not flushed by a later lifecycle event', () async {
|
||||
final repo = await SidecarRepository.open(src, docType: 'pdf', debounce: _slow);
|
||||
repo.scheduleStrokeSave(0, [_stroke('s1')]);
|
||||
// Closing the editor without flushing: dispose() cancels the timer AND
|
||||
// unregisters, so a later background event can't resurrect it.
|
||||
repo.dispose();
|
||||
|
||||
final observer = SidecarFlushObserver()..attach();
|
||||
addTearDown(observer.detach);
|
||||
observer.didChangeAppLifecycleState(AppLifecycleState.detached);
|
||||
await SidecarRepositoryRegistry.flushAll();
|
||||
|
||||
// The dropped repo wrote nothing (its pending edit is intentionally lost on
|
||||
// an explicit dispose-without-flush; the editors flush in their own
|
||||
// dispose() before calling this).
|
||||
expect(await repo.sidecarFile.exists(), isFalse);
|
||||
});
|
||||
}
|
||||
184
test/vault_search_index_test.dart
Normal file
184
test/vault_search_index_test.dart
Normal file
@@ -0,0 +1,184 @@
|
||||
// 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`),
|
||||
// * a CJK substring (this user writes Chinese).
|
||||
// It also documents the known GAP: a PDF's embedded text layer is NOT indexed.
|
||||
|
||||
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,
|
||||
}) 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,
|
||||
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('KNOWN GAP: a PDF embedded text layer is NOT indexed', () async {
|
||||
// The sidecar carries no annotations; only the PDF body would contain the
|
||||
// word "bodytext". Search does NOT read the PDF text layer (documented
|
||||
// limitation), so this returns nothing.
|
||||
await seedDocNotebook(folder: 'Plain', pdfName: 'plain.pdf');
|
||||
final index = VaultSearchIndex(vault);
|
||||
expect(await index.search('bodytext'), isEmpty);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user