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

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