mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 20:27:01 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Four optional note fields (repo, done_check, by, evidence) in the crdt_sync record, written only when set, so editing a plain idea stays wire-identical to v3 (note_repository_sync_compat_test). The log is schemaless: no stored schema version; the legacy sqlite reader stays at v3. NoteFilter gains a sources facet (ideas / every repo task / one repo); search also matches the repo. The notes list shows only open ideas by default (no badge); the filter sheet adds Ideas, Repo tasks and one chip per repo when repo tasks exist. The per-note sheet shows the task fields read-only. BACKLOG.md markers carry the new attributes, entity-escaped. CLI: --repo --done-check --by on add, --close <id> --evidence (refused without), --list [--repo] [--status], --search, and every id flag takes a unique 8-character prefix. --list prints id8, status, priority, repo, title. install_hooks.sh: the wrapper finds pre-commit's generated hook next to itself, so commits from a git worktree (where .git is a file) work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
162 lines
5.6 KiB
Dart
162 lines
5.6 KiB
Dart
// Wire compatibility between the v4 note mapping (repo task fields) and
|
|
// devices still running the v3 build, which knows only the five original
|
|
// fields. Both builds share crdt_sync 0.11.1, so the v3 side is modelled by
|
|
// its own note<->record mapping, copied verbatim below.
|
|
import 'package:crdt_sync/crdt_sync.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:todo/data/note.dart';
|
|
import 'package:todo/data/note_repository.dart';
|
|
|
|
/// The exact field set the v3 build writes for every note.
|
|
const _v3Keys = {'text', 'priority', 'status', 'created_at', 'updated_at'};
|
|
|
|
/// The v3 build's `_fieldsFor`, verbatim.
|
|
Map<String, Field> _v3FieldsFor(Note note, Hlc hlc) => {
|
|
'text': (note.text, hlc),
|
|
'priority': (note.priority.value, hlc),
|
|
'status': (note.status.value, hlc),
|
|
'created_at': (note.createdAt.toIso8601String(), hlc),
|
|
'updated_at': (note.updatedAt.toIso8601String(), hlc),
|
|
};
|
|
|
|
/// The v3 build's `_toNote`, verbatim: unknown fields are never looked at.
|
|
Note _v3Read(Record record) => Note(
|
|
id: record.id,
|
|
text: record.fields['text']?.$1 as String? ?? '',
|
|
priority: Priority.fromValue(record.fields['priority']?.$1 as int?),
|
|
status: Status.fromValue(record.fields['status']?.$1 as int?),
|
|
createdAt: DateTime.parse(record.fields['created_at']!.$1! as String),
|
|
updatedAt: DateTime.parse(record.fields['updated_at']!.$1! as String),
|
|
);
|
|
|
|
Note _task({String text = 'Fix the gate'}) {
|
|
final at = DateTime.utc(2026, 11);
|
|
return Note(
|
|
id: 'task-1',
|
|
text: text,
|
|
priority: Priority.high,
|
|
status: Status.todo,
|
|
createdAt: at,
|
|
updatedAt: at,
|
|
repo: 'kuhyx/todo',
|
|
doneCheck: 'flutter test "test/a b.dart"',
|
|
by: 'claude',
|
|
evidence: 'abc123 exit 0',
|
|
);
|
|
}
|
|
|
|
/// What a peer receives: the log as it travels through RTDB (JSON text).
|
|
Log _overTheWire(Log log) => logFromJson(logToJson(log));
|
|
|
|
void main() {
|
|
test(
|
|
'v3 peer merges and reads a v4 repo-task record without error',
|
|
() async {
|
|
final v4 = await NoteRepository.openInMemory(nodeId: 'v4');
|
|
await v4.upsert(_task());
|
|
|
|
final v3 = LogStore(persistence: MemoryPersistence(), nodeId: 'v3');
|
|
await v3.load();
|
|
await v3.replaceAll(
|
|
mergeLogs(v3.snapshot(), _overTheWire(v4.exportLog())),
|
|
);
|
|
|
|
final seen = _v3Read(v3.snapshot()['task-1']!);
|
|
expect(seen.text, 'Fix the gate');
|
|
expect(seen.priority, Priority.high);
|
|
// The v3 log still carries the fields it does not understand.
|
|
expect(v3.snapshot()['task-1']!.fields['repo']!.$1, 'kuhyx/todo');
|
|
},
|
|
);
|
|
|
|
test('a v3 edit drops the new fields locally; the next merge restores '
|
|
'them and keeps the v3 edit', () async {
|
|
final v4 = await NoteRepository.openInMemory(nodeId: 'v4');
|
|
await v4.upsert(_task());
|
|
|
|
final v3 = LogStore(persistence: MemoryPersistence(), nodeId: 'v3');
|
|
await v3.load();
|
|
await v3.replaceAll(mergeLogs(v3.snapshot(), _overTheWire(v4.exportLog())));
|
|
final edited = _v3Read(v3.snapshot()['task-1']!).copyWith(
|
|
text: 'Fix the gate (edited on v3)',
|
|
updatedAt: DateTime.utc(2026, 11, 2),
|
|
);
|
|
// LogStore.upsert replaces the whole record with the v3 field set.
|
|
await v3.upsert(
|
|
Record(id: edited.id, fields: _v3FieldsFor(edited, v3.nextHlc())),
|
|
);
|
|
expect(v3.snapshot()['task-1']!.fields.keys.toSet(), _v3Keys);
|
|
|
|
await v4.importLog(_overTheWire(v3.snapshot()));
|
|
final merged = (await v4.listNotes()).single;
|
|
expect(merged.text, 'Fix the gate (edited on v3)');
|
|
expect(merged.repo, 'kuhyx/todo');
|
|
expect(merged.doneCheck, 'flutter test "test/a b.dart"');
|
|
expect(merged.by, 'claude');
|
|
expect(merged.evidence, 'abc123 exit 0');
|
|
|
|
// And the v3 device gets them back on its next pull.
|
|
await v3.replaceAll(mergeLogs(v3.snapshot(), _overTheWire(v4.exportLog())));
|
|
expect(v3.snapshot()['task-1']!.fields['repo']!.$1, 'kuhyx/todo');
|
|
});
|
|
|
|
test('v4 reads a v3 record (no new fields) as a plain idea', () async {
|
|
final at = DateTime.utc(2026);
|
|
final idea = Note(
|
|
id: 'idea-1',
|
|
text: 'Idea',
|
|
priority: Priority.low,
|
|
status: Status.todo,
|
|
createdAt: at,
|
|
updatedAt: at,
|
|
);
|
|
final hlc = Hlc(wallTimeMs: 1, counter: 0, nodeId: 'v3');
|
|
final v4 = await NoteRepository.openInMemory(nodeId: 'v4');
|
|
await v4.importLog({
|
|
'idea-1': Record(id: 'idea-1', fields: _v3FieldsFor(idea, hlc)),
|
|
});
|
|
|
|
final read = (await v4.listNotes()).single;
|
|
expect(read.text, 'Idea');
|
|
expect(read.repo, isNull);
|
|
expect(read.doneCheck, isNull);
|
|
expect(read.by, isNull);
|
|
expect(read.evidence, isNull);
|
|
});
|
|
|
|
test('v4 editing a plain idea writes exactly the v3 field set', () async {
|
|
final v4 = await NoteRepository.openInMemory(nodeId: 'v4');
|
|
final at = DateTime.utc(2026);
|
|
final idea = Note(
|
|
id: 'idea-1',
|
|
text: 'Idea',
|
|
priority: Priority.medium,
|
|
status: Status.todo,
|
|
createdAt: at,
|
|
updatedAt: at,
|
|
);
|
|
await v4.upsert(idea);
|
|
await v4.upsert(idea.copyWith(text: 'Idea, edited', status: Status.done));
|
|
await v4.importNotes([idea.copyWith(updatedAt: DateTime.utc(2027))]);
|
|
|
|
final record = _overTheWire(v4.exportLog())['idea-1']!;
|
|
expect(record.fields.keys.toSet(), _v3Keys);
|
|
expect(_v3Read(record).text, 'Idea');
|
|
});
|
|
|
|
test('a restored repo task keeps its fields at the note\'s own clock', () {
|
|
final record = NoteRepository.recordAtNoteTime(_task(), 'n');
|
|
expect(record.fields.keys.toSet(), {
|
|
..._v3Keys,
|
|
'repo',
|
|
'done_check',
|
|
'by',
|
|
'evidence',
|
|
});
|
|
expect(
|
|
record.fields['repo']!.$2.wallTimeMs,
|
|
DateTime.utc(2026, 11).millisecondsSinceEpoch,
|
|
);
|
|
});
|
|
}
|