todo-app/test/local_backup_test.dart
Krzysztof kuhy Rudnicki 6db9ee11d0 Auto-export a local Markdown backup and recover from it on launch
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>
2026-06-15 22:11:08 +02:00

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