chore: Halkyon Learn moves into apps/learn

Its server, client, app and curator, and the tools that write its
assets (maps, Anki fixtures, icons), as they are; paths are fixed in
the commits that follow.

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-19 20:08:06 +02:00
commit 649499be23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,163 @@
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_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.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', 'note': 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 CardDeletions deletions;
setUp(() {
gateway = FakeCardGateway();
cards = CardRepository(gateway);
tags = TagRepository(FakeTagGateway(), cardChanges: cards.changes);
deletions = CardDeletions(cards, undoWindow: Duration.zero);
});
tearDown(() async {
await deletions.dispose();
await tags.dispose();
await cards.dispose();
});
group('CardListBloc', () {
test(
'a card reported or buried shows so, and either is taken back',
() async {
final id = await cards.create('japanese', card('猫', 'cat'));
final bloc = CardListBloc(cards, tags, deletions, 'japanese')
..add(const CardListEventStarted());
addTearDown(bloc.close);
Future<CardListStateLoaded> next(
bool Function(CardListStateLoaded) test,
) async => await bloc.stream.firstWhere(
(state) => state is CardListStateLoaded && test(state),
) as CardListStateLoaded;
final neko = (await next((_) => true)).cards.single;
bloc.add(CardListEventBuryToggled(neko));
await next((state) => state.buried.contains(id));
bloc.add(CardListEventBuryToggled(neko));
await next((state) => state.buried.isEmpty);
final report = CardReport(
category: ReportCategory.typo,
at: DateTime.utc(2026, 10, 7),
);
bloc.add(CardListEventReported(neko, report));
final reported = await next(
(state) => state.cards.single.content.report == report,
);
bloc.add(CardListEventReported(reported.cards.single, null));
await next((state) => state.cards.single.content.report == null);
},
);
test('lists the cards and follows every change', () async {
await cards.create('japanese', card('猫', 'cat'));
final bloc = CardListBloc(cards, tags, deletions, '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),
),
),
);
});
});
group('CardListBloc with tags', () {
late CardListBloc bloc;
late List<String> ids;
setUp(() async {
for (final front in ['猫', '犬', '食べる']) {
await cards.create('japanese', card(front, ''));
}
ids = [for (final card in await cards.cardsOf('japanese')) card.id];
final noun = await tags.create('noun');
final verb = await tags.create('verb');
await tags.create('unused');
await tags.setTagsOf(ids[0], {noun});
await tags.setTagsOf(ids[1], {noun});
await tags.setTagsOf(ids[2], {verb});
bloc = CardListBloc(cards, tags, deletions, 'japanese')
..add(const CardListEventStarted());
});
tearDown(() => bloc.close());
/// The next state that has the tags read.
Future<CardListStateLoaded> tagged() async => await bloc.stream.firstWhere(
(state) => state is CardListStateLoaded && state.tagging.all.isNotEmpty,
) as CardListStateLoaded;
test("shows each card's tags, and offers to filter by those the cards "
'have', () async {
final state = await tagged();
expect(state.tagging.of(ids[2]).map((tag) => tag.name), ['verb']);
expect(state.tagsInUse.map((tag) => tag.name), ['noun', 'verb']);
expect(state.shown, hasLength(3));
});
test('a tag narrows the list to its cards, and clearing it shows them '
'all again', () async {
final noun = (await tagged()).tagging.named('noun')!;
bloc.add(CardListEventTagFilterChanged(noun.id));
final narrowed = await bloc.stream.first as CardListStateLoaded;
bloc.add(const CardListEventTagFilterChanged(null));
final all = await bloc.stream.first as CardListStateLoaded;
expect(narrowed.filter, noun);
expect(narrowed.shown.map((card) => card.id), [ids[0], ids[1]]);
expect(all.shown, hasLength(3));
});
test('a deleted tag no longer narrows the list', () async {
final noun = (await tagged()).tagging.named('noun')!;
bloc.add(CardListEventTagFilterChanged(noun.id));
await bloc.stream.first;
await tags.delete(noun.id);
final state = await bloc.stream.firstWhere(
(state) =>
state is CardListStateLoaded && state.tagging.named('noun') == null,
) as CardListStateLoaded;
expect(state.filter, isNull);
expect(state.shown, hasLength(3));
});
});
}

