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:
2026-06-23 02:53:28 +08:00
parent d50087247c
commit f64e6561a0

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