mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 18:07:03 +01:00
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>
120 lines
3.6 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|