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:
parent
927017bf15
commit
a5515c13f7
1 changed files with 1079 additions and 0 deletions
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue