diff --git a/test/save_scheduler_test.dart b/test/save_scheduler_test.dart new file mode 100644 index 0000000..8b630da --- /dev/null +++ b/test/save_scheduler_test.dart @@ -0,0 +1,137 @@ +// 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); + }); +}