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
5 changed files with 2392 additions and 142 deletions

View file

@ -0,0 +1,190 @@
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/bloc/card_edit_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import '../../../support/fake_card_gateway.dart';
RichContent text(String text) => RichContent({
'document': {'type': 'page', 'note': text},
});
CardContent card(String front, String back) =>
CardContent(front: text(front), back: text(back));
void main() {
late FakeCardGateway gateway;
late CardRepository cards;
setUp(() {
gateway = FakeCardGateway();
cards = CardRepository(gateway);
});
tearDown(() => cards.dispose());
group('CardListBloc', () {
test('lists the cards and follows every change', () async {
await cards.create('japanese', card('猫', 'cat'));
final bloc = CardListBloc(cards, 'japanese')
..add(const CardListEventStarted());
addTearDown(bloc.close);
await expectLater(
bloc.stream,
emits(
isA<CardListStateLoaded>().having(
(s) => s.cards,
'cards',
hasLength(1),
),
),
);
await cards.create('japanese', card('犬', 'dog'));
await expectLater(
bloc.stream,
emits(
isA<CardListStateLoaded>().having(
(s) => s.cards,
'cards',
hasLength(2),
),
),
);
});
test('moves a card', () async {
await cards.create('japanese', card('a', ''));
await cards.create('japanese', card('b', ''));
final bloc = CardListBloc(cards, 'japanese')
..add(const CardListEventStarted());
addTearDown(bloc.close);
final loaded = await bloc.stream.firstWhere(
(state) => state is CardListStateLoaded,
) as CardListStateLoaded;
bloc.add(CardListEventMoved(loaded.cards.last, toIndex: 0));
await expectLater(
bloc.stream,
emits(
isA<CardListStateLoaded>().having(
(s) => [for (final card in s.cards) card.content.front],
'fronts',
[text('b'), text('a')],
),
),
);
});
});
group('CardEditBloc', () {
test('a new card can be saved once it has content, and then is '
'done', () async {
final bloc = CardEditBloc(cards, 'japanese', null);
addTearDown(bloc.close);
expect(bloc.state.canSave, isFalse);
bloc.add(CardEditEventSideChanged(CardSide.front, text('猫')));
await expectLater(
bloc.stream,
emits(isA<CardEditState>().having((s) => s.canSave, 'can save', true)),
);
bloc.add(const CardEditEventSaveRequested());
await expectLater(
bloc.stream,
emits(isA<CardEditState>().having((s) => s.isDone, 'done', true)),
);
expect(
(await cards.cardsOf('japanese')).single.content.front,
text('猫'),
);
});
test('an existing card can be saved only when changed', () async {
await cards.create('japanese', card('猫', 'cat'));
final bloc = CardEditBloc(
cards,
'japanese',
(await cards.cardsOf('japanese')).single,
);
addTearDown(bloc.close);
expect(bloc.state.canSave, isFalse);
bloc.add(CardEditEventSideChanged(CardSide.back, text('a cat')));
await expectLater(
bloc.stream,
emits(isA<CardEditState>().having((s) => s.canSave, 'can save', true)),
);
});
group('with a card changed differently on two devices', () {
late Card conflicted;
late CardEditBloc bloc;
setUp(() async {
await cards.create('japanese', card('猫', 'cat'));
final created = (await cards.cardsOf('japanese')).single;
await cards.save(created, card('猫', 'a cat'));
gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: created.id,
parents: created.heads,
payload: encodeCardPayload(card('猫', 'the cat')),
);
conflicted = (await cards.cardsOf('japanese')).single;
bloc = CardEditBloc(cards, 'japanese', conflicted);
});
tearDown(() => bloc.close());
test('waits for a version of every conflicted side', () {
expect(bloc.state.unresolvedSides, {CardSide.back});
expect(bloc.state.canSave, isFalse);
});
test('choosing a version resolves the side; saving resolves the '
'card', () async {
bloc.add(CardEditEventVersionChosen(CardSide.back, text('a cat')));
await expectLater(
bloc.stream,
emits(
isA<CardEditState>()
.having((s) => s.unresolvedSides, 'unresolved', isEmpty)
.having((s) => s.canSave, 'can save', true),
),
);
bloc.add(const CardEditEventSaveRequested());
await expectLater(
bloc.stream,
emits(isA<CardEditState>().having((s) => s.isDone, 'done', true)),
);
final resolved = (await cards.cardsOf('japanese')).single;
expect(resolved.hasConflict, isFalse);
expect(resolved.content.back, text('a cat'));
});
});
test('deleting the card is done too', () async {
await cards.create('japanese', card('猫', 'cat'));
final bloc = CardEditBloc(
cards,
'japanese',
(await cards.cardsOf('japanese')).single,
)..add(const CardEditEventDeleteRequested());
addTearDown(bloc.close);
await expectLater(
bloc.stream,
emits(isA<CardEditState>().having((s) => s.isDone, 'done', true)),
);
expect(await cards.cardsOf('japanese'), isEmpty);
});
});
}

