todo-app/test/note_repository_test.dart
Krzysztof kuhy Rudnicki a874e09b65 Cut the llm-design-spec template from 12 sections to 7
Audited the template against 514 real Claude sessions (1503 top-level
prompts) joined to the 49 notes in BACKLOG.md. Sections were judged on
whether the agent actually read them, not on fill rate — four of them
said "leave blank if none" in their own helper, so a low fill rate was
the section working correctly.

Measured over the 22 note/session pairs:
  estimate  filled 22, referenced  1 (4%)
  done      filled 22, quoted back 4 (18%)
  refs      filled 14, URLs fetched 5 (35%)
  tech      the agent opened pubspec.yaml/CLAUDE.md anyway in 54%
  ask       100% ask rate with it, 92% without — no measurable effect

Kept: title, what, where, must, done. Dropped: tech (into `where`),
ask, nice and never (into `must` as `optional:` / `must not:` lines),
depends, estimate. Renamed `refs` -> `read first`. Added `verify`, the
only addition: "test it yourself, on the phone" was the single most
repeated correction in the corpus, and 3 of the 7 filled `never`
sections were already smuggling it in.

Fixes a latent data-loss bug found while testing this. parse() treats
an unknown `## heading` as content on purpose (values may contain
subheadings), so a legacy `## tech` block was folded into the preceding
section's value AND the note still reported conforming — opening any
old note in the stepper would have swallowed it on the next save.
NoteTemplate.retiredLabels now makes those notes explicitly
non-conforming, so they open raw and untouched.

tool/migrate_backlog.dart converts an old export (export -> migrate ->
import). It is idempotent and gated: it throws if a rewritten note does
not conform or if any source line vanishes. All 49 notes migrated on
the desktop and merged to the phone (0 new, 25 updated, 24 unchanged).

Also formats the few files dart format had never been run over, and
extracts a local in frame_stats_io.dart so the reflowed line stays
under 80 chars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 22:03:35 +02:00

667 lines
22 KiB
Dart

