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