View file

@ -0,0 +1,46 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
void main() {
ByteData bytes(Object json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));
RichContent text(String text) => RichContent({
'document': {'type': 'page', 'note': text},
});
test('a card payload survives a round trip, Japanese included', () {
final content = CardContent(front: text('猫'), back: text('cat'));
expect(decodeCardPayload(encodeCardPayload(content)), content);
});
test('refuses a schema newer than this app knows', () {
expect(
() => decodeCardPayload(
bytes({
'schemaVersion': cardSchemaVersion + 1,
'front': RichContent.empty().toJson(),
'back': RichContent.empty().toJson(),
}),
),
throwsFormatException,
);
});
test('refuses what is not a card payload, a blanked one included', () {
expect(() => decodeCardPayload(redactedPayload), throwsFormatException);
expect(
() => decodeCardPayload(
bytes({'schemaVersion': cardSchemaVersion, 'front': 'no'}),
),
throwsFormatException,
);
});
}

View file

@ -0,0 +1,192 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import '../../../support/fake_card_gateway.dart';
RichContent text(String text) => RichContent({
'document': {'type': 'page', 'note': text},
});
CardContent card(String front, String back) =>
CardContent(front: text(front), back: text(back));
void main() {
late FakeCardGateway gateway;
late CardRepository repository;
setUp(() {
gateway = FakeCardGateway();
repository = CardRepository(gateway);
});
tearDown(() => repository.dispose());
Iterable<String> fronts(List<Card> cards) => [
for (final card in cards)
(card.content.front.editorJson['document']! as Map)['note']! as String,
];
test('creates cards at the end of their studyset, in order', () async {
await repository.create('japanese', card('猫', 'cat'));
await repository.create('japanese', card('犬', 'dog'));
await repository.create('kanji', card('日', 'sun'));
expect(fronts(await repository.cardsOf('japanese')), ['猫', '犬']);
expect(fronts(await repository.cardsOf('kanji')), ['日']);
});
test('saving appends a revision that replaces the current one', () async {
final id = await repository.create('japanese', card('猫', 'cat'));
final created = (await repository.cardsOf('japanese')).single;
await repository.save(created, card('猫', 'a cat'));
final saved = (await repository.cardsOf('japanese')).single;
expect(saved.id, id);
expect(saved.content, card('猫', 'a cat'));
expect(gateway.written.last.parents, created.heads);
});
test('saving what is already stored writes nothing', () async {
await repository.create('japanese', card('猫', 'cat'));
final created = (await repository.cardsOf('japanese')).single;
await repository.save(created, card('猫', 'cat'));
expect(gateway.written, hasLength(1));
});
group('with an edit from another device', () {
late Card created;
setUp(() async {
await repository.create('japanese', card('猫', 'cat'));
created = (await repository.cardsOf('japanese')).single;
});
void remoteEdit(CardContent content) => gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: created.id,
parents: created.heads,
payload: encodeCardPayload(content),
);
test('different sides merge', () async {
await repository.save(created, card('ねこ', 'cat'));
remoteEdit(card('猫', 'a cat'));
final merged = (await repository.cardsOf('japanese')).single;
expect(merged.content, card('ねこ', 'a cat'));
expect(merged.hasConflict, isFalse);
});
test('the same side changed differently is a conflict; saving resolves '
'it', () async {
await repository.save(created, card('猫', 'a cat'));
remoteEdit(card('猫', 'the cat'));
final conflicted = (await repository.cardsOf('japanese')).single;
expect(conflicted.conflicts.keys, [CardSide.back]);
await repository.save(conflicted, card('猫', 'cat'));
final resolved = (await repository.cardsOf('japanese')).single;
expect(resolved.hasConflict, isFalse);
expect(resolved.content, card('猫', 'cat'));
expect(gateway.written.last.parents, conflicted.heads);
expect(conflicted.heads, hasLength(2));
});
});
test(
'moves a card between two others, to the start and to the end',
() async {
for (final front in ['a', 'b', 'c']) {
await repository.create('japanese', card(front, ''));
}
Future<List<Card>> cards() => repository.cardsOf('japanese');
await repository.move('japanese', (await cards())[2], toIndex: 1);
expect(fronts(await cards()), ['a', 'c', 'b']);
await repository.move('japanese', (await cards())[2], toIndex: 0);
expect(fronts(await cards()), ['b', 'a', 'c']);
await repository.move('japanese', (await cards())[0], toIndex: 2);
expect(fronts(await cards()), ['a', 'c', 'b']);
},
);
test('deleting blanks every revision before the card goes', () async {
final id = await repository.create('japanese', card('秘密', 'secret'));
await repository.save(
(await repository.cardsOf('japanese')).single,
card('秘密', 'a secret'),
);
await repository.delete((await repository.cardsOf('japanese')).single);
expect(gateway.deletions, ['blank $id', 'delete $id']);
expect(gateway.payloads.values, everyElement(redactedPayload));
expect(await repository.cardsOf('japanese'), isEmpty);
});
test("deletes a studyset's cards, each blanked first", () async {
final first = await repository.create('japanese', card('猫', 'cat'));
final second = await repository.create('japanese', card('犬', 'dog'));
await repository.create('kanji', card('日', 'sun'));
await repository.deleteAllOf('japanese');
expect(gateway.deletions, [
'blank $first',
'delete $first',
'blank $second',
'delete $second',
]);
expect(await repository.cardsOf('kanji'), hasLength(1));
});
test('follows edits from other devices', () async {
await repository.create('japanese', card('猫', 'cat'));
final created = (await repository.cardsOf('japanese')).single;
final watched = repository.watch('japanese');
final expectation = expectLater(
watched.map((cards) => cards.single.content),
emitsInOrder([card('猫', 'cat'), card('猫', 'a cat')]),
);
await Future<void>.delayed(Duration.zero);
gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: created.id,
parents: created.heads,
payload: encodeCardPayload(card('猫', 'a cat')),
);
await expectation;
});
test('counts revisions this app version cannot read as not there', () async {
await repository.create('japanese', card('猫', 'cat'));
final created = (await repository.cardsOf('japanese')).single;
gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: created.id,
parents: created.heads,
payload: ByteData.sublistView(
utf8.encode(jsonEncode({'schemaVersion': cardSchemaVersion + 1})),
),
);
expect(
(await repository.cardsOf('japanese')).single.content,
card('猫', 'cat'),
);
});
}

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