import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
// SqliteCrdt is needed to seed the legacy DB in migration tests; hide its Hlc
// so the crdt_sync Hlc/Record are unambiguous.
import 'package:sqlite_crdt/sqlite_crdt.dart' hide Hlc;
import 'package:path/path.dart' as p;
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repository_factory_io.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 exported log so the deletion syncs.
final log = repo.exportLog();
expect(log['b']!.deleted, isTrue);
});
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');
});
test('priority sort breaks ties by most-recently-updated', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
await repo.upsert(
note('a', 'older', priority: Priority.high, updatedAt: DateTime(2026)),
);
await repo.upsert(
note('b', 'newer', priority: Priority.high, updatedAt: DateTime(2026, 6)),
);
final notes = await repo.listNotes(sort: NoteSort.priorityDesc);
expect(notes.map((n) => n.text), ['newer', 'older']);
});
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('restores at the note\'s own edit time, not "now"', () async {
// Regression guard. Recovery used to stamp fresh clocks on every
// restored note, so a device that recovered from a backup outranked
// every other device under per-field last-writer-wins and silently
// overwrote newer edits made elsewhere. Observed for real: a restored
// desktop rewrote 245 of 330 field clocks to "now".
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
final edited = DateTime(2026, 3, 4, 5, 6, 7);
await repo.importNotes([
note('restored', 'from a backup', createdAt: edited, updatedAt: edited),
]);
final field = repo.exportLog()['restored']!.fields['text']!;
expect(
field.$2.wallTimeMs,
edited.millisecondsSinceEpoch,
reason:
'clock must come from updatedAt so a restore cannot outrank '
'genuinely newer data on another device',
);
});
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 {
SharedPreferences.setMockInitialValues({});
final dir = await Directory.systemTemp.createTemp('todo_migration');
final path = '${dir.path}/todo.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 openRepositoryIn(p.dirname(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 {
SharedPreferences.setMockInitialValues({});
final dir = await Directory.systemTemp.createTemp('todo_migration_v1');
final path = '${dir.path}/todo.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 openRepositoryIn(p.dirname(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, log export/import merge and close', () async {
// Each openInMemory repo owns its own in-memory log, so two of them model
// two independent devices directly.
final source = await NoteRepository.openInMemory(nodeId: 'source');
final target = await NoteRepository.openInMemory(nodeId: 'target');
addTearDown(target.close);
expect(source.nodeId, 'source');
await source.upsert(note('a', 'shared idea'));
final log = source.exportLog();
await source.close();
await target.importLog(log);
final merged = await target.listNotes();
expect(merged.single.text, 'shared idea');
});
group('migration from a legacy sqlite_crdt database', () {
// Writes a v3 legacy DB at [path] with one live note and one tombstone,
// exactly as the pre-crdt_sync app would have left it.
Future<String> seedLegacyDb(String dir) async {
final path = '$dir/todo.db';
final legacy = await SqliteCrdt.open(
path,
version: 3,
onCreate: (db, _) async {
await db.execute('''
CREATE TABLE notes (
id TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 2,
status INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (id)
)
''');
},
);
Future<void> insert(String id, String text, int status) => legacy.execute(
'INSERT INTO notes (id, text, priority, status, created_at, '
'updated_at) VALUES (?1, ?2, 3, ?3, ?4, ?4)',
[id, text, status, '2026-01-01T00:00:00.000'],
);
await insert('live', 'kept idea', Status.inProgress.value);
await insert('gone', 'deleted idea', Status.todo.value);
await legacy.execute('DELETE FROM notes WHERE id = ?1', ['gone']);
await legacy.close();
return path;
}
test('imports live notes and preserves tombstones, once', () async {
SharedPreferences.setMockInitialValues({});
final dir = await Directory.systemTemp.createTemp('todo_migrate');
addTearDown(() => dir.delete(recursive: true));
final path = await seedLegacyDb(dir.path);
final repo = await openRepositoryIn(p.dirname(path));
addTearDown(repo.close);
// The live note migrated with its fields intact...
final notes = await repo.listNotes();
expect(notes.map((n) => n.text), ['kept idea']);
expect(notes.single.priority, Priority.high);
expect(notes.single.status, Status.inProgress);
// ...and the deletion carried over as a sticky tombstone.
expect(repo.exportLog()['gone']!.deleted, isTrue);
expect(repo.nodeId, isNotEmpty);
// Reopening does not re-run migration: a note added after the first open
// survives, and the (still-present) legacy DB is not re-imported.
await repo.upsert(note('new', 'added post-migration'));
await repo.close();
final reopened = await openRepositoryIn(p.dirname(path));
addTearDown(reopened.close);
final texts = (await reopened.listNotes()).map((n) => n.text).toSet();
expect(texts, {'kept idea', 'added post-migration'});
});
test('a fresh install with no legacy DB starts empty', () async {
SharedPreferences.setMockInitialValues({});
final dir = await Directory.systemTemp.createTemp('todo_fresh');
addTearDown(() => dir.delete(recursive: true));
final repo = await openRepositoryIn(dir.path);
addTearDown(repo.close);
expect(await repo.listNotes(), isEmpty);
expect(repo.nodeId, isNotEmpty);
});
});
test('watchNotes emits the seed then re-emits after each change', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
final lengths = <int>[];
final sub = repo.watchNotes().listen((notes) => lengths.add(notes.length));
await repo.upsert(note('a', 'first'));
await repo.upsert(note('b', 'second'));
await pumpEventQueue();
await sub.cancel();
expect(lengths.first, 0); // seed
expect(lengths.last, 2); // after both writes
});
test('watchCount emits the seed then re-emits after a change', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
final counts = <int>[];
final sub = repo.watchCount().listen(counts.add);
await repo.upsert(note('a', 'x'));
await pumpEventQueue();
await sub.cancel();
expect(counts.first, 0);
expect(counts.last, 1);
});
test('a foreign record missing timestamps falls back to the epoch', () async {
// A note this app writes always has both timestamps; this guards a
// malformed record arriving from another device via importLog.
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
await repo.importLog({
'partial': Record(
id: 'partial',
fields: {'text': ('no dates', Hlc.newTick('other'))},
),
});
final only = (await repo.listNotes()).single;
expect(only.text, 'no dates');
final epoch = DateTime.fromMillisecondsSinceEpoch(0);
expect(only.createdAt, epoch);
expect(only.updatedAt, epoch);
});
test('date-range filters bound by whole calendar days', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
await repo.upsert(
note(
'jan',
'january idea',
createdAt: DateTime(2026, 1, 10),
updatedAt: DateTime(2026, 1, 10),
),
);
await repo.upsert(
note(
'mar',
'march idea',
createdAt: DateTime(2026, 3, 10),
updatedAt: DateTime(2026, 3, 10),
),
);
final byCreated = await repo.listNotes(
filter: NoteFilter(
createdFrom: DateTime(2026, 2, 1),
createdTo: DateTime(2026, 4, 1),
),
);
expect(byCreated.map((n) => n.text), ['march idea']);
final byUpdated = await repo.listNotes(
filter: NoteFilter(
updatedFrom: DateTime(2026, 1, 1),
updatedTo: DateTime(2026, 1, 31),
),
);
expect(byUpdated.map((n) => n.text), ['january 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);
});
});
group('changes', () {
test('ticks once per write across upsert, delete, and merge', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
var ticks = 0;
final sub = repo.changes.listen((_) => ticks++);
addTearDown(sub.cancel);
await repo.upsert(note('a', 'x'));
await repo.delete('a');
// A log from another device, merged in, must also tick.
final other = await NoteRepository.openInMemory(nodeId: 'other');
addTearDown(other.close);
await other.upsert(note('b', 'from other'));
await repo.importLog(other.exportLog());
await pumpEventQueue(); // let the broadcast events dispatch
expect(ticks, 3);
});
test('importNotes ticks once per note it writes', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
var ticks = 0;
final sub = repo.changes.listen((_) => ticks++);
addTearDown(sub.cancel);
await repo.importNotes([note('a', 'x'), note('b', 'y')]);
await pumpEventQueue();
expect(ticks, 2); // two new notes → two upserts → two ticks
});
});
}