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
12 changed files with 0 additions and 0 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,130 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_settings_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_settings_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/fake_tag_gateway.dart';
|
||||
|
||||
void main() {
|
||||
late StudysetRepository studysets;
|
||||
late CardRepository cards;
|
||||
late TagRepository tags;
|
||||
late SettingsRepository settings;
|
||||
late StudySettingsBloc bloc;
|
||||
|
||||
CardContent card(String front) =>
|
||||
CardContent(front: RichContent.empty(), back: RichContent.empty());
|
||||
|
||||
setUp(() async {
|
||||
studysets = StudysetRepository(
|
||||
FakeStudysetGateway()
|
||||
..receiveRemoteChange(
|
||||
'japanese',
|
||||
encodeStudysetPayload(StudysetContent.titled('Japanese')),
|
||||
)
|
||||
..receiveRemoteChange(
|
||||
'kanji',
|
||||
encodeStudysetPayload(StudysetContent.titled('Kanji')),
|
||||
parentId: 'japanese',
|
||||
)
|
||||
..receiveRemoteChange(
|
||||
'german',
|
||||
encodeStudysetPayload(StudysetContent.titled('German')),
|
||||
),
|
||||
);
|
||||
cards = CardRepository(FakeCardGateway());
|
||||
tags = TagRepository(FakeTagGateway());
|
||||
settings = SettingsRepository(FakeSettingsGateway());
|
||||
final neko = await cards.create('japanese', card('猫'));
|
||||
final kanji = await cards.create('kanji', card('日'));
|
||||
final hund = await cards.create('german', card('Hund'));
|
||||
await tags.setTagsOf(neko, {await tags.create('noun')});
|
||||
await tags.setTagsOf(kanji, {await tags.create('kanji')});
|
||||
await tags.setTagsOf(hund, {await tags.create('german noun')});
|
||||
await tags.create('unused');
|
||||
bloc = StudySettingsBloc(studysets, cards, tags, settings, 'japanese')
|
||||
..add(const StudySettingsEventStarted());
|
||||
});
|
||||
tearDown(() async {
|
||||
await bloc.close();
|
||||
await studysets.dispose();
|
||||
await cards.dispose();
|
||||
await tags.dispose();
|
||||
await settings.dispose();
|
||||
});
|
||||
|
||||
Future<StudySettingsStateLoaded> loaded() async =>
|
||||
await bloc.stream.firstWhere((s) => s is StudySettingsStateLoaded)
|
||||
as StudySettingsStateLoaded;
|
||||
|
||||
test('offers the tags the studied cards have, those inside included, '
|
||||
'none chosen at first', () async {
|
||||
final state = await loaded();
|
||||
|
||||
expect(state.tags.map((tag) => tag.name), ['kanji', 'noun']);
|
||||
expect(state.chosen, isEmpty);
|
||||
expect(state.canSave, isFalse);
|
||||
});
|
||||
|
||||
test('saves the tags chosen into the settings, by id and name', () async {
|
||||
final noun = (await loaded()).tags.last;
|
||||
|
||||
bloc
|
||||
..add(StudySettingsEventTagToggled(noun.id))
|
||||
..add(const StudySettingsEventSaveRequested());
|
||||
await bloc.stream.firstWhere(
|
||||
(s) => s is StudySettingsStateLoaded && s.isDone,
|
||||
);
|
||||
|
||||
expect((await settings.read()).studyTagsOf('japanese'), [noun.ref]);
|
||||
});
|
||||
|
||||
test('a reset chooses none, and saving that studies every card '
|
||||
'again', () async {
|
||||
final noun = (await loaded()).tags.last;
|
||||
await settings.update((s) => s.withStudyTags('japanese', [noun.ref]));
|
||||
await bloc.close();
|
||||
bloc = StudySettingsBloc(studysets, cards, tags, settings, 'japanese')
|
||||
..add(const StudySettingsEventStarted());
|
||||
expect((await loaded()).chosen, {noun.id});
|
||||
|
||||
bloc
|
||||
..add(const StudySettingsEventResetRequested())
|
||||
..add(const StudySettingsEventSaveRequested());
|
||||
await bloc.stream.firstWhere(
|
||||
(s) => s is StudySettingsStateLoaded && s.isDone,
|
||||
);
|
||||
|
||||
expect((await settings.read()).studyTags, isEmpty);
|
||||
});
|
||||
|
||||
test('saves the study options chosen, with the tags', () async {
|
||||
await loaded();
|
||||
|
||||
bloc
|
||||
..add(
|
||||
const StudySettingsEventOptionsChanged(
|
||||
StudyOptions(mode: LearnMode.cram, cardsPerRound: 10),
|
||||
),
|
||||
)
|
||||
..add(const StudySettingsEventSaveRequested());
|
||||
await bloc.stream.firstWhere(
|
||||
(s) => s is StudySettingsStateLoaded && s.isDone,
|
||||
);
|
||||
|
||||
expect(
|
||||
(await settings.read()).study,
|
||||
const StudyOptions(mode: LearnMode.cram, cardsPerRound: 10),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
|
||||
|
||||
import '../../../support/fake_review_gateway.dart';
|
||||
|
||||
/// A [FakeReviewGateway] counting how often it reads the whole log, whose
|
||||
/// [receiveRemote] plays a sync bringing another device's review.
|
||||
class _CountingReviewGateway() extends FakeReviewGateway {
|
||||
int wholeReads = 0;
|
||||
final _remote = StreamController<void>.broadcast();
|
||||
|
||||
@override
|
||||
Future<List<ReviewRow>> allReviews() {
|
||||
wholeReads++;
|
||||
return super.allReviews();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _remote.stream;
|
||||
|
||||
void receiveRemote(ReviewRow row) {
|
||||
rows.add(row);
|
||||
_remote.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late FakeReviewGateway gateway;
|
||||
late ReviewRepository reviews;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeReviewGateway();
|
||||
reviews = ReviewRepository(gateway);
|
||||
});
|
||||
|
||||
test(
|
||||
'records a review in UTC with its duration, and reads it back',
|
||||
() async {
|
||||
final at = DateTime.utc(2026, 10, 5, 9);
|
||||
|
||||
await reviews.record(
|
||||
cardId: 'card',
|
||||
rating: Rating.good,
|
||||
reviewedAt: at.toLocal(),
|
||||
duration: const Duration(milliseconds: 4200),
|
||||
);
|
||||
|
||||
final review = (await reviews.reviewsOf({'card'})).single;
|
||||
expect(review.rating, Rating.good);
|
||||
expect(review.reviewedAt, at);
|
||||
expect(review.reviewedAt.isUtc, isTrue);
|
||||
expect(review.duration, const Duration(milliseconds: 4200));
|
||||
},
|
||||
);
|
||||
|
||||
test("keeps the whole log once many cards' reviews are asked for, with "
|
||||
"this device's ratings, undos and fresh starts in it; a sync bringing "
|
||||
"others' reads it anew", () async {
|
||||
final counting = _CountingReviewGateway();
|
||||
final repository = ReviewRepository(counting);
|
||||
addTearDown(repository.dispose);
|
||||
final cards = {for (var i = 0; i < 250; i++) 'card-$i'};
|
||||
final at = DateTime.utc(2026, 10, 5, 9);
|
||||
final later = at.add(const Duration(minutes: 1));
|
||||
Future<void> rate(String card, DateTime when) => repository.record(
|
||||
cardId: card,
|
||||
rating: Rating.good,
|
||||
reviewedAt: when,
|
||||
duration: const Duration(seconds: 3),
|
||||
);
|
||||
Future<List<(String, StudyMode)>> kept() async => [
|
||||
for (final review in await repository.reviewsOf(cards))
|
||||
(review.cardId, review.mode),
|
||||
];
|
||||
await rate('card-0', at);
|
||||
|
||||
// A few cards' reviews are read on their own.
|
||||
expect(await repository.reviewsOf({'card-0'}), hasLength(1));
|
||||
expect(counting.wholeReads, 0);
|
||||
expect(await kept(), [('card-0', StudyMode.review)]);
|
||||
expect(counting.wholeReads, 1);
|
||||
|
||||
await rate('card-1', at);
|
||||
await rate('card-2', later);
|
||||
await repository.undo(
|
||||
cardId: 'card-2',
|
||||
reviewedAt: later,
|
||||
mode: StudyMode.review,
|
||||
);
|
||||
await repository.reset(['card-0'], later);
|
||||
|
||||
expect(
|
||||
await kept(),
|
||||
unorderedEquals([
|
||||
('card-0', StudyMode.review),
|
||||
('card-0', StudyMode.reset),
|
||||
('card-1', StudyMode.review),
|
||||
]),
|
||||
);
|
||||
expect(await repository.all(), hasLength(3));
|
||||
expect(counting.wholeReads, 1);
|
||||
|
||||
counting.receiveRemote((
|
||||
id: 'remote',
|
||||
cardId: 'card-3',
|
||||
rating: Rating.good.value,
|
||||
reviewedAt: later,
|
||||
durationMs: 1000,
|
||||
mode: StudyMode.review.value,
|
||||
));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(await repository.all(), hasLength(4));
|
||||
expect(counting.wholeReads, 2);
|
||||
});
|
||||
|
||||
test('reads only the reviews of the cards asked for', () async {
|
||||
for (final card in ['a', 'b']) {
|
||||
await reviews.record(
|
||||
cardId: card,
|
||||
rating: Rating.easy,
|
||||
reviewedAt: DateTime.utc(2026, 10, 5),
|
||||
duration: Duration.zero,
|
||||
);
|
||||
}
|
||||
|
||||
expect((await reviews.reviewsOf({'b'})).single.cardId, 'b');
|
||||
});
|
||||
|
||||
test("replays each card's schedule from its reviews; a card without "
|
||||
'any is new', () async {
|
||||
await reviews.record(
|
||||
cardId: 'studied',
|
||||
rating: Rating.good,
|
||||
reviewedAt: DateTime.utc(2026, 10, 5),
|
||||
duration: Duration.zero,
|
||||
);
|
||||
|
||||
final schedules = await reviews.schedulesOf(['studied', 'new']);
|
||||
|
||||
expect(schedules.keys, ['studied', 'new']);
|
||||
expect(
|
||||
schedules['studied'],
|
||||
scheduleOf('studied', await reviews.reviewsOf({'studied'})),
|
||||
);
|
||||
expect(schedules['new']!.isNew, isTrue);
|
||||
});
|
||||
|
||||
test('a schedule worked out before follows every new rating, and a '
|
||||
'rating taken back', () async {
|
||||
Future<void> rate(Rating rating, DateTime at) => reviews.record(
|
||||
cardId: 'word',
|
||||
rating: rating,
|
||||
reviewedAt: at,
|
||||
duration: Duration.zero,
|
||||
);
|
||||
Future<CardSchedule> now() async =>
|
||||
(await reviews.schedulesOf(['word']))['word']!;
|
||||
Future<CardSchedule> replayed() async =>
|
||||
scheduleOf('word', await reviews.reviewsOf({'word'}));
|
||||
|
||||
await rate(Rating.good, DateTime.utc(2026, 10, 5));
|
||||
final once = await now();
|
||||
expect(once, await replayed());
|
||||
|
||||
await rate(Rating.again, DateTime.utc(2026, 10, 6));
|
||||
expect(await now(), await replayed());
|
||||
expect(await now(), isNot(once));
|
||||
|
||||
await reviews.undo(
|
||||
cardId: 'word',
|
||||
reviewedAt: DateTime.utc(2026, 10, 6),
|
||||
mode: StudyMode.review,
|
||||
);
|
||||
expect(await now(), once);
|
||||
});
|
||||
|
||||
test('skips a rating or a mode this app does not know', () async {
|
||||
await gateway.insert([
|
||||
(
|
||||
id: '',
|
||||
cardId: 'card',
|
||||
rating: 9,
|
||||
reviewedAt: DateTime.utc(2026, 10, 5),
|
||||
durationMs: 0,
|
||||
mode: 'review',
|
||||
),
|
||||
(
|
||||
id: '',
|
||||
cardId: 'card',
|
||||
rating: 3,
|
||||
reviewedAt: DateTime.utc(2026, 10, 5),
|
||||
durationMs: 0,
|
||||
mode: 'telepathy',
|
||||
),
|
||||
]);
|
||||
|
||||
expect(await reviews.reviewsOf({'card'}), isEmpty);
|
||||
});
|
||||
|
||||
group('Modes', () {
|
||||
final day = DateTime.utc(2026, 10, 5, 9);
|
||||
|
||||
Future<void> rate(String card, Rating rating, int hours, StudyMode mode) =>
|
||||
reviews.record(
|
||||
cardId: card,
|
||||
rating: rating,
|
||||
reviewedAt: day.add(Duration(hours: hours)),
|
||||
duration: Duration.zero,
|
||||
mode: mode,
|
||||
);
|
||||
|
||||
test('studying back to front has a schedule of its own; cramming moves '
|
||||
'none', () async {
|
||||
await rate('card', Rating.good, 0, StudyMode.review);
|
||||
await rate('card', Rating.again, 1, StudyMode.reverse);
|
||||
await rate('card', Rating.easy, 2, StudyMode.cram);
|
||||
|
||||
final forward = (await reviews.schedulesOf(['card']))['card']!;
|
||||
final backward = (await reviews.schedulesOf([
|
||||
'card',
|
||||
], mode: StudyMode.reverse))['card']!;
|
||||
|
||||
expect(forward.reviewCount, 1);
|
||||
expect(backward.reviewCount, 1);
|
||||
expect(backward.isLearning, isTrue);
|
||||
});
|
||||
|
||||
test('a fresh start makes cards new again in both directions, and '
|
||||
'keeps their reviews', () async {
|
||||
await rate('card', Rating.good, 0, StudyMode.review);
|
||||
await rate('card', Rating.good, 1, StudyMode.reverse);
|
||||
await reviews.reset(['card'], day.add(const Duration(hours: 2)));
|
||||
|
||||
expect((await reviews.schedulesOf(['card']))['card']!.isNew, isTrue);
|
||||
expect(
|
||||
(await reviews.schedulesOf([
|
||||
'card',
|
||||
], mode: StudyMode.reverse))['card']!.isNew,
|
||||
isTrue,
|
||||
);
|
||||
expect(await reviews.reviewsOf({'card'}), hasLength(3));
|
||||
|
||||
await rate('card', Rating.good, 3, StudyMode.review);
|
||||
expect(
|
||||
(await reviews.schedulesOf(['card']))['card']!.reviewCount,
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
|
||||
|
||||
final start = DateTime.utc(2026, 10, 1, 8);
|
||||
|
||||
Review review(String id, Rating rating, DateTime at) => Review(
|
||||
id: id,
|
||||
cardId: 'card',
|
||||
rating: rating,
|
||||
reviewedAt: at,
|
||||
duration: const Duration(seconds: 5),
|
||||
);
|
||||
|
||||
/// Good every time the card is due, [times] times.
|
||||
List<Review> studiedWell(int times) {
|
||||
final reviews = <Review>[];
|
||||
var at = start;
|
||||
for (var i = 0; i < times; i++) {
|
||||
reviews.add(review('r$i', Rating.good, at));
|
||||
at = scheduleOf('card', reviews).due!;
|
||||
}
|
||||
return reviews;
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('a card never reviewed is new, without a due date', () {
|
||||
final schedule = scheduleOf('card', const []);
|
||||
|
||||
expect(schedule.isNew, isTrue);
|
||||
expect(schedule.due, isNull);
|
||||
});
|
||||
|
||||
test('a card is mature once remembered for three weeks', () {
|
||||
expect(scheduleOf('card', const []).isMature, isFalse);
|
||||
// Studied well three times, it is remembered for about two weeks; a
|
||||
// fourth time, for about six.
|
||||
expect(scheduleOf('card', studiedWell(3)).isMature, isFalse);
|
||||
expect(scheduleOf('card', studiedWell(4)).isMature, isTrue);
|
||||
});
|
||||
|
||||
test('a first rating starts the learning steps', () {
|
||||
final schedule = scheduleOf('card', [review('r1', Rating.good, start)]);
|
||||
|
||||
expect(schedule.isNew, isFalse);
|
||||
expect(schedule.isLearning, isTrue);
|
||||
expect(schedule.firstReviewedAt, start);
|
||||
expect(schedule.due!.isAfter(start), isTrue);
|
||||
});
|
||||
|
||||
test('knows when the card was last reviewed, whatever order the reviews '
|
||||
'arrive in', () {
|
||||
final reviews = studiedWell(3);
|
||||
|
||||
expect(
|
||||
scheduleOf('card', reviews.reversed).lastReviewedAt,
|
||||
reviews.last.reviewedAt,
|
||||
);
|
||||
expect(scheduleOf('card', const []).lastReviewedAt, isNull);
|
||||
});
|
||||
|
||||
test('good answers space the reviews further and further apart', () {
|
||||
final reviews = studiedWell(6);
|
||||
final intervals = [
|
||||
for (var i = 1; i < reviews.length; i++)
|
||||
reviews[i].reviewedAt.difference(reviews[i - 1].reviewedAt),
|
||||
];
|
||||
|
||||
for (var i = 1; i < intervals.length; i++) {
|
||||
expect(intervals[i], greaterThanOrEqualTo(intervals[i - 1]));
|
||||
}
|
||||
expect(intervals.last, greaterThan(const Duration(days: 3)));
|
||||
});
|
||||
|
||||
test('every device computes the same schedule, whatever order the '
|
||||
'reviews arrive in', () {
|
||||
final reviews = studiedWell(5);
|
||||
|
||||
expect(
|
||||
scheduleOf('card', reviews.reversed),
|
||||
scheduleOf('card', reviews),
|
||||
);
|
||||
});
|
||||
|
||||
test('a preview shows what recording that rating yields', () {
|
||||
final reviews = studiedWell(4);
|
||||
final schedule = scheduleOf('card', reviews);
|
||||
final now = schedule.due!;
|
||||
|
||||
final previews = nextDueDates('card', schedule, now);
|
||||
|
||||
for (final rating in Rating.values) {
|
||||
expect(
|
||||
scheduleOf('card', [...reviews, review('next', rating, now)]).due,
|
||||
previews[rating],
|
||||
reason: '$rating',
|
||||
);
|
||||
}
|
||||
expect(previews[Rating.again]!.isBefore(previews[Rating.easy]!), isTrue);
|
||||
});
|
||||
|
||||
group('the fuzz', () {
|
||||
test('leaves short intervals exact', () {
|
||||
expect(fuzzedDays(2, 0.99), 2);
|
||||
});
|
||||
|
||||
test('stays within FSRS range and covers it evenly', () {
|
||||
// For 10 days, FSRS spreads over 8 to 12 days.
|
||||
expect(fuzzedDays(10, 0), 8);
|
||||
expect(fuzzedDays(10, 0.5), 10);
|
||||
expect(fuzzedDays(10, 0.999), 12);
|
||||
});
|
||||
|
||||
test('depends only on the card and its reviews', () {
|
||||
final reviews = studiedWell(6);
|
||||
|
||||
expect(
|
||||
scheduleOf('card', reviews).due,
|
||||
scheduleOf('card', [...reviews]).due,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_study_mode.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||
|
||||
void main() {
|
||||
const writing = StudyOptions(writeByHand: true);
|
||||
|
||||
CardContent word(String front) => CardContent(
|
||||
type: CardType.vocabulary,
|
||||
front: RichContent.plain(front),
|
||||
back: RichContent.empty(),
|
||||
meanings: const ['a meaning'],
|
||||
);
|
||||
|
||||
test('written by hand: words and characters of scripts whose strokes are '
|
||||
'drawn, not words of alphabets', () {
|
||||
expect(writtenByHand(word('食べる'), writing), isTrue);
|
||||
expect(writtenByHand(word('学习'), writing), isTrue);
|
||||
expect(writtenByHand(word('학교'), writing), isTrue);
|
||||
expect(writtenByHand(word('das Haus'), writing), isFalse);
|
||||
expect(writtenByHand(word('σπίτι'), writing), isFalse);
|
||||
expect(
|
||||
writtenByHand(
|
||||
CardContent(
|
||||
type: CardType.character,
|
||||
front: RichContent.plain('Β β'),
|
||||
back: RichContent.empty(),
|
||||
meanings: const ['v'],
|
||||
),
|
||||
writing,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(writtenByHand(word('食べる'), const StudyOptions()), isFalse);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/example_word.dart';
|
||||
|
||||
/// A card [id] of the type [type] whose front is [front].
|
||||
ExampleWord word(
|
||||
String id,
|
||||
String front, {
|
||||
CardType type = CardType.vocabulary,
|
||||
}) => (
|
||||
cardId: id,
|
||||
studysetId: 'japanese',
|
||||
content: CardContent(
|
||||
type: type,
|
||||
front: RichContent.plain(front),
|
||||
back: RichContent.empty(),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('the words written with a character, in their order', () {
|
||||
final words = [
|
||||
word('1', '食べる'),
|
||||
word('2', '猫'),
|
||||
word('3', '食事'),
|
||||
];
|
||||
|
||||
expect(
|
||||
[
|
||||
for (final found in exampleWordsOf('食', words)) found.cardId,
|
||||
],
|
||||
['1', '3'],
|
||||
);
|
||||
});
|
||||
|
||||
test('neither the character itself nor anything but words', () {
|
||||
final words = [
|
||||
word('kanji', '食', type: CardType.character),
|
||||
word('own', '食'),
|
||||
word('plain', '食', type: CardType.frontBack),
|
||||
];
|
||||
|
||||
expect(exampleWordsOf('食', words, exceptId: 'own'), isEmpty);
|
||||
});
|
||||
|
||||
test('at most five', () {
|
||||
final words = [for (var i = 0; i < 8; i++) word('$i', '食$i')];
|
||||
|
||||
expect(exampleWordsOf('食', words), hasLength(maxExampleWords));
|
||||
expect(maxExampleWords, 5);
|
||||
});
|
||||
|
||||
test('none for a card without a character', () {
|
||||
expect(exampleWordsOf('', [word('1', '食べる')]), isEmpty);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/round_order.dart';
|
||||
|
||||
void main() {
|
||||
test('Bad brings a card back 4 cards on: at the end with fewer behind '
|
||||
'it, at once with none', () {
|
||||
expect(cardsBefore(Rating.again, 7), 4);
|
||||
expect(comesBack(Rating.again, 7), const ComesBackIn(4));
|
||||
expect(cardsBefore(Rating.again, 4), 4);
|
||||
expect(comesBack(Rating.again, 4), const ComesBackAtEnd());
|
||||
expect(cardsBefore(Rating.again, 2), 2);
|
||||
expect(comesBack(Rating.again, 2), const ComesBackAtEnd());
|
||||
expect(comesBack(Rating.again, 0), const ComesBackNow());
|
||||
});
|
||||
|
||||
test('any other rating that has the card due again within the day brings '
|
||||
'it back at the end', () {
|
||||
for (final rating in [Rating.hard, Rating.good, Rating.easy]) {
|
||||
expect(cardsBefore(rating, 7), 7);
|
||||
expect(comesBack(rating, 7), const ComesBackAtEnd());
|
||||
expect(comesBack(rating, 0), const ComesBackNow());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
|
||||
final now = DateTime.utc(2026, 10, 5, 12);
|
||||
final startOfToday = DateTime.utc(2026, 10, 5);
|
||||
|
||||
/// A card reviewed before, due at [due], remembered for [stability] days.
|
||||
CardSchedule reviewed(DateTime due, {double stability = 5}) => CardSchedule(
|
||||
reviewCount: 3,
|
||||
due: due,
|
||||
firstReviewedAt: DateTime.utc(2026, 9, 2),
|
||||
stability: stability,
|
||||
);
|
||||
|
||||
const fresh = CardSchedule(reviewCount: 0);
|
||||
|
||||
void main() {
|
||||
test('counts the cards, those a session would study now, the reviews '
|
||||
'among them, and the mature ones', () {
|
||||
final progress = studyProgress(
|
||||
groups: [
|
||||
['due', 'new', 'later', 'mature'],
|
||||
],
|
||||
schedules: {
|
||||
'due': reviewed(now.subtract(const Duration(hours: 1))),
|
||||
'new': fresh,
|
||||
'later': reviewed(now.add(const Duration(days: 3))),
|
||||
'mature': reviewed(now.add(const Duration(days: 40)), stability: 45),
|
||||
},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
expect(
|
||||
progress,
|
||||
const StudyProgress(cards: 4, due: 2, reviewsDue: 1, mature: 1),
|
||||
);
|
||||
expect(progress.mastery, 0.25);
|
||||
});
|
||||
|
||||
test('counts only as many new cards as today still starts', () {
|
||||
final order = [for (var i = 0; i < 30; i++) 'n$i'];
|
||||
|
||||
final progress = studyProgress(
|
||||
groups: [order],
|
||||
schedules: {for (final id in order) id: fresh},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
expect(progress.due, 20);
|
||||
expect(progress.reviewsDue, 0);
|
||||
});
|
||||
|
||||
test('has no mastery without cards', () {
|
||||
final progress = studyProgress(
|
||||
groups: const [],
|
||||
schedules: const {},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
expect(
|
||||
progress,
|
||||
const StudyProgress(cards: 0, due: 0, reviewsDue: 0, mature: 0),
|
||||
);
|
||||
expect(progress.mastery, isNull);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
|
||||
final now = DateTime.utc(2026, 10, 5, 12);
|
||||
final startOfToday = DateTime.utc(2026, 10, 5);
|
||||
|
||||
CardSchedule dueAt(DateTime due, {bool learning = false}) => CardSchedule(
|
||||
reviewCount: 1,
|
||||
due: due,
|
||||
firstReviewedAt: DateTime.utc(2026, 9, 5),
|
||||
isLearning: learning,
|
||||
);
|
||||
|
||||
const fresh = CardSchedule(reviewCount: 0);
|
||||
|
||||
void main() {
|
||||
test('due cards come first, earliest first, then new cards in their '
|
||||
'studyset order', () {
|
||||
final queue = studyQueue(
|
||||
groups: [
|
||||
['n1', 'late', 'n2', 'early'],
|
||||
],
|
||||
schedules: {
|
||||
'n1': fresh,
|
||||
'late': dueAt(now.subtract(const Duration(hours: 1))),
|
||||
'n2': fresh,
|
||||
'early': dueAt(now.subtract(const Duration(days: 2))),
|
||||
},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
expect(queue, ['early', 'late', 'n1', 'n2']);
|
||||
});
|
||||
|
||||
test('leaves out cards set aside for the day, due or new', () {
|
||||
final queue = studyQueue(
|
||||
groups: [
|
||||
['n1', 'due', 'n2', 'learning'],
|
||||
],
|
||||
schedules: {
|
||||
'n1': fresh,
|
||||
'due': dueAt(now.subtract(const Duration(hours: 1))),
|
||||
'n2': fresh,
|
||||
'learning': dueAt(
|
||||
now.add(const Duration(minutes: 5)),
|
||||
learning: true,
|
||||
),
|
||||
},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
setAside: {'due', 'n1', 'learning'},
|
||||
);
|
||||
|
||||
expect(queue, ['n2']);
|
||||
});
|
||||
|
||||
test('leaves out cards due later', () {
|
||||
final queue = studyQueue(
|
||||
groups: [
|
||||
['later'],
|
||||
],
|
||||
schedules: {'later': dueAt(now.add(const Duration(days: 1)))},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
expect(queue, isEmpty);
|
||||
});
|
||||
|
||||
test('starts at most 20 new cards a local day, counting those started '
|
||||
'today', () {
|
||||
final order = [for (var i = 0; i < 30; i++) 'n$i'];
|
||||
final schedules = {for (final id in order) id: fresh}
|
||||
..['n0'] = CardSchedule(
|
||||
reviewCount: 1,
|
||||
due: now.add(const Duration(days: 1)),
|
||||
firstReviewedAt: startOfToday.add(const Duration(hours: 9)),
|
||||
);
|
||||
|
||||
final queue = studyQueue(
|
||||
groups: [order],
|
||||
schedules: schedules,
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
expect(queue, hasLength(19));
|
||||
expect(queue.first, 'n1');
|
||||
});
|
||||
|
||||
test('when nothing else is due, a learning card due within 20 minutes '
|
||||
'comes early', () {
|
||||
final queue = studyQueue(
|
||||
groups: [
|
||||
['soon', 'later'],
|
||||
],
|
||||
schedules: {
|
||||
'soon': dueAt(now.add(const Duration(minutes: 5)), learning: true),
|
||||
'later': dueAt(now.add(const Duration(minutes: 30)), learning: true),
|
||||
},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
expect(queue, ['soon']);
|
||||
});
|
||||
|
||||
group('over several studysets, as a studyset with what is inside it', () {
|
||||
test('due cards of every studyset come first, earliest first, then each '
|
||||
"studyset's new cards in tree order", () {
|
||||
final queue = studyQueue(
|
||||
groups: [
|
||||
['parent-new', 'parent-late'],
|
||||
['child-new', 'child-early'],
|
||||
],
|
||||
schedules: {
|
||||
'parent-new': fresh,
|
||||
'parent-late': dueAt(now.subtract(const Duration(hours: 1))),
|
||||
'child-new': fresh,
|
||||
'child-early': dueAt(now.subtract(const Duration(days: 2))),
|
||||
},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
expect(queue, ['child-early', 'parent-late', 'parent-new', 'child-new']);
|
||||
});
|
||||
|
||||
test('the studied studyset starts its 20 new cards a local day with '
|
||||
'what is inside it, not 20 each, in tree order', () {
|
||||
final parent = [for (var i = 0; i < 5; i++) 'p$i'];
|
||||
final child = [for (var i = 0; i < 25; i++) 'c$i'];
|
||||
final other = [for (var i = 0; i < 25; i++) 'o$i'];
|
||||
|
||||
final queue = studyQueue(
|
||||
groups: [parent, child, other],
|
||||
schedules: {
|
||||
for (final id in [...parent, ...child, ...other]) id: fresh,
|
||||
},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
expect(queue, [...parent, ...child.take(15)]);
|
||||
});
|
||||
});
|
||||
|
||||
group("a studyset's study options", () {
|
||||
/// A card reviewed before today, due at [due], last reviewed at
|
||||
/// [lastReviewedAt] (before today unless given).
|
||||
CardSchedule review(DateTime due, {DateTime? lastReviewedAt}) =>
|
||||
CardSchedule(
|
||||
reviewCount: 2,
|
||||
due: due,
|
||||
firstReviewedAt: DateTime.utc(2026, 9, 5),
|
||||
lastReviewedAt: lastReviewedAt ?? DateTime.utc(2026, 10, 2),
|
||||
);
|
||||
|
||||
final overdue = now.subtract(const Duration(hours: 1));
|
||||
|
||||
test('each studyset starts as many new cards a day as its options say', () {
|
||||
final queue = studyQueue(
|
||||
groups: [
|
||||
['a1', 'a2', 'a3'],
|
||||
['b1', 'b2', 'b3'],
|
||||
['c1', 'c2'],
|
||||
],
|
||||
options: const [
|
||||
StudysetStudyOptions(newPerDay: 6),
|
||||
StudysetStudyOptions(newPerDay: 1),
|
||||
],
|
||||
newPerDay: 1,
|
||||
schedules: const {},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
expect(queue, ['a1', 'a2', 'a3', 'b1', 'c1'], reason: 'c by default');
|
||||
});
|
||||
|
||||
test("the studied studyset's limits hold for everything inside it "
|
||||
'together, counting what was studied today anywhere in it', () {
|
||||
final queue = studyQueue(
|
||||
groups: [
|
||||
['started', 'a1', 'a2', 'reviewed', 'r1'],
|
||||
['b1', 'b2', 'r2', 'r3', 'learning'],
|
||||
],
|
||||
options: const [
|
||||
StudysetStudyOptions(newPerDay: 2, reviewsPerDay: 3),
|
||||
StudysetStudyOptions(newPerDay: StudysetStudyOptions.unlimited),
|
||||
],
|
||||
schedules: {
|
||||
'started': CardSchedule(
|
||||
reviewCount: 1,
|
||||
due: now.add(const Duration(days: 1)),
|
||||
firstReviewedAt: startOfToday.add(const Duration(hours: 8)),
|
||||
lastReviewedAt: startOfToday.add(const Duration(hours: 8)),
|
||||
),
|
||||
'reviewed': review(
|
||||
now.add(const Duration(days: 3)),
|
||||
lastReviewedAt: startOfToday.add(const Duration(hours: 8)),
|
||||
),
|
||||
'r1': review(overdue),
|
||||
'r2': review(overdue.subtract(const Duration(days: 2))),
|
||||
'r3': review(overdue.subtract(const Duration(days: 1))),
|
||||
'learning': dueAt(overdue, learning: true),
|
||||
},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
// One new card and two reviews left of the day's; the learning card
|
||||
// comes anyway.
|
||||
expect(queue, ['r2', 'r3', 'learning', 'a1']);
|
||||
});
|
||||
|
||||
test('a studyset reviews at most as many cards a day as its options say, '
|
||||
'counting those reviewed today; cards in their learning steps come '
|
||||
'anyway', () {
|
||||
final queue = studyQueue(
|
||||
groups: [
|
||||
['done', 'r1', 'r2', 'r3', 'learning'],
|
||||
],
|
||||
options: const [StudysetStudyOptions(reviewsPerDay: 2)],
|
||||
schedules: {
|
||||
'done': review(
|
||||
now.add(const Duration(days: 3)),
|
||||
lastReviewedAt: startOfToday.add(const Duration(hours: 8)),
|
||||
),
|
||||
'r1': review(overdue.subtract(const Duration(days: 2))),
|
||||
'r2': review(overdue.subtract(const Duration(days: 1))),
|
||||
'r3': review(overdue),
|
||||
'learning': dueAt(overdue, learning: true),
|
||||
},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
expect(queue, ['r1', 'learning']);
|
||||
});
|
||||
|
||||
test('new first puts the new cards before the due ones', () {
|
||||
final queue = studyQueue(
|
||||
groups: [
|
||||
['due', 'n1', 'n2'],
|
||||
],
|
||||
order: NewCardOrder.newFirst,
|
||||
schedules: {'due': review(overdue)},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
expect(queue, ['n1', 'n2', 'due']);
|
||||
});
|
||||
|
||||
test('shuffled, the reviews still come before the new cards', () {
|
||||
final queue = studyQueue(
|
||||
groups: [
|
||||
['d1', 'd2', 'd3', 'n1', 'n2', 'n3'],
|
||||
],
|
||||
shuffle: math.Random(7),
|
||||
schedules: {
|
||||
for (final (i, id) in ['d1', 'd2', 'd3'].indexed)
|
||||
id: review(overdue.add(Duration(minutes: i))),
|
||||
},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
expect(queue.take(3).toSet(), {'d1', 'd2', 'd3'});
|
||||
expect(queue.skip(3).toSet(), {'n1', 'n2', 'n3'});
|
||||
});
|
||||
|
||||
test('mixed spreads the new cards evenly among the due ones', () {
|
||||
final queue = studyQueue(
|
||||
groups: [
|
||||
['d1', 'd2', 'd3', 'd4', 'n1'],
|
||||
],
|
||||
order: NewCardOrder.mixed,
|
||||
schedules: {
|
||||
for (final (i, id) in ['d1', 'd2', 'd3', 'd4'].indexed)
|
||||
id: review(overdue.add(Duration(minutes: i))),
|
||||
},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
expect(queue, ['d1', 'd2', 'n1', 'd3', 'd4']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/vocabulary_quiz.dart';
|
||||
|
||||
CardContent word(String expression, String reading, List<String> meanings) =>
|
||||
CardContent(
|
||||
type: CardType.vocabulary,
|
||||
front: RichContent.plain(expression),
|
||||
back: RichContent.empty(),
|
||||
reading: reading,
|
||||
meanings: meanings,
|
||||
);
|
||||
|
||||
void main() {
|
||||
final taberu = word('食べる', 'たべる', ['to eat', 'to live on']);
|
||||
final words = [
|
||||
taberu,
|
||||
word('飲む', 'のむ', ['to drink']),
|
||||
word('見る', 'みる', ['to see']),
|
||||
word('聞く', 'きく', ['to hear']),
|
||||
word('食う', 'くう', ['TO EAT']),
|
||||
word('暮らす', 'くらす', ['to live on']),
|
||||
];
|
||||
final pools = QuizPools.of(words);
|
||||
|
||||
Set<String> options(CardContent quiz) => {
|
||||
for (final answer in quiz.answers) answer.content.plainText.trim(),
|
||||
};
|
||||
|
||||
test('asks the meaning of a word among three others, the meaning right', () {
|
||||
final quiz = vocabularyQuiz(
|
||||
taberu,
|
||||
aspect: VocabularyQuizAspect.meaning,
|
||||
pools: pools,
|
||||
random: math.Random(1),
|
||||
)!;
|
||||
|
||||
expect(quiz.type, CardType.multipleChoice);
|
||||
expect(quiz.front, taberu.front);
|
||||
expect(quiz.answers, hasLength(4));
|
||||
expect(
|
||||
[
|
||||
for (final answer in quiz.answers)
|
||||
if (answer.correct) answer.content.plainText.trim(),
|
||||
],
|
||||
['to eat'],
|
||||
);
|
||||
// None of its own meanings counts as wrong, in any case.
|
||||
expect(options(quiz), isNot(contains('TO EAT')));
|
||||
expect(options(quiz), isNot(contains('to live on')));
|
||||
});
|
||||
|
||||
test('asks the reading, or the expression for the meaning', () {
|
||||
final reading = vocabularyQuiz(
|
||||
taberu,
|
||||
aspect: VocabularyQuizAspect.reading,
|
||||
pools: pools,
|
||||
random: math.Random(1),
|
||||
)!;
|
||||
expect(reading.front, taberu.front);
|
||||
expect(options(reading), contains('たべる'));
|
||||
|
||||
final expression = vocabularyQuiz(
|
||||
taberu,
|
||||
aspect: VocabularyQuizAspect.expression,
|
||||
pools: pools,
|
||||
random: math.Random(1),
|
||||
)!;
|
||||
expect(expression.front.plainText.trim(), 'to eat');
|
||||
expect(options(expression), contains('食べる'));
|
||||
});
|
||||
|
||||
test('stays a word to flip without enough to choose from, or without the '
|
||||
'aspect asked', () {
|
||||
final few = QuizPools.of([
|
||||
taberu,
|
||||
word('飲む', 'のむ', ['to drink']),
|
||||
]);
|
||||
expect(
|
||||
vocabularyQuiz(
|
||||
taberu,
|
||||
aspect: VocabularyQuizAspect.meaning,
|
||||
pools: few,
|
||||
random: math.Random(1),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
vocabularyQuiz(
|
||||
word('すごい', '', ['amazing']),
|
||||
aspect: VocabularyQuizAspect.reading,
|
||||
pools: pools,
|
||||
random: math.Random(1),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('mixed asks the reading or the meaning, never the expression', () {
|
||||
for (var seed = 0; seed < 10; seed++) {
|
||||
final quiz = vocabularyQuiz(
|
||||
taberu,
|
||||
aspect: VocabularyQuizAspect.mixed,
|
||||
pools: pools,
|
||||
random: math.Random(seed),
|
||||
)!;
|
||||
expect(quiz.front, taberu.front);
|
||||
final right = quiz.answers.singleWhere((answer) => answer.correct);
|
||||
expect(['たべる', 'to eat'], contains(right.content.plainText.trim()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_document.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.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/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_note_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/fake_tag_gateway.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
void main() {
|
||||
late FakeStudysetGateway studysets;
|
||||
late FakeCardGateway cardGateway;
|
||||
late FakeTagGateway tagGateway;
|
||||
late String verb;
|
||||
|
||||
setUp(() async {
|
||||
studysets = FakeStudysetGateway()
|
||||
..receiveRemoteChange(
|
||||
'japanese',
|
||||
encodeStudysetPayload(StudysetContent.titled('Japanese')),
|
||||
);
|
||||
cardGateway = FakeCardGateway();
|
||||
tagGateway = FakeTagGateway();
|
||||
final cards = CardRepository(cardGateway);
|
||||
final tags = TagRepository(tagGateway);
|
||||
addTearDown(cards.dispose);
|
||||
addTearDown(tags.dispose);
|
||||
final taberu = await cards.create(
|
||||
'japanese',
|
||||
CardContent(
|
||||
front: RichContent.plain('食べる'),
|
||||
back: RichContent.plain('to eat'),
|
||||
),
|
||||
);
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(
|
||||
front: RichContent.plain('猫'),
|
||||
back: RichContent.plain('cat'),
|
||||
),
|
||||
);
|
||||
verb = await tags.create('verb');
|
||||
await tags.setTagsOf(taberu, {verb});
|
||||
});
|
||||
|
||||
Future<void> showApp(WidgetTester tester, String location) async {
|
||||
tester.view.physicalSize = const Size(1200, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: studysets,
|
||||
cardGateway: cardGateway,
|
||||
tagGateway: tagGateway,
|
||||
noteGateway: FakeNoteGateway(),
|
||||
),
|
||||
location: location,
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
/// A studyset whose description holds a study button of [attributes].
|
||||
void describedWithButton(Map<String, Object?> attributes) =>
|
||||
studysets.receiveRemoteChange(
|
||||
'lesson',
|
||||
encodeStudysetPayload(
|
||||
StudysetContent(
|
||||
title: 'Lesson',
|
||||
description: Document(
|
||||
root: pageNode(
|
||||
children: [
|
||||
Node(type: studyButtonBlockType, attributes: attributes),
|
||||
],
|
||||
),
|
||||
).toRichContent(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets("goes into a note from Insert: the note's studyset studied "
|
||||
'first, with a tag, a label, and without its topics', (tester) async {
|
||||
await showApp(tester, RoutePaths.newNote('japanese'));
|
||||
await tester.tap(find.byType(AppFlowyEditor));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
final editorState = tester
|
||||
.widget<AppFlowyEditor>(find.byType(AppFlowyEditor))
|
||||
.editorState;
|
||||
|
||||
await tester.tap(find.byTooltip('Insert'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.ensureVisible(find.text('Study button'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Study button'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Japanese'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('All cards'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('verb').last);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Label'), 'Verbs');
|
||||
await tester.tap(find.text('With its topics'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final block = editorState.document.root.children.first;
|
||||
expect(block.type, studyButtonBlockType);
|
||||
expect(block.attributes, {
|
||||
studyButtonStudysetKey: 'japanese',
|
||||
studyButtonTagKey: verb,
|
||||
studyButtonTagNameKey: 'verb',
|
||||
studyButtonLabelKey: 'Verbs',
|
||||
studyButtonTopicsKey: false,
|
||||
studyButtonBackFirstKey: false,
|
||||
});
|
||||
expect(find.text('Verbs'), findsOneWidget);
|
||||
expect(find.text('Tagged verb · Without its topics'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shown read-only, starts a session of the cards it says, '
|
||||
'back first if asked', (tester) async {
|
||||
describedWithButton({
|
||||
studyButtonStudysetKey: 'japanese',
|
||||
studyButtonTagKey: verb,
|
||||
studyButtonTagNameKey: 'verb',
|
||||
studyButtonLabelKey: 'Verbs',
|
||||
studyButtonBackFirstKey: true,
|
||||
});
|
||||
await showApp(tester, RoutePaths.studyset('lesson'));
|
||||
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Verbs'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Only the verb, and its back first.
|
||||
expect(find.textContaining('to eat', findRichText: true), findsOneWidget);
|
||||
expect(find.textContaining('猫', findRichText: true), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a button whose studyset is gone says so', (tester) async {
|
||||
describedWithButton({studyButtonStudysetKey: 'gone'});
|
||||
await showApp(tester, RoutePaths.studyset('lesson'));
|
||||
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Study'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('That studyset no longer exists.'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue