mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 11:43:10 +02:00
Third durability layer beside GitHub auto-sync and Android Auto Backup: a plain, human/LLM-readable Markdown file kept current on local disk. - LocalBackup (lib/sync): pure, injectable file IO. scheduleExport() debounces writes (a burst of keystrokes → one export); recover() parses the file back into notes. Reused NotesMarkdown serializer. - CaptureScreen wires it: on launch, recover into an *empty* DB only (so a stale backup never clobbers existing notes), then keep the backup current as notes change. Platform path = ~/todo/BACKLOG.md on desktop (the path the user's workflow already reads) or the app documents dir on mobile (covered by Android Auto Backup). File IO is injected in tests. - Added fake_async dev dep to unit-test the debounce with a virtual clock. 151 tests, 100% line coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
101 lines
2.9 KiB
Dart
101 lines
2.9 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('scheduleExport', () {
|
|
test('zero debounce writes the exported markdown immediately', () {
|
|
String? written;
|
|
final backup = LocalBackup(
|
|
reader: () async => null,
|
|
writer: (md) async => written = md,
|
|
debounce: Duration.zero,
|
|
);
|
|
|
|
backup.scheduleExport([note('a', '# A')]);
|
|
expect(written, isNotNull);
|
|
expect(written, contains('# A'));
|
|
backup.dispose();
|
|
});
|
|
|
|
test('debounced writes coalesce to the latest snapshot', () {
|
|
fakeAsync((async) {
|
|
final writes = <String>[];
|
|
final backup = LocalBackup(
|
|
reader: () async => null,
|
|
writer: (md) async => writes.add(md),
|
|
debounce: const Duration(seconds: 2),
|
|
);
|
|
|
|
backup.scheduleExport([note('a', '# first')]);
|
|
async.elapse(const Duration(seconds: 1)); // not yet
|
|
backup.scheduleExport([note('a', '# second')]); // resets the timer
|
|
expect(writes, isEmpty);
|
|
|
|
async.elapse(const Duration(seconds: 2));
|
|
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(
|
|
reader: () async => null,
|
|
writer: (_) async => calls++,
|
|
debounce: const Duration(seconds: 2),
|
|
);
|
|
|
|
backup.scheduleExport([note('a', '# x')]);
|
|
backup.dispose();
|
|
async.elapse(const Duration(seconds: 5));
|
|
|
|
expect(calls, 0); // timer was cancelled
|
|
});
|
|
});
|
|
});
|
|
|
|
group('recover', () {
|
|
test('parses a backup file into notes', () async {
|
|
final markdown = NotesMarkdown.export([note('a', '# Recovered')]);
|
|
final backup = LocalBackup(
|
|
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(
|
|
reader: () async => null,
|
|
writer: (_) async {},
|
|
);
|
|
expect(await backup.recover(), isEmpty);
|
|
});
|
|
|
|
test('returns empty when the backup file is blank', () async {
|
|
final backup = LocalBackup(
|
|
reader: () async => ' \n ',
|
|
writer: (_) async {},
|
|
);
|
|
expect(await backup.recover(), isEmpty);
|
|
});
|
|
});
|
|
}
|