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.
123 lines
4.8 KiB
Dart
123 lines
4.8 KiB
Dart
// 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);
|
|
});
|
|
}
|