// Tests for SaveScheduler (P0 step 8): debounce coalescing, synchronous // snapshot capture, per-host independence, flush, and dispose. The scheduler's // value is its batching/timing logic, so we drive it with a recording repo that // records saveHost calls instead of touching the DB. // // Run via: // bash tool/test.sh test/save_scheduler_test.dart import 'package:flutter_test/flutter_test.dart'; // Database + inMemoryDatabasePath are re-exported by sqflite_common_ffi. import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:badnote/editor/engine/stroke_model.dart'; import 'package:badnote/editor/persistence/editor_repository.dart'; import 'package:badnote/editor/persistence/save_scheduler.dart'; class _RecordingRepo extends EditorRepository { _RecordingRepo(super.db); final List<({String kind, String host, List strokes})> calls = []; @override Future saveHost( String hostKind, String hostId, List strokes, ) async { calls.add((kind: hostKind, host: hostId, strokes: strokes)); } } EditorStroke _stroke(String id) => EditorStroke.create( id: id, points: const [ EditorPoint(x: 0.1, y: 0.2), EditorPoint(x: 0.3, y: 0.4), ], ); void main() { late Database db; late _RecordingRepo repo; setUpAll(() { sqfliteFfiInit(); databaseFactory = databaseFactoryFfi; }); setUp(() async { db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); repo = _RecordingRepo(db); }); tearDown(() async { await db.close(); }); test('flush writes pending immediately without waiting for the debounce', () async { final scheduler = SaveScheduler(repo, debounce: const Duration(seconds: 30)); scheduler.schedule('page', 'h1', [_stroke('a')]); expect(repo.calls, isEmpty, reason: 'debounce not elapsed yet'); await scheduler.flush(); expect(repo.calls, hasLength(1)); expect(repo.calls.single.host, 'h1'); expect(repo.calls.single.strokes.single.id, 'a'); scheduler.dispose(); }); test('rapid successive schedules coalesce into ONE write with the latest snapshot', () async { final scheduler = SaveScheduler(repo, debounce: const Duration(milliseconds: 20)); scheduler.schedule('page', 'h1', [_stroke('v1')]); scheduler.schedule('page', 'h1', [_stroke('v1'), _stroke('v2')]); scheduler.schedule('page', 'h1', [_stroke('v1'), _stroke('v2'), _stroke('v3')]); await Future.delayed(const Duration(milliseconds: 60)); expect(repo.calls, hasLength(1), reason: '3 schedules → 1 debounced write'); expect(repo.calls.single.strokes.map((s) => s.id), ['v1', 'v2', 'v3']); scheduler.dispose(); }); test('captured snapshot is isolated from later mutation of the source list', () async { final scheduler = SaveScheduler(repo, debounce: const Duration(seconds: 30)); final source = [_stroke('a')]; // Caller convention: pass a defensive copy. scheduler.schedule('page', 'h1', List.of(source)); // Mutating the source after scheduling must not affect the write. source.add(_stroke('b')); await scheduler.flush(); expect(repo.calls.single.strokes.map((s) => s.id), ['a']); scheduler.dispose(); }); test('distinct hosts are scheduled and flushed independently', () async { final scheduler = SaveScheduler(repo, debounce: const Duration(seconds: 30)); scheduler.schedule('page', 'h1', [_stroke('a')]); scheduler.schedule('page', 'h2', [_stroke('b')]); await scheduler.flush(); expect(repo.calls, hasLength(2)); expect(repo.calls.map((c) => c.host).toSet(), {'h1', 'h2'}); scheduler.dispose(); }); test('dispose cancels a pending write (nothing is persisted)', () async { final scheduler = SaveScheduler(repo, debounce: const Duration(milliseconds: 20)); scheduler.schedule('page', 'h1', [_stroke('a')]); scheduler.dispose(); await Future.delayed(const Duration(milliseconds: 60)); expect(repo.calls, isEmpty); }); test('schedule after dispose is a no-op', () async { final scheduler = SaveScheduler(repo, debounce: const Duration(milliseconds: 20)); scheduler.dispose(); scheduler.schedule('page', 'h1', [_stroke('a')]); await Future.delayed(const Duration(milliseconds: 60)); expect(repo.calls, isEmpty); }); }