mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 11:43:10 +02:00
- Inject an optional http.Client into CaptureScreen (mirroring SettingsScreen) so the configured sync path runs against a MockClient instead of the network; capture_screen.dart now 100%. - Mock the file_selector and url_launcher platform interfaces and the clipboard channel so the import flow, _openPage launch, and the device-code dialog's error/Cancel/Open paths are exercised deterministically (no hangs, no timers). - Add unit tests for the remaining fallbacks/defaults: copyWith no-arg paths, GitHubApiException.toString, default-constructed clients, empty NoteFilter, the v1->v2 status-column migration, and the export/import error branches. - coverage:ignore the private static-only NotesMarkdown ctor. 101 tests, all green in ~5.5s. Line coverage 96.2% -> 100%. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
443 lines
15 KiB
Dart
443 lines
15 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
|
import 'package:sqlite_crdt/sqlite_crdt.dart';
|
|
import 'package:todo/data/note.dart';
|
|
import 'package:todo/data/note_repository.dart';
|
|
|
|
void main() {
|
|
setUpAll(sqfliteFfiInit);
|
|
|
|
Note note(
|
|
String id,
|
|
String text, {
|
|
Priority priority = Priority.medium,
|
|
Status status = Status.todo,
|
|
DateTime? createdAt,
|
|
DateTime? updatedAt,
|
|
}) {
|
|
final now = DateTime.now();
|
|
return Note(
|
|
id: id,
|
|
text: text,
|
|
priority: priority,
|
|
status: status,
|
|
createdAt: createdAt ?? now,
|
|
updatedAt: updatedAt ?? now,
|
|
);
|
|
}
|
|
|
|
test('upsert then list returns the note', () async {
|
|
final repo = await NoteRepository.openInMemory();
|
|
addTearDown(repo.close);
|
|
|
|
await repo.upsert(note('a', 'first idea'));
|
|
final notes = await repo.listNotes();
|
|
|
|
expect(notes, hasLength(1));
|
|
expect(notes.single.text, 'first idea');
|
|
});
|
|
|
|
test('deleted notes are excluded from reads (tombstone filter)', () async {
|
|
final repo = await NoteRepository.openInMemory();
|
|
addTearDown(repo.close);
|
|
|
|
await repo.upsert(note('a', 'keep me'));
|
|
await repo.upsert(note('b', 'delete me'));
|
|
await repo.delete('b');
|
|
|
|
final notes = await repo.listNotes();
|
|
expect(notes, hasLength(1));
|
|
expect(notes.single.text, 'keep me');
|
|
|
|
// The tombstone must survive in the changeset so the deletion syncs.
|
|
final changeset = await repo.getChangeset();
|
|
final rows = changeset['notes']!;
|
|
final deleted = rows.firstWhere((r) => r['id'] == 'b');
|
|
expect(deleted['is_deleted'], 1);
|
|
});
|
|
|
|
test('priority sort orders highest first', () async {
|
|
final repo = await NoteRepository.openInMemory();
|
|
addTearDown(repo.close);
|
|
|
|
await repo.upsert(note('a', 'low', priority: Priority.low));
|
|
await repo.upsert(note('b', 'high', priority: Priority.high));
|
|
|
|
final notes = await repo.listNotes(sort: NoteSort.priorityDesc);
|
|
expect(notes.first.text, 'high');
|
|
expect(notes.last.text, 'low');
|
|
});
|
|
|
|
group('text search', () {
|
|
test('matches a case-insensitive substring', () async {
|
|
final repo = await NoteRepository.openInMemory();
|
|
addTearDown(repo.close);
|
|
await repo.upsert(note('a', 'Buy MILK and eggs'));
|
|
await repo.upsert(note('b', 'call the dentist'));
|
|
|
|
final notes = await repo.listNotes(
|
|
filter: const NoteFilter(query: 'milk'),
|
|
);
|
|
expect(notes.map((n) => n.id), ['a']);
|
|
});
|
|
|
|
test('escapes LIKE wildcards so % is matched literally', () async {
|
|
final repo = await NoteRepository.openInMemory();
|
|
addTearDown(repo.close);
|
|
await repo.upsert(note('pct', 'a%b'));
|
|
await repo.upsert(note('plain', 'axb'));
|
|
|
|
// Without escaping, 'a%b' as a LIKE pattern would also match 'axb'.
|
|
final notes = await repo.listNotes(
|
|
filter: const NoteFilter(query: 'a%b'),
|
|
);
|
|
expect(notes.map((n) => n.id), ['pct']);
|
|
});
|
|
});
|
|
|
|
group('attribute filters', () {
|
|
test('priority filter includes only the selected priorities', () async {
|
|
final repo = await NoteRepository.openInMemory();
|
|
addTearDown(repo.close);
|
|
await repo.upsert(note('lo', 'l', priority: Priority.low));
|
|
await repo.upsert(note('me', 'm', priority: Priority.medium));
|
|
await repo.upsert(note('hi', 'h', priority: Priority.high));
|
|
|
|
final notes = await repo.listNotes(
|
|
filter: const NoteFilter(priorities: {Priority.low, Priority.high}),
|
|
);
|
|
expect(notes.map((n) => n.id).toSet(), {'lo', 'hi'});
|
|
});
|
|
|
|
test('status filter includes only the selected statuses', () async {
|
|
final repo = await NoteRepository.openInMemory();
|
|
addTearDown(repo.close);
|
|
await repo.upsert(note('t', 'todo', status: Status.todo));
|
|
await repo.upsert(note('d', 'done', status: Status.done));
|
|
await repo.upsert(note('x', 'gone', status: Status.abandoned));
|
|
|
|
final notes = await repo.listNotes(
|
|
filter: const NoteFilter(statuses: {Status.todo, Status.inProgress}),
|
|
);
|
|
expect(notes.map((n) => n.id), ['t']);
|
|
});
|
|
});
|
|
|
|
group('date range filters', () {
|
|
final jan = DateTime(2026, 1, 15, 10);
|
|
final jun = DateTime(2026, 6, 15, 10);
|
|
|
|
test('created range bounds are inclusive by calendar day', () async {
|
|
final repo = await NoteRepository.openInMemory();
|
|
addTearDown(repo.close);
|
|
await repo.upsert(note('j', 'jan', createdAt: jan, updatedAt: jan));
|
|
await repo.upsert(note('u', 'jun', createdAt: jun, updatedAt: jun));
|
|
|
|
// A single-day range on Jan 15 includes the 10:00 note that day.
|
|
final notes = await repo.listNotes(
|
|
filter: NoteFilter(
|
|
createdFrom: DateTime(2026, 1, 15),
|
|
createdTo: DateTime(2026, 1, 15),
|
|
),
|
|
);
|
|
expect(notes.map((n) => n.id), ['j']);
|
|
});
|
|
|
|
test('created and updated ranges apply independently', () async {
|
|
final repo = await NoteRepository.openInMemory();
|
|
addTearDown(repo.close);
|
|
// Created in Jan, but last updated in Jun.
|
|
await repo.upsert(
|
|
note('e', 'edited later', createdAt: jan, updatedAt: jun),
|
|
);
|
|
|
|
// Matches on the updated range...
|
|
final byUpdated = await repo.listNotes(
|
|
filter: NoteFilter(
|
|
updatedFrom: DateTime(2026, 6, 1),
|
|
updatedTo: DateTime(2026, 6, 30),
|
|
),
|
|
);
|
|
expect(byUpdated.map((n) => n.id), ['e']);
|
|
|
|
// ...but not when the created range excludes January.
|
|
final byCreated = await repo.listNotes(
|
|
filter: NoteFilter(
|
|
createdFrom: DateTime(2026, 6, 1),
|
|
createdTo: DateTime(2026, 6, 30),
|
|
),
|
|
);
|
|
expect(byCreated, isEmpty);
|
|
});
|
|
});
|
|
|
|
test('filters combine with AND', () async {
|
|
final repo = await NoteRepository.openInMemory();
|
|
addTearDown(repo.close);
|
|
await repo.upsert(
|
|
note(
|
|
'match',
|
|
'urgent report',
|
|
priority: Priority.high,
|
|
status: Status.inProgress,
|
|
),
|
|
);
|
|
await repo.upsert(
|
|
note(
|
|
'wrongPrio',
|
|
'urgent report',
|
|
priority: Priority.low,
|
|
status: Status.inProgress,
|
|
),
|
|
);
|
|
await repo.upsert(
|
|
note(
|
|
'wrongText',
|
|
'casual note',
|
|
priority: Priority.high,
|
|
status: Status.inProgress,
|
|
),
|
|
);
|
|
|
|
final notes = await repo.listNotes(
|
|
filter: const NoteFilter(
|
|
query: 'urgent',
|
|
priorities: {Priority.high},
|
|
statuses: {Status.inProgress},
|
|
),
|
|
);
|
|
expect(notes.map((n) => n.id), ['match']);
|
|
});
|
|
|
|
group('priority defaults', () {
|
|
test('fromValue maps legacy/unknown values to medium', () {
|
|
expect(Priority.fromValue(0), Priority.medium); // old "none"
|
|
expect(Priority.fromValue(null), Priority.medium);
|
|
expect(Priority.fromValue(99), Priority.medium);
|
|
// Known values still round-trip.
|
|
expect(Priority.fromValue(1), Priority.low);
|
|
expect(Priority.fromValue(2), Priority.medium);
|
|
expect(Priority.fromValue(3), Priority.high);
|
|
});
|
|
});
|
|
|
|
group('importNotes (safe merge)', () {
|
|
test('adds notes whose id is not present locally', () async {
|
|
final repo = await NoteRepository.openInMemory();
|
|
addTearDown(repo.close);
|
|
await repo.upsert(note('a', 'local'));
|
|
|
|
final outcome = await repo.importNotes([note('b', 'incoming')]);
|
|
|
|
expect(outcome.added, 1);
|
|
expect(outcome.updated, 0);
|
|
expect(outcome.skipped, 0);
|
|
expect((await repo.listNotes()).map((n) => n.id).toSet(), {'a', 'b'});
|
|
});
|
|
|
|
test('overwrites a local note only when the import is newer', () async {
|
|
final repo = await NoteRepository.openInMemory();
|
|
addTearDown(repo.close);
|
|
final old = DateTime(2026, 1, 1);
|
|
final newer = DateTime(2026, 6, 1);
|
|
await repo.upsert(note('a', 'local-old', updatedAt: old));
|
|
|
|
final outcome = await repo.importNotes([
|
|
note('a', 'imported-new', updatedAt: newer),
|
|
]);
|
|
|
|
expect(outcome.updated, 1);
|
|
final stored = (await repo.listNotes()).single;
|
|
expect(stored.text, 'imported-new');
|
|
});
|
|
|
|
test('never clobbers a newer local edit with a stale import', () async {
|
|
final repo = await NoteRepository.openInMemory();
|
|
addTearDown(repo.close);
|
|
final stale = DateTime(2026, 1, 1);
|
|
final fresh = DateTime(2026, 6, 1);
|
|
// Local note is the freshly-edited one.
|
|
await repo.upsert(note('a', 'local-fresh', updatedAt: fresh));
|
|
|
|
final outcome = await repo.importNotes([
|
|
note('a', 'backup-stale', updatedAt: stale),
|
|
]);
|
|
|
|
expect(outcome.skipped, 1);
|
|
expect(outcome.updated, 0);
|
|
// The newer local edit survives — "never lose ideas".
|
|
expect((await repo.listNotes()).single.text, 'local-fresh');
|
|
});
|
|
});
|
|
|
|
test('v2→v3 migration backfills priority 0 to medium', () async {
|
|
final dir = await Directory.systemTemp.createTemp('todo_migration');
|
|
final path = '${dir.path}/notes.db';
|
|
addTearDown(() => dir.delete(recursive: true));
|
|
|
|
// Build a v2 database (status column present, no priority backfill) and
|
|
// insert a legacy note with the old priority 0 ("none").
|
|
final v2 = await SqliteCrdt.open(
|
|
path,
|
|
version: 2,
|
|
onCreate: (db, version) async {
|
|
await db.execute('''
|
|
CREATE TABLE notes (
|
|
id TEXT NOT NULL,
|
|
text TEXT NOT NULL DEFAULT '',
|
|
priority INTEGER NOT NULL DEFAULT 0,
|
|
status INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (id)
|
|
)
|
|
''');
|
|
},
|
|
);
|
|
final now = DateTime.now().toIso8601String();
|
|
await v2.execute(
|
|
'INSERT INTO notes (id, text, priority, status, created_at, updated_at) '
|
|
'VALUES (?1, ?2, ?3, ?4, ?5, ?6)',
|
|
['legacy', 'old idea', 0, 0, now, now],
|
|
);
|
|
await v2.close();
|
|
|
|
// Reopening through the repository runs onUpgrade to v3.
|
|
final repo = await NoteRepository.open(path);
|
|
addTearDown(repo.close);
|
|
final notes = await repo.listNotes();
|
|
|
|
expect(notes.single.id, 'legacy');
|
|
expect(notes.single.priority, Priority.medium);
|
|
});
|
|
|
|
test('v1→v2 migration adds the status column with a default', () async {
|
|
final dir = await Directory.systemTemp.createTemp('todo_migration_v1');
|
|
final path = '${dir.path}/notes.db';
|
|
addTearDown(() => dir.delete(recursive: true));
|
|
|
|
// v1 schema predates the status column entirely.
|
|
final v1 = await SqliteCrdt.open(
|
|
path,
|
|
version: 1,
|
|
onCreate: (db, version) async {
|
|
await db.execute('''
|
|
CREATE TABLE notes (
|
|
id TEXT NOT NULL,
|
|
text TEXT NOT NULL DEFAULT '',
|
|
priority INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (id)
|
|
)
|
|
''');
|
|
},
|
|
);
|
|
final now = DateTime.now().toIso8601String();
|
|
await v1.execute(
|
|
'INSERT INTO notes (id, text, priority, created_at, updated_at) '
|
|
'VALUES (?1, ?2, ?3, ?4, ?5)',
|
|
['old', 'pre-status idea', 1, now, now],
|
|
);
|
|
await v1.close();
|
|
|
|
// Reopening runs onUpgrade v1→v2 (adds status, default todo) then v2→v3.
|
|
final repo = await NoteRepository.open(path);
|
|
addTearDown(repo.close);
|
|
final notes = await repo.listNotes();
|
|
expect(notes.single.id, 'old');
|
|
expect(notes.single.status, Status.todo); // backfilled default
|
|
});
|
|
|
|
group('sorting and streams', () {
|
|
test('createdDesc and alphabetical orderings', () async {
|
|
final repo = await NoteRepository.openInMemory();
|
|
addTearDown(repo.close);
|
|
final t1 = DateTime(2026, 1, 1);
|
|
final t2 = DateTime(2026, 2, 1);
|
|
await repo.upsert(note('a', 'banana', createdAt: t1, updatedAt: t1));
|
|
await repo.upsert(note('b', 'apple', createdAt: t2, updatedAt: t2));
|
|
|
|
final byCreated = await repo.listNotes(sort: NoteSort.createdDesc);
|
|
expect(byCreated.first.id, 'b'); // newest created first
|
|
|
|
final alpha = await repo.listNotes(sort: NoteSort.alphabetical);
|
|
expect(alpha.map((n) => n.text), ['apple', 'banana']);
|
|
});
|
|
|
|
test('watchNotes and watchCount emit current state', () async {
|
|
final repo = await NoteRepository.openInMemory();
|
|
addTearDown(repo.close);
|
|
await repo.upsert(note('a', 'one'));
|
|
|
|
expect(await repo.watchNotes().first, hasLength(1));
|
|
expect(await repo.watchCount().first, 1);
|
|
});
|
|
});
|
|
|
|
test('nodeId, changeset merge and close', () async {
|
|
// Use file-backed DBs: two openInMemory repos would share one `:memory:`
|
|
// connection, so they cannot model two independent devices.
|
|
final dir = await Directory.systemTemp.createTemp('todo_merge');
|
|
addTearDown(() => dir.delete(recursive: true));
|
|
final source = await NoteRepository.open('${dir.path}/source.db');
|
|
final target = await NoteRepository.open('${dir.path}/target.db');
|
|
addTearDown(target.close);
|
|
|
|
expect(source.nodeId, isNotEmpty);
|
|
await source.upsert(note('a', 'shared idea'));
|
|
final changeset = await source.getChangeset();
|
|
await source.close();
|
|
|
|
// getChangeset serialises hlc/modified as Strings and returns read-only
|
|
// rows; merge expects mutable maps with Hlc objects. Rebuild them the
|
|
// way the sync layer does after its JSON round-trip.
|
|
final revived = {
|
|
for (final entry in changeset.entries)
|
|
entry.key: [
|
|
for (final record in entry.value)
|
|
{
|
|
...record,
|
|
'hlc': Hlc.parse(record['hlc'] as String),
|
|
'modified': Hlc.parse(record['modified'] as String),
|
|
},
|
|
],
|
|
};
|
|
await target.merge(revived);
|
|
final merged = await target.listNotes();
|
|
expect(merged.single.text, 'shared idea');
|
|
});
|
|
|
|
group('NoteFilter', () {
|
|
test('a default filter is empty (all facets cleared)', () {
|
|
// Evaluates the full conjunction in `isEmpty`, including the date bounds.
|
|
const filter = NoteFilter();
|
|
expect(filter.isEmpty, isTrue);
|
|
expect(filter.activeCount, 0);
|
|
});
|
|
|
|
test('a filter with any facet set is not empty', () {
|
|
expect(const NoteFilter(query: 'x').isEmpty, isFalse);
|
|
expect(const NoteFilter(statuses: {Status.done}).isEmpty, isFalse);
|
|
});
|
|
|
|
test('copyWith with no arguments preserves every facet', () {
|
|
final base = NoteFilter(
|
|
query: 'milk',
|
|
priorities: const {Priority.high},
|
|
statuses: const {Status.todo},
|
|
createdFrom: DateTime(2026, 1, 1),
|
|
updatedTo: DateTime(2026, 2, 2),
|
|
);
|
|
final clone = base.copyWith();
|
|
expect(clone.query, base.query);
|
|
expect(clone.priorities, base.priorities);
|
|
expect(clone.statuses, base.statuses);
|
|
expect(clone.createdFrom, base.createdFrom);
|
|
expect(clone.updatedTo, base.updatedTo);
|
|
});
|
|
});
|
|
}
|