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:
parent
11abd9afce
commit
649499be23
3 changed files with 0 additions and 0 deletions
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue