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