todo-app/test/local_backup_test.dart
Krzysztof kuhy Rudnicki 01411810da Cut per-keystroke O(N) work to keep capture smooth at scale
The capture screen fed the on-disk BACKLOG.md backup off an always-live
watchNotes() subscription, so every keystroke re-queried the whole table,
re-parsed every row, and rebuilt the full Markdown export on the UI
isolate — O(notes) per character. It also fired a whole-screen setState
just to update the "Saved locally" timestamp.

- Add NoteRepository.changes: an O(1) broadcast tick after each write
  (upsert/delete/merge; importNotes transitively). LocalBackup now pulls
  notes via an injected fetcher only when its 2s debounce fires, so the
  query + parse + Markdown export runs at most once per idle window
  instead of on every keystroke. Capture listens to changes, not
  watchNotes; a launch-time schedule() keeps BACKLOG.md fresh.
- Scope the save indicator to a ValueNotifier/ValueListenableBuilder so a
  keystroke updates one line of text, not the AppBar/dropdowns/editor.
- Key list tiles by note id for identity-based reorder diffing.

Measured on a 4K/144Hz desktop the UI-thread build stays <=3.3ms;
coverage held at 100% on all changed files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:51:46 +02:00

120 lines
3.6 KiB
Dart

import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/note.dart';
import 'package:todo/sync/local_backup.dart';
import 'package:todo/sync/notes_markdown.dart';
void main() {
Note note(String id, String text) => Note(
id: id,
text: text,
priority: Priority.medium,
status: Status.todo,
createdAt: DateTime(2026, 6, 15),
updatedAt: DateTime(2026, 6, 15),
);
group('schedule', () {
test('zero debounce fetches and writes the markdown immediately', () {
fakeAsync((async) {
String? written;
final backup = LocalBackup(
fetch: () async => [note('a', '# A')],
reader: () async => null,
writer: (md) async => written = md,
debounce: Duration.zero,
);
backup.schedule();
async.flushMicrotasks(); // drain the async fetch + write
expect(written, isNotNull);
expect(written, contains('# A'));
backup.dispose();
});
});
test('a burst of schedules fetches once, at the latest snapshot', () {
fakeAsync((async) {
var fetches = 0;
var current = [note('a', '# first')];
final writes = <String>[];
final backup = LocalBackup(
fetch: () async {
fetches++;
return current;
},
reader: () async => null,
writer: (md) async => writes.add(md),
debounce: const Duration(seconds: 2),
);
backup.schedule();
async.elapse(const Duration(seconds: 1)); // not yet
current = [note('a', '# second')]; // pulled lazily at fire time
backup.schedule(); // resets the timer
expect(fetches, 0); // nothing pulled while typing
expect(writes, isEmpty);
async.elapse(const Duration(seconds: 2));
async.flushMicrotasks();
expect(fetches, 1); // two schedules → a single export
expect(writes, hasLength(1));
expect(writes.single, contains('# second'));
backup.dispose();
});
});
test('dispose cancels a pending write', () {
fakeAsync((async) {
var calls = 0;
final backup = LocalBackup(
fetch: () async => [note('a', '# x')],
reader: () async => null,
writer: (_) async => calls++,
debounce: const Duration(seconds: 2),
);
backup.schedule();
backup.dispose();
async.elapse(const Duration(seconds: 5));
async.flushMicrotasks();
expect(calls, 0); // timer was cancelled before it could fetch/write
});
});
});
group('recover', () {
test('parses a backup file into notes', () async {
final markdown = NotesMarkdown.export([note('a', '# Recovered')]);
final backup = LocalBackup(
fetch: () async => const <Note>[],
reader: () async => markdown,
writer: (_) async {},
);
final recovered = await backup.recover();
expect(recovered, hasLength(1));
expect(recovered.single.text, contains('# Recovered'));
});
test('returns empty when there is no backup file', () async {
final backup = LocalBackup(
fetch: () async => const <Note>[],
reader: () async => null,
writer: (_) async {},
);
expect(await backup.recover(), isEmpty);
});
test('returns empty when the backup file is blank', () async {
final backup = LocalBackup(
fetch: () async => const <Note>[],
reader: () async => ' \n ',
writer: (_) async {},
);
expect(await backup.recover(), isEmpty);
});
});
}