feat(app): the card composer's state: many cards, saved on leaving

A bloc holding a studyset's cards as drafts, new ones on top and the
stored ones below. A card is saved when the person leaves it, once it
changed and has everything its type asks for, only its type's fields
stored; a failed save waits for the next leaving. A new card takes the
type of the card before it and what is pinned of it (front, answer,
tags); duplicating, clearing and deleting; a card devices changed
differently first needs a version chosen for every field in conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-01 18:21:54 +02:00
commit a5515c13f7
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,356 @@
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_composer_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_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_tag_gateway.dart';
RichContent text(String text) => RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': text},
],
},
},
],
},
});
CardContent card(String front, String back) =>
CardContent(front: text(front), back: text(back));
void main() {
late FakeCardGateway gateway;
late CardRepository cards;
late TagRepository tags;
late CardComposerBloc bloc;
setUp(() {
gateway = FakeCardGateway();
cards = CardRepository(gateway);
tags = TagRepository(FakeTagGateway(), cardChanges: cards.changes);
});
tearDown(() async {
await bloc.close();
await tags.dispose();
await cards.dispose();
});
void start() =>
bloc = CardComposerBloc(cards, tags, 'japanese')
..add(const CardComposerEventStarted());
/// The next state (or the current one) [test] holds for.
Future<CardComposerState> until(bool Function(CardComposerState) test) {
if (test(bloc.state)) {
return Future.value(bloc.state);
}
return bloc.stream.firstWhere(test);
}
Future<CardComposerState> loaded() => until((s) => s.loaded);
test('holds the stored cards in order, with their tags', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
await cards.create('japanese', card('犬', 'dog'));
final noun = await tags.create('noun');
await tags.setTagsOf(neko, {noun});
start();
final state = await until(
(s) => s.loaded && (s.byId(neko)?.tagIds.isNotEmpty ?? false),
);
expect(
[for (final draft in state.items) draft.content.front],
[
text('猫'),
text('犬'),
],
);
expect(state.byId(neko)!.tagIds, {noun});
expect(state.byId(neko)!.status, CardDraftStatus.clean);
});
group('A new card', () {
test('goes on top, and is saved once left complete, then held only '
'once', () async {
await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(CardComposerEventChanged('new', card('犬', '')));
final incomplete = await until(
(s) => s.byId('new')?.status == CardDraftStatus.dirty,
);
expect(incomplete.items.first.id, 'new');
bloc.add(const CardComposerEventLeft('new'));
expect(gateway.written, hasLength(1));
bloc
..add(CardComposerEventChanged('new', card('犬', 'dog')))
..add(const CardComposerEventLeft('new'));
final saved = await until(
(s) =>
s.byId('new')?.status == CardDraftStatus.saved &&
s.existing.length == 1,
);
expect(gateway.written, hasLength(2));
expect(saved.items, hasLength(2));
expect(saved.saving, CardComposerSaving.saved);
expect((await cards.cardsOf('japanese')).last.content, card('犬', 'dog'));
});
test('keeps only the fields of its type when saved', () async {
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(
CardComposerEventChanged(
'new',
card('食べる', 'leftover back').copyWith(
type: CardType.vocabulary,
meanings: const ['to eat'],
),
),
)
..add(const CardComposerEventLeft('new'));
await until((s) => s.byId('new')?.status == CardDraftStatus.saved);
final stored = decodeCardPayload(gateway.written.single.payload);
expect(stored.back, RichContent.empty());
expect(stored.meanings, ['to eat']);
});
test('takes the type of the card before it, and what is pinned', () async {
final noun = await tags.create('noun');
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('first'))
..add(
CardComposerEventChanged(
'first',
card('食べる', 'to eat').copyWith(type: CardType.vocabulary),
),
)
..add(CardComposerEventTagsChanged('first', {noun}))
..add(const CardComposerEventPinToggled(CardPin.tags))
..add(const CardComposerEventDraftAdded('second', after: 'first'));
final state = await until((s) => s.byId('second') != null);
final second = state.byId('second')!;
expect(second.content.type, CardType.vocabulary);
expect(second.content.front, RichContent.empty());
expect(second.tagIds, {noun});
expect(state.items.first.id, 'second');
});
test(
'an untouched one is clean and nothing to lose, of any type',
() async {
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(
CardComposerEventChanged(
'new',
CardContent.empty().copyWith(type: CardType.vocabulary),
),
);
final state = await until(
(s) => s.byId('new')?.content.type == CardType.vocabulary,
);
expect(state.byId('new')!.status, CardDraftStatus.clean);
expect(state.unsaved, 0);
},
);
});
group('A stored card', () {
test('saves an edit as a new revision once left', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
bloc
..add(CardComposerEventChanged(neko, card('猫', 'a cat')))
..add(CardComposerEventLeft(neko));
await until((s) => s.byId(neko)?.status == CardDraftStatus.saved);
expect(gateway.written, hasLength(2));
expect(
(await cards.cardsOf('japanese')).single.content,
card('猫', 'a cat'),
);
});
test('left unchanged, stores nothing', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
bloc.add(CardComposerEventLeft(neko));
await Future<void>.delayed(Duration.zero);
expect(gateway.written, hasLength(1));
});
test('with only its tags changed, stores them and no revision', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
final noun = await tags.create('noun');
start();
await loaded();
bloc
..add(CardComposerEventTagsChanged(neko, {noun}))
..add(CardComposerEventLeft(neko));
await until((s) => s.byId(neko)?.status == CardDraftStatus.saved);
expect(gateway.written, hasLength(1));
expect((await tags.read()).cardsWith(noun), {neko});
});
test('a failed save says so, and leaving again tries anew', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
gateway.failWrites = Exception('disk');
bloc
..add(CardComposerEventChanged(neko, card('猫', 'a cat')))
..add(CardComposerEventLeft(neko));
final failed = await until(
(s) => s.byId(neko)?.status == CardDraftStatus.failed,
);
expect(failed.saving, CardComposerSaving.failed);
expect(failed.unsaved, 1);
gateway.failWrites = null;
bloc.add(CardComposerEventLeft(neko));
await until((s) => s.byId(neko)?.status == CardDraftStatus.saved);
});
test('changed differently on two devices waits for a version of every '
'field in conflict, and saving settles it', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
final stored = (await cards.cardsOf('japanese')).single;
await cards.save(stored, card('猫', 'a cat'));
gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: neko,
parents: stored.heads,
payload: encodeCardPayload(card('猫', 'the cat')),
);
start();
final conflicted = await until(
(s) => s.byId(neko)?.conflicts.isNotEmpty ?? false,
);
expect(conflicted.byId(neko)!.unresolved, {CardField.back});
expect(conflicted.byId(neko)!.canSave, isFalse);
bloc
..add(
CardComposerEventVersionChosen(neko, CardField.back, text('a cat')),
)
..add(CardComposerEventLeft(neko));
await until(
(s) =>
s.byId(neko)?.status == CardDraftStatus.saved &&
s.byId(neko)!.conflicts.isEmpty,
);
final resolved = (await cards.cardsOf('japanese')).single;
expect(resolved.hasConflict, isFalse);
expect(resolved.content.back, text('a cat'));
});
test('takes changes from another device unless the person changed '
'it', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
final stored = (await cards.cardsOf('japanese')).single;
gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: neko,
parents: stored.heads,
payload: encodeCardPayload(card('猫', 'kitty')),
);
final state = await until(
(s) => s.byId(neko)?.content == card('猫', 'kitty'),
);
expect(state.byId(neko)!.status, CardDraftStatus.clean);
});
});
test('a duplicate copies content and tags as a new card', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
bloc.add(CardComposerEventDuplicated(neko, 'copy'));
final state = await until((s) => s.byId('copy') != null);
expect(state.items.first.id, 'copy');
expect(state.byId('copy')!.content, card('猫', 'cat'));
expect(state.byId('copy')!.status, CardDraftStatus.dirty);
});
test('clearing empties the fields and keeps the type', () async {
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(
CardComposerEventChanged(
'new',
card('食べる', '').copyWith(type: CardType.vocabulary),
),
)
..add(const CardComposerEventCleared('new'));
final state = await until(
(s) =>
s.byId('new')?.content.type == CardType.vocabulary &&
s.byId('new')!.content.front == RichContent.empty(),
);
expect(state.byId('new')!.status, CardDraftStatus.clean);
});
test('deleting drops a new card, and deletes a stored one', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(const CardComposerEventDeleted('new'))
..add(CardComposerEventDeleted(neko));
await until((s) => s.items.isEmpty);
expect(await cards.cardsOf('japanese'), isEmpty);
expect(gateway.deletions, ['blank $neko', 'delete $neko']);
});
}