View file

@ -0,0 +1,183 @@
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/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
import 'package:material_ui/material_ui.dart' hide Card;
import '../../../support/fake_card_gateway.dart';
/// Rich content with one paragraph of [text].
RichContent paragraph(String text) => RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': text},
],
},
},
],
},
});
CardContent card(String front, String back) =>
CardContent(front: paragraph(front), back: paragraph(back));
void main() {
late FakeCardGateway gateway;
late CardRepository cards;
setUp(() {
gateway = FakeCardGateway();
cards = CardRepository(gateway);
});
tearDown(() => cards.dispose());
Future<void> showList(WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ListView(
padding: const EdgeInsets.all(16),
children: [CardList(cards: cards, studysetId: 'japanese')],
),
),
),
);
await tester.pumpAndSettle();
}
Future<void> open(WidgetTester tester, String front) async {
await tester.tap(find.text(front));
await tester.pump();
// Flushes the editor's timers.
await tester.pump(const Duration(seconds: 1));
}
/// A card whose back was changed differently on this device and another.
Future<void> conflictedCard() async {
await cards.create('japanese', card('猫', 'cat'));
final created = (await cards.cardsOf('japanese')).single;
await cards.save(created, card('猫', 'a cat'));
gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: created.id,
parents: created.heads,
payload: encodeCardPayload(card('猫', 'the cat')),
);
}
testWidgets('says when a studyset has no cards yet', (tester) async {
await showList(tester);
expect(find.text('No cards yet.'), findsOneWidget);
});
testWidgets('lists the cards in order, front and back', (tester) async {
await cards.create('japanese', card('猫', 'cat'));
await cards.create('japanese', card('犬', 'dog'));
await showList(tester);
expect(
tester.getTopLeft(find.text('猫')).dy,
lessThan(tester.getTopLeft(find.text('犬')).dy),
);
expect(find.text('dog'), findsOneWidget);
});
testWidgets('marks a card changed on two devices', (tester) async {
await conflictedCard();
await showList(tester);
expect(find.byTooltip('Changed on two devices'), findsOneWidget);
});
testWidgets('a new card can be saved only with content', (tester) async {
await showList(tester);
await tester.tap(find.text('Add card'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.text('New card'), findsOneWidget);
expect(
tester
.widget<IconButton>(
find.widgetWithIcon(IconButton, Icons.save_outlined),
)
.onPressed,
isNull,
);
});
testWidgets('a conflicted side shows every version; keeping one lets the '
'card be saved, which resolves it', (tester) async {
await conflictedCard();
await showList(tester);
await open(tester, '猫');
expect(
find.textContaining('changed differently on two devices'),
findsOneWidget,
);
// The versions show in the editor's read-only view.
expect(find.textContaining('a cat', findRichText: true), findsOneWidget);
expect(find.textContaining('the cat', findRichText: true), findsOneWidget);
IconButton save() => tester.widget<IconButton>(
find.widgetWithIcon(IconButton, Icons.save_outlined),
);
expect(save().onPressed, isNull);
await tester.tap(find.text('Keep this').first);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(save().onPressed, isNotNull);
await tester.tap(find.byTooltip('Save'));
await tester.pumpAndSettle();
final resolved = (await cards.cardsOf('japanese')).single;
expect(resolved.hasConflict, isFalse);
expect(resolved.content.back, paragraph('a cat'));
expect(find.text('Card'), findsNothing);
});
testWidgets('deleting asks first; Cancel keeps the card', (tester) async {
await cards.create('japanese', card('猫', 'cat'));
await showList(tester);
await open(tester, '猫');
await tester.tap(find.byTooltip('Delete'));
await tester.pumpAndSettle();
expect(find.text('Delete this card?'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(gateway.deletions, isEmpty);
});
testWidgets('a confirmed delete blanks and removes the card and closes '
'it', (tester) async {
final id = await cards.create('japanese', card('猫', 'cat'));
await showList(tester);
await open(tester, '猫');
await tester.tap(find.byTooltip('Delete'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(gateway.deletions, ['blank $id', 'delete $id']);
expect(find.text('No cards yet.'), findsOneWidget);
});
}