test(p0): SaveScheduler debounce/snapshot/flush/dispose coverage (step 8)
Closes a P0 step-8 test gap. Drives SaveScheduler with a recording EditorRepository subclass (real in-memory ffi db only to satisfy the ctor) and pins: flush writes immediately; rapid schedules coalesce to ONE debounced write with the latest snapshot; the captured snapshot is isolated from later mutation of the source list; distinct hosts flush independently; dispose cancels a pending write; schedule-after-dispose is a no-op. flutter analyze clean; 6/6 new, 84/84 total. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
137
test/save_scheduler_test.dart
Normal file
137
test/save_scheduler_test.dart
Normal file
@@ -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<EditorStroke> strokes})> calls =
|
||||
[];
|
||||
|
||||
@override
|
||||
Future<void> saveHost(
|
||||
String hostKind,
|
||||
String hostId,
|
||||
List<EditorStroke> 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<void>.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<void>.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<void>.delayed(const Duration(milliseconds: 60));
|
||||
|
||||
expect(repo.calls, isEmpty);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user