View file

@ -0,0 +1,195 @@
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_browser_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_browser.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_tag_gateway.dart';
CardContent word(String front, String reading, String meaning) => CardContent(
type: CardType.vocabulary,
front: RichContent.plain(front),
back: RichContent.empty(),
reading: reading,
meanings: [meaning],
);
CardContent card(String front, String back) =>
CardContent(front: RichContent.plain(front), back: RichContent.plain(back));
void main() {
late CardRepository cards;
late TagRepository tags;
late ReviewRepository reviews;
late CardDeletions deletions;
late CardBrowserBloc bloc;
setUp(() {
cards = CardRepository(FakeCardGateway());
tags = TagRepository(FakeTagGateway(), cardChanges: cards.changes);
reviews = ReviewRepository(FakeReviewGateway());
deletions = CardDeletions(cards);
});
tearDown(() async {
await bloc.close();
await deletions.dispose();
await tags.dispose();
await reviews.dispose();
await cards.dispose();
});
void start([List<String> studysets = const ['japanese']]) =>
bloc = CardBrowserBloc(cards, tags, reviews, deletions, studysets)
..add(const CardBrowserEventStarted());
Future<CardBrowserState> until(bool Function(CardBrowserState) test) =>
test(bloc.state)
? Future.value(bloc.state)
: bloc.stream.firstWhere(test);
List<String> fronts(CardBrowserState state) => [
for (final each in state.shown) each.card.content.front.plainText.trim(),
];
test('a search finds cards by their front, reading (katakana as '
'hiragana, romaji for kana), meanings and tags', () async {
final taberu = await cards.create('japanese', word('食べる', 'たべる', 'to eat'));
await cards.create('japanese', word('テレビ', 'てれび', 'television'));
await cards.create('japanese', card('hola', 'hello'));
await tags.setTagsOf(taberu, {await tags.create('Verb')});
start();
await until((s) => s.loaded && s.cards.length == 3);
Future<List<String>> search(String query) async {
bloc.add(CardBrowserEventQueryChanged(query));
return fronts(await until((s) => s.filter.query == query));
}
expect(await search('たべ'), ['食べる']);
expect(await search('taberu'), ['食べる']);
expect(await search('テレ'), ['テレビ']);
expect(await search('TELEVISION'), ['テレビ']);
expect(await search('verb'), ['食べる']);
expect(await search('hello'), ['hola']);
expect(await search(''), hasLength(3));
});
test('filters by kind and state, and sorts', () async {
await cards.create('japanese', word('猫', 'ねこ', 'cat'));
final studied = await cards.create('japanese', card('b', 'b'));
await cards.create('japanese', word('犬', 'いぬ', 'dog'));
await reviews.record(
cardId: studied,
rating: Rating.easy,
reviewedAt: DateTime.utc(2026, 1, 2),
duration: Duration.zero,
);
start();
await until((s) => s.loaded && s.cards.length == 3);
bloc.add(
const CardBrowserEventFilterChanged(kind: CardBrowserKind.vocabulary),
);
expect(
fronts(await until((s) => s.filter.kind == CardBrowserKind.vocabulary)),
['猫', '犬'],
);
bloc.add(const CardBrowserEventSortChanged(CardBrowserSort.alphabetical));
expect(
fronts(
await until((s) => s.sort == CardBrowserSort.alphabetical),
),
['犬', '猫'],
);
bloc.add(const CardBrowserEventFilterChanged(states: {CardStatus.fresh}));
final fresh = await until((s) => s.filter.states.isNotEmpty);
expect(fronts(fresh), ['犬', '猫']);
bloc.add(const CardBrowserEventFilterChanged(states: {CardStatus.due}));
expect(
fronts(await until((s) => s.filter.states.contains(CardStatus.due))),
['b'],
);
bloc.add(const CardBrowserEventFiltersCleared());
expect(await until((s) => !s.isFiltered), isA<CardBrowserState>());
});
test('chooses cards one by one or as a range, and moves, tags, untags and '
'starts them afresh all at once', () async {
final ids = [
for (final front in ['a', 'b', 'c', 'd'])
await cards.create('japanese', card(front, front)),
];
final lesson = await tags.create('Lesson 1');
start();
await until((s) => s.loaded && s.cards.length == 4);
bloc
..add(const CardBrowserEventSelectingToggled())
..add(CardBrowserEventCardSelected(ids[0]))
..add(CardBrowserEventCardSelected(ids[2], range: true));
final chosen = await until((s) => s.selected.length == 3);
expect(chosen.selected, {ids[0], ids[1], ids[2]});
bloc.add(CardBrowserEventTagsAdded({lesson}));
await until(
(s) => s.cards.where((each) => each.tags.isNotEmpty).length == 3,
);
bloc.add(CardBrowserEventTagsRemoved({lesson}));
await until((s) => s.cards.every((each) => each.tags.isEmpty));
await reviews.record(
cardId: ids[1],
rating: Rating.easy,
reviewedAt: DateTime.utc(2026, 1, 2),
duration: Duration.zero,
);
bloc.add(const CardBrowserEventProgressReset());
await until(
(s) => s.cards.every((each) => each.schedule?.isNew ?? true),
);
bloc.add(const CardBrowserEventMoved('kana'));
final moved = await until((s) => s.cards.length == 1);
expect(moved.selected, isEmpty);
expect(
[for (final each in await cards.cardsOf('kana')) each.id],
[ids[0], ids[1], ids[2]],
);
});
test('a studyset and those inside it are browsed together, in tree '
'order', () async {
await cards.create('japanese', card('top', 'x'));
await cards.create('kana', card('inside', 'x'));
start(['japanese', 'kana']);
final state = await until((s) => s.loaded && s.cards.length == 2);
expect(fronts(state), ['top', 'inside']);
expect(state.shown.last.studysetId, 'kana');
});
test('a card whose deletion waits leaves the browser and the '
'selection', () async {
final gone = await cards.create('japanese', card('gone', 'x'));
await cards.create('japanese', card('stays', 'x'));
start();
await until((s) => s.loaded && s.cards.length == 2);
bloc
..add(const CardBrowserEventSelectingToggled())
..add(CardBrowserEventCardSelected(gone));
await until((s) => s.selected.isNotEmpty);
deletions.delete(gone);
final state = await until((s) => s.cards.length == 1);
expect(state.selected, isEmpty);
});
}

