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
7 changed files with 0 additions and 0 deletions
|
|
@ -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()));
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue