feat(storage): app-pause flush + vault search index
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:
2026-06-24 23:19:21 +08:00
parent 4886f1b2df
commit 24d13642fd
9 changed files with 749 additions and 68 deletions

View 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);
});
}

View 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);
});
}