feat(app): flashcards on append-only revisions

A studyset's screen lists its cards (reorderable; a marker on cards changed on two devices) and opens a card's screen with an editor for each side. Saving appends a revision whose parents are all current heads. mergeRevisions merges several heads per side against their newest common ancestor, as decided: different sides merge automatically, the same side changed differently is a conflict. The card screen then shows every version of that side with "Keep this", and saving is possible once each conflicted side is settled, which resolves the conflict for every device. Nothing is merged silently inside a side.

CardRepository watches a studyset's cards, creates at the end and moves by fractional index (fractional_indexing_dart, checked against the reference algorithm's test vectors), and deletes by blanking every revision first (ADR 0002, rule 2). Deleting a studyset now deletes its cards the same way before the studyset goes. Every person gets a CardRepository whose revisions name them as author. RichContent gains plainText for the list's previews.

Tests: the merge (chains, different and same sides, conflicts, resolving, three heads, missing parents, arrival order), the payload, the repository against a fake gateway, both blocs, and the widgets, including that cancelling a delete keeps the card. The sync tests, which need the running server, cover merging and conflicts across two devices and blank revisions on the server; their device helper is now shared in test/sync/support. AGENTS.md describes the revision pattern, the fractional order, and two tooling traps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-09-30 14:41:44 +02:00
commit 66886ef383
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,134 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_merge.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_revision.dart';
/// Rich content holding just [text], enough to tell versions apart.
RichContent text(String text) => RichContent({
'document': {'type': 'page', 'note': text},
});
CardContent card(String front, String back) =>
CardContent(front: text(front), back: text(back));
/// A revision with the id [id] (ids sort by creation, like UUID v7).
CardRevision revision(
String id,
CardContent content, {
Set<String> parents = const {},
}) => CardRevision(id: id, parents: parents, content: content);
void main() {
test('a card with one revision shows it', () {
final merged = mergeRevisions([revision('r1', card('big', 'groß'))])!;
expect(merged.content, card('big', 'groß'));
expect(merged.heads, {'r1'});
expect(merged.conflicts, isEmpty);
});
test('the newest revision of a chain is the current one', () {
final merged = mergeRevisions([
revision('r1', card('big', 'groß')),
revision('r2', card('large', 'groß'), parents: {'r1'}),
revision('r3', card('large', 'gross'), parents: {'r2'}),
])!;
expect(merged.content, card('large', 'gross'));
expect(merged.heads, {'r3'});
});
test('changes to different sides on two devices both survive', () {
final merged = mergeRevisions([
revision('r1', card('big', 'groß')),
revision('r2', card('large', 'groß'), parents: {'r1'}),
revision('r3', card('big', 'gross'), parents: {'r1'}),
])!;
expect(merged.content, card('large', 'gross'));
expect(merged.heads, {'r2', 'r3'});
expect(merged.conflicts, isEmpty);
});
test('the same change on two devices is no conflict', () {
final merged = mergeRevisions([
revision('r1', card('big', 'groß')),
revision('r2', card('large', 'groß'), parents: {'r1'}),
revision('r3', card('large', 'groß'), parents: {'r1'}),
])!;
expect(merged.content, card('large', 'groß'));
expect(merged.conflicts, isEmpty);
});
test('different changes to the same side are a conflict with every '
'version; the other side still merges', () {
final merged = mergeRevisions([
revision('r1', card('big', 'groß')),
revision('r2', card('large', 'groß'), parents: {'r1'}),
revision('r3', card('huge', 'gross'), parents: {'r1'}),
])!;
expect(merged.conflicts, {
CardSide.front: [text('large'), text('huge')],
});
// Until the person chooses, the newest version shows.
expect(merged.content, card('huge', 'gross'));
expect(merged.heads, {'r2', 'r3'});
});
test('a revision with both as parents resolves the conflict', () {
final merged = mergeRevisions([
revision('r1', card('big', 'groß')),
revision('r2', card('large', 'groß'), parents: {'r1'}),
revision('r3', card('huge', 'gross'), parents: {'r1'}),
revision('r4', card('large', 'gross'), parents: {'r2', 'r3'}),
])!;
expect(merged.content, card('large', 'gross'));
expect(merged.heads, {'r4'});
expect(merged.conflicts, isEmpty);
});
test('three devices merge the same way', () {
final merged = mergeRevisions([
revision('r1', card('big', 'groß')),
revision('r2', card('large', 'groß'), parents: {'r1'}),
revision('r3', card('big', 'gross'), parents: {'r1'}),
revision('r4', card('huge', 'groß'), parents: {'r1'}),
])!;
expect(merged.heads, {'r2', 'r3', 'r4'});
expect(merged.conflicts, {
CardSide.front: [text('large'), text('huge')],
});
expect(merged.content.back, text('gross'));
});
test('counts a revision whose parent has not arrived yet as it is', () {
final merged = mergeRevisions([
revision('r2', card('large', 'groß'), parents: {'r1'}),
])!;
expect(merged.content, card('large', 'groß'));
expect(merged.heads, {'r2'});
});
test('comes out the same whatever order the revisions arrive in', () {
final revisions = [
revision('r1', card('big', 'groß')),
revision('r2', card('large', 'groß'), parents: {'r1'}),
revision('r3', card('huge', 'gross'), parents: {'r1'}),
];
expect(
mergeRevisions(revisions.reversed),
mergeRevisions(revisions),
);
});
test('a card without revisions has no content', () {
expect(mergeRevisions([]), isNull);
});
}