View file

@ -0,0 +1,697 @@
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('an incomplete one still saves its tags, and only them', () async {
final neko = await cards.create('japanese', card('猫', ''));
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('keeps what another device changed meanwhile in a side the person '
'left alone', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
final stored = (await cards.cardsOf('japanese')).single;
bloc.add(CardComposerEventChanged(neko, card('ねこ', 'cat')));
await until((s) => s.byId(neko)?.status == CardDraftStatus.dirty);
gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: neko,
parents: stored.heads,
payload: encodeCardPayload(card('猫', 'kitty')),
);
await until((s) => s.byId(neko)?.saved == card('猫', 'kitty'));
bloc.add(CardComposerEventLeft(neko));
await until((s) => s.byId(neko)?.content == card('ねこ', 'kitty'));
final merged = (await cards.cardsOf('japanese')).single;
expect(merged.content, card('ねこ', 'kitty'));
expect(merged.hasConflict, isFalse);
});
test('a side both changed meanwhile becomes a conflict, never '
'overwritten', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
final stored = (await cards.cardsOf('japanese')).single;
bloc.add(CardComposerEventChanged(neko, card('猫', 'a cat')));
await until((s) => s.byId(neko)?.status == CardDraftStatus.dirty);
gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: neko,
parents: stored.heads,
payload: encodeCardPayload(card('猫', 'kitty')),
);
await until((s) => s.byId(neko)?.saved == card('猫', 'kitty'));
bloc.add(CardComposerEventLeft(neko));
final conflicted = await until(
(s) => s.byId(neko)?.conflicts.isNotEmpty ?? false,
);
expect(conflicted.byId(neko)!.conflicts, {
CardField.back: [text('a cat'), text('kitty')],
});
expect(
(await cards.cardsOf('japanese')).single.conflicts[CardField.back],
[text('a cat'), text('kitty')],
);
});
test('builds an edit made while saving on that save, not beside '
'it', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
bloc
..add(CardComposerEventChanged(neko, card('猫', 'a cat')))
..add(CardComposerEventLeft(neko))
..add(CardComposerEventChanged(neko, card('猫', 'the cat')));
await until(
(s) =>
s.byId(neko)?.savedHere == true &&
s.byId(neko)?.status == CardDraftStatus.dirty,
);
bloc.add(CardComposerEventLeft(neko));
await until(
(s) =>
s.byId(neko)?.status == CardDraftStatus.saved &&
s.byId(neko)?.saved == card('猫', 'the cat'),
);
final stored = (await cards.cardsOf('japanese')).single;
expect(stored.hasConflict, isFalse);
expect(stored.heads, hasLength(1));
expect(stored.content, card('猫', 'the cat'));
});
test('builds an edit made while a new card is created on that '
'card', () async {
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(CardComposerEventChanged('new', card('犬', 'dog')))
..add(const CardComposerEventLeft('new'))
..add(CardComposerEventChanged('new', card('犬', 'a dog')));
await until(
(s) =>
s.byId('new')?.savedHere == true &&
s.byId('new')?.status == CardDraftStatus.dirty,
);
bloc.add(const CardComposerEventLeft('new'));
await until(
(s) =>
s.byId('new')?.status == CardDraftStatus.saved &&
s.byId('new')?.saved == card('犬', 'a dog'),
);
final stored = (await cards.cardsOf('japanese')).single;
expect(stored.hasConflict, isFalse);
expect(stored.heads, hasLength(1));
expect(stored.content, card('犬', 'a dog'));
});
});
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);
});
group('A word looked up', () {
CardContent word(String headword) => card(
headword,
'',
).copyWith(type: CardType.vocabulary, meanings: const ['a meaning']);
Future<Set<String>> namesOf(CardDraft draft) async {
final tagging = await tags.read();
return {
for (final id in draft.tagIds) tagging.byId(id)!.name,
...draft.newTagNames,
};
}
test("fills the card and gives it the word's tags: those that exist at "
'once, the others made when it is saved', () async {
final noun = await tags.create('Noun');
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(
CardComposerEventLookedUp('new', word('食べる'), const [
'JLPT-N5',
'Verb',
'Noun',
]),
);
// Once the tags are read, the one that exists is the card's own.
final state = await until(
(s) =>
s.byId('new')?.content.front == text('食べる') &&
s.byId('new')!.tagIds.isNotEmpty,
);
final draft = state.byId('new')!;
expect(draft.tagIds, {noun});
expect(draft.newTagNames, ['JLPT-N5', 'Verb']);
expect((await tags.read()).named('Verb'), isNull);
bloc.add(const CardComposerEventLeft('new'));
final saved = await until(
(s) => s.byId('new')?.status == CardDraftStatus.saved,
);
final tagging = await tags.read();
expect(
{for (final tag in tagging.of(saved.byId('new')!.cardId!)) tag.name},
{'JLPT-N5', 'Verb', 'Noun'},
);
expect(saved.byId('new')!.newTagNames, isEmpty);
});
test("again replaces the tags a word brings and keeps the person's "
'own; the word replaced leaves no tag behind', () async {
final mine = await tags.create('Food');
final verb = await tags.create('Verb');
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(CardComposerEventTagsChanged('new', {mine}))
..add(
CardComposerEventLookedUp('new', word('食べる'), const [
'JLPT-N5',
'Verb',
]),
)
..add(CardComposerEventLookedUp('new', word('寿司'), const ['Noun']));
final state = await until(
(s) =>
s.byId('new')?.content.front == text('寿司') &&
s.tagging.all.length == 2,
);
expect(state.byId('new')!.tagIds, {mine});
expect(await namesOf(state.byId('new')!), {'Food', 'Noun'});
expect((await tags.read()).named('JLPT-N5'), isNull);
expect(state.byId('new')!.tagIds, isNot(contains(verb)));
});
test('with tags pinned, the next card keeps the tags but takes the '
"next word's level and class", () async {
final mine = await tags.create('Lesson 3');
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('first'))
..add(CardComposerEventTagsChanged('first', {mine}))
..add(
CardComposerEventLookedUp('first', word('寿司'), const [
'JLPT-N5',
'Noun',
]),
)
..add(const CardComposerEventPinToggled(CardPin.tags))
..add(const CardComposerEventDraftAdded('second', after: 'first'))
..add(
CardComposerEventLookedUp('second', word('走る'), const [
'JLPT-N3',
'Verb',
]),
);
final state = await until(
(s) =>
s.byId('second')?.content.front == text('走る') &&
s.tagging.all.isNotEmpty,
);
expect(await namesOf(state.byId('second')!), {
'Lesson 3',
'JLPT-N3',
'Verb',
});
});
});
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);
// The list empties at once; the stored card goes after.
await pumpEventQueue();
expect(await cards.cardsOf('japanese'), isEmpty);
expect(gateway.deletions, ['blank $neko', 'delete $neko']);
});
test('a card written from a PDF knows its page, waits for its chapter '
'tag by name, and takes text sent to its front and answer', () async {
start();
await loaded();
bloc
..add(
const CardComposerEventDraftAdded(
'new',
source: (pdfId: 'grammar', page: 5),
tagNames: ['Particles'],
),
)
..add(
const CardComposerEventTextAdded(
'new',
text: 'は marks',
toAnswer: false,
),
)
..add(
const CardComposerEventTextAdded(
'new',
text: 'the topic',
toAnswer: true,
),
);
final draft = (await until(
(s) => s.byId('new')?.content.back.plainText == 'the topic',
)).byId('new')!;
expect(draft.content.front.plainText, 'は marks');
expect(draft.content.source, (pdfId: 'grammar', page: 5));
expect(draft.newTagNames, ['Particles']);
bloc.add(const CardComposerEventLeft('new'));
final saved = await until(
(s) => s.byId('new')?.status == CardDraftStatus.saved,
);
final stored = (await cards.cardsOf('japanese')).single;
expect(stored.content.source, (pdfId: 'grammar', page: 5));
expect((await tags.read()).of(stored.id).single.name, 'Particles');
// Held by its draft alone, never also among the stored cards.
expect(saved.items.where((d) => d.cardId == stored.id), hasLength(1));
});
test('text for the answer goes where the kind of card keeps it', () {
final base = card('猫', '');
expect(
base.withTextAdded('cat', toAnswer: true).back.plainText,
'cat',
);
final word = CardContent(
front: text('猫'),
back: RichContent.empty(),
type: CardType.vocabulary,
meanings: const ['cat'],
);
expect(
word.withTextAdded('feline\nanimal', toAnswer: true).meanings,
['cat', 'feline animal'],
);
final choice = CardContent(
front: text('猫'),
back: RichContent.empty(),
type: CardType.multipleChoice,
answers: [
CardAnswer(content: text('dog')),
CardAnswer(content: RichContent.empty(), correct: true),
],
);
expect(
choice.withTextAdded('cat', toAnswer: true).answers[1].content.plainText,
'cat',
);
});
test('discarding drops a new card, and takes a stored one back to how '
'it is stored', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(CardComposerEventChanged(neko, card('猫', 'kitten')))
..add(const CardComposerEventDiscarded('new'))
..add(CardComposerEventDiscarded(neko));
final state = await until(
(s) =>
s.byId('new') == null &&
s.byId(neko)?.content.back.plainText == 'cat',
);
expect(state.byId(neko)!.isDirty, isFalse);
expect(await cards.cardsOf('japanese'), hasLength(1));
});
}