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
12 changed files with 0 additions and 0 deletions

View file

@ -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),
);
});
}

View file

@ -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,
);
});
});
}

View file

@ -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,
);
});
});
}

View file

@ -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);
});
}

View file

@ -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);
});
}

View file

@ -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());
}
});
}

View file

@ -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);
});
}

View file

@ -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']);
});
});
}

View file

@ -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()));
}
});
}

View file

@ -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