feat: 独立词库、三向认词与当日快闪复习
## 独立词库 理解词原先只能跟着课程单元走,学完 A0 十课词汇量只增加约 22 个实词, 不足以解决"记不住单词"。新增一份独立词库 assets/words/wordbank.json (2748 词,A1–B1),挂进 receptiveWordRegistry 的合成单元 bank-A1/A2/B1, 完全复用理解词已有的状态机,不依赖课程进度,第一天就能用。 数据来源、许可与合成规则记在 tool/words/DATA-NOTE.md:CEFR-J 定等级、 公开词书提供音标、AI 重写全部释义并生成例句、OpenSubtitles 提供口语词频。 词书部分为 CC BY-NC-SA 4.0 且上游权利不明,仅供个人非商用; 若要分发或上架,须替换音标那一列。 ## 背单词机制 - 间隔阶梯 1/3/7/15/30/60/120 天,连续答对上一级,答错回第一级。 原先首次答对后要等 7 天才复习,正是"第二天就忘"的成因。 - 每日新词上限(10 分钟 8 个 / 20 分钟 15 个 / 30 分钟 20 个)。 阶梯第一级是次日,今天引入的新词就是明天的工作量。 - 新词按口语频率发放,不再按字母序 —— A1 从 a.m./ability 变成 no/not/know/just。 - 三个方向按层级轮转:看词(英→中)→ 听词(音→中)→ 想词(中→英)。 想词题仍是选择题,不要求产出,理解词定位不变,不进升级分母。 - 单词页独立成 tab,首页今日任务卡下方给一张认词入口卡。 ## 用法对照 课程 JSON 增加 usage 字段(when/reply/swap/confuse):一个句型用在什么场合、 对方通常怎么答、还能怎么说、跟哪个学过的句型容易混。 知道 How are you? 的意思,不等于知道它不是用来问名字的。 ## 复习流 - 当日快闪(recap)独立成队列,不占复习预算,也不计入积压。 - 只发放当日预算内的量,其余保持到期状态等下次,不悄悄丢弃或改期。 - 答错的项隔几题后回来,而不是立刻重问。 测试 296 通过,flutter analyze 干净。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -108,6 +108,13 @@ void main() {
|
||||
skill: '回忆表达',
|
||||
),
|
||||
);
|
||||
state.mastery['A0-P12'] = const MasteryItem(
|
||||
id: 'A0-P12',
|
||||
label: "I'm from …",
|
||||
status: MasteryStatus.recall,
|
||||
evidence: [],
|
||||
checkpoint: 1,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
|
||||
@@ -59,6 +59,99 @@ void main() {
|
||||
expect(failures, isEmpty);
|
||||
});
|
||||
|
||||
test('every A0 pattern says when it is used', () {
|
||||
final missing = [
|
||||
for (final pack in CourseRepository.instance.units)
|
||||
if (pack.level == 'A0')
|
||||
for (final item in pack.coreItems)
|
||||
if (item.type != 'word' && (coreUsage(item.id)?.when ?? '').isEmpty)
|
||||
'${item.id} "${item.en}"',
|
||||
];
|
||||
expect(
|
||||
missing,
|
||||
isEmpty,
|
||||
reason: '句型要写清什么时候用,否则学会了也不知道该用哪一句',
|
||||
);
|
||||
});
|
||||
|
||||
test('every confusable points at a real taught item with a note', () {
|
||||
final broken = <String>[];
|
||||
for (final pack in CourseRepository.instance.units) {
|
||||
for (final item in pack.coreItems) {
|
||||
for (final other in item.usage.confuse) {
|
||||
if (!isCoreItem(other.id)) broken.add('${item.id} → ${other.id} 不存在');
|
||||
if (other.note.isEmpty) broken.add('${item.id} → ${other.id} 没写区别');
|
||||
if (other.id == item.id) broken.add('${item.id} 指向自己');
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(broken, isEmpty);
|
||||
});
|
||||
|
||||
test('every A0 pattern has something to be told apart from', () {
|
||||
final alone = [
|
||||
for (final pack in CourseRepository.instance.units)
|
||||
if (pack.level == 'A0')
|
||||
for (final item in pack.coreItems)
|
||||
if (item.type != 'word' && item.usage.confuse.isEmpty)
|
||||
'${item.id} "${item.en}"',
|
||||
];
|
||||
expect(alone, isEmpty, reason: '每个句型都要有一句容易混的,才出得了辨析题');
|
||||
});
|
||||
|
||||
test('a pattern can be told apart from something already taught', () {
|
||||
final order = {
|
||||
for (var i = 0; i < CourseRepository.instance.units.length; i++)
|
||||
CourseRepository.instance.units[i].id: i,
|
||||
};
|
||||
final late = <String>[];
|
||||
for (final pack in CourseRepository.instance.units) {
|
||||
if (pack.level != 'A0') continue;
|
||||
for (final item in pack.coreItems) {
|
||||
if (item.type == 'word' || item.usage.confuse.isEmpty) continue;
|
||||
// The first review of an item comes a day after its own unit, so a
|
||||
// pairing that only names later units has nothing to ask about then.
|
||||
final reachable = item.usage.confuse.any((other) {
|
||||
final unit = coreItemRegistry[other.id]?.unit;
|
||||
return unit != null && (order[unit] ?? 999) <= order[pack.id]!;
|
||||
});
|
||||
if (!reachable) late.add('${item.id} 的易混句都在后面的单元');
|
||||
}
|
||||
}
|
||||
expect(late, isEmpty);
|
||||
});
|
||||
|
||||
test('a contrast question offers the item and something else to pick', () {
|
||||
final broken = <String>[];
|
||||
for (final id in allCoreItemIds) {
|
||||
if ((coreUsage(id)?.confuse ?? const []).isEmpty) continue;
|
||||
final question = coreContrastQuestion(id);
|
||||
if (question == null) {
|
||||
broken.add('$id has confusables but no question');
|
||||
continue;
|
||||
}
|
||||
// Two options is a real choice; more than three turns recall into a
|
||||
// reading exercise.
|
||||
if (question.options.length < 2 || question.options.length > 3) {
|
||||
broken.add('$id has ${question.options.length} options');
|
||||
}
|
||||
// A situation that quotes one of the sentences gives the choice away.
|
||||
for (final option in question.options) {
|
||||
if (question.situation.contains(option)) {
|
||||
broken.add('$id 的情境里写出了选项「$option」');
|
||||
}
|
||||
}
|
||||
if (!question.options.contains(question.answer)) {
|
||||
broken.add('$id answer is not among the options');
|
||||
}
|
||||
// Two sentences a learner has to tell apart cannot be the same string.
|
||||
if (question.options.toSet().length != question.options.length) {
|
||||
broken.add('$id repeats an option');
|
||||
}
|
||||
}
|
||||
expect(broken, isEmpty);
|
||||
});
|
||||
|
||||
test('assessment tasks are answerable and replacements point home', () {
|
||||
final ids = {for (final pack in assessmentPacks) pack.id};
|
||||
final failures = <String>[];
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/app_state.dart';
|
||||
import 'package:kouyu_english/core/courses/courses.dart';
|
||||
import 'package:kouyu_english/features/shell/learning_shell.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// The word bank is reachable from its own tab, but nothing on the 学习 tab
|
||||
/// mentioned it, so a learner who opens the app to today's task never saw
|
||||
/// that words were waiting. These cover the entry point, not the quiz.
|
||||
void main() {
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
clearCoreItems();
|
||||
WordBank.instance.resetForTest();
|
||||
await WordBank.instance.load();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
clearCoreItems();
|
||||
WordBank.instance.resetForTest();
|
||||
});
|
||||
|
||||
testWidgets('the 学习 tab offers a round of words and opens the word tab', (
|
||||
tester,
|
||||
) async {
|
||||
final state = AppState()..finishOnboarding();
|
||||
await tester.pumpWidget(MaterialApp(home: LearningShell(state: state)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('认词'), findsOneWidget);
|
||||
expect(find.text('今天还能学 15 个新词'), findsOneWidget);
|
||||
expect(find.text('15 个题,听到、看到能认出来就行,不用会说。'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('开始认词'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('学过的词'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the entry stays once the day is done, as a line not a task', (
|
||||
tester,
|
||||
) async {
|
||||
final state = AppState()..finishOnboarding();
|
||||
for (final question in state.wordSession()) {
|
||||
state.recordWordRecognition(question.id, correct: true);
|
||||
}
|
||||
await tester.pumpWidget(MaterialApp(home: LearningShell(state: state)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(state.wordSessionPreviewSize, 0);
|
||||
expect(find.text('开始认词'), findsNothing);
|
||||
expect(find.text('今天的新词学满 15 个了,明天继续'), findsOneWidget);
|
||||
expect(find.text('认识 15 · 熟悉 0'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('今天的新词学满 15 个了,明天继续'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('学过的词'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -80,7 +80,8 @@ void main() {
|
||||
await tester.pumpWidget(MaterialApp(home: LearningShell(state: state)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(NavigationDestination), findsNWidgets(4));
|
||||
expect(find.byType(NavigationDestination), findsNWidgets(5));
|
||||
expect(find.text('单词'), findsOneWidget);
|
||||
expect(find.text('今日新课'), findsOneWidget);
|
||||
expect(find.text('课程路径'), findsOneWidget);
|
||||
expect(find.textContaining('第 1 课 · '), findsWidgets);
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/app_state.dart';
|
||||
import 'package:kouyu_english/core/courses/course_pack.dart';
|
||||
import 'package:kouyu_english/core/courses/courses.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
import 'package:kouyu_english/features/words/words_page.dart';
|
||||
|
||||
/// Learning engine 3.5: the recognition-only layer — one success to 「认识」,
|
||||
/// a second one at least seven days later to 「熟悉」, a monthly spot check
|
||||
/// after that, and a miss that only ever drops back to 「认识」.
|
||||
/// Calendar days from today to the day the word is next due.
|
||||
///
|
||||
/// A miss schedules by wall clock (tomorrow morning), not by adding 24 hours,
|
||||
/// so measuring the gap as a duration answers 0 for a test run in the evening.
|
||||
/// Counting days on the calendar is what the ladder actually means.
|
||||
int gapDays(DateTime? due) {
|
||||
final today = DateTime.now();
|
||||
return DateTime(due!.year, due.month, due.day)
|
||||
.difference(DateTime(today.year, today.month, today.day))
|
||||
.inDays;
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
registerSavedWord('t-ready', en: 'ready', zh: '准备好', level: 'A0');
|
||||
registerSavedWord('t-later', en: 'later', zh: '晚点', level: 'A0');
|
||||
registerSavedWord('t-busy', en: 'busy', zh: '忙', level: 'A0');
|
||||
registerSavedWord('t-tired', en: 'tired', zh: '累', level: 'A0');
|
||||
registerSavedWord('t-early', en: 'early', zh: '早', level: 'A0');
|
||||
});
|
||||
|
||||
test('a met word starts as 新学 with nothing scheduled', () {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
final word = state.wordKnowledge['t-ready']!;
|
||||
expect(word.status, WordStatus.newWord);
|
||||
expect(word.dueAt, isNull);
|
||||
expect(state.unaskedWords, contains('t-ready'));
|
||||
});
|
||||
|
||||
test('one success reaches 认识 and comes back the next day', () {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
final word = state.wordKnowledge['t-ready']!;
|
||||
expect(word.status, WordStatus.recognized);
|
||||
expect(word.step, 1);
|
||||
expect(gapDays(word.dueAt), 1);
|
||||
expect(state.dueWords, isEmpty);
|
||||
});
|
||||
|
||||
test('each success in a row stretches the gap', () {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
for (final expected in [1, 3, 7, 15, 30, 60, 120, 120]) {
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
expect(gapDays(state.wordKnowledge['t-ready']!.dueAt), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('successes in a row are not enough for 熟悉 without the days', () {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
for (var i = 0; i < 5; i++) {
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
}
|
||||
// Five rungs climbed inside one minute: the calendar has not moved.
|
||||
expect(state.wordKnowledge['t-ready']!.status, WordStatus.recognized);
|
||||
});
|
||||
|
||||
test('the fourth success a week on reaches 熟悉', () {
|
||||
final state = AppState();
|
||||
state.wordKnowledge['t-ready'] = WordKnowledge(
|
||||
id: 't-ready',
|
||||
status: WordStatus.recognized,
|
||||
recognizedAt: DateTime.now().subtract(const Duration(days: 8)),
|
||||
dueAt: DateTime.now().subtract(const Duration(hours: 1)),
|
||||
step: 3,
|
||||
);
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
final word = state.wordKnowledge['t-ready']!;
|
||||
expect(word.status, WordStatus.familiar);
|
||||
expect(gapDays(word.dueAt), 15);
|
||||
});
|
||||
|
||||
test('a miss drops 熟悉 back to 认识 and asks again tomorrow', () {
|
||||
final state = AppState();
|
||||
state.wordKnowledge['t-ready'] = WordKnowledge(
|
||||
id: 't-ready',
|
||||
status: WordStatus.familiar,
|
||||
recognizedAt: DateTime.now().subtract(const Duration(days: 40)),
|
||||
dueAt: DateTime.now(),
|
||||
);
|
||||
state.recordWordRecognition('t-ready', correct: false);
|
||||
final word = state.wordKnowledge['t-ready']!;
|
||||
expect(word.status, WordStatus.recognized);
|
||||
expect(word.misses, 1);
|
||||
expect(word.step, 0, reason: 'a miss goes back to the first rung');
|
||||
expect(gapDays(word.dueAt), 1);
|
||||
expect(state.shakyWords, contains('t-ready'));
|
||||
});
|
||||
|
||||
test('a miss never touches the core mastery of an item', () {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
state.recordWordRecognition('t-ready', correct: false);
|
||||
expect(state.mastery, isEmpty);
|
||||
expect(state.reviewQueue, isEmpty);
|
||||
expect(state.knownItemCount, 0);
|
||||
});
|
||||
|
||||
test('a recovered word has to wait another week for 熟悉', () {
|
||||
final state = AppState();
|
||||
state.wordKnowledge['t-ready'] = WordKnowledge(
|
||||
id: 't-ready',
|
||||
status: WordStatus.familiar,
|
||||
recognizedAt: DateTime.now().subtract(const Duration(days: 40)),
|
||||
dueAt: DateTime.now(),
|
||||
);
|
||||
state.recordWordRecognition('t-ready', correct: false);
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
final word = state.wordKnowledge['t-ready']!;
|
||||
expect(word.status, WordStatus.recognized);
|
||||
expect(word.step, 1, reason: 'the ladder is climbed again from the bottom');
|
||||
expect(gapDays(word.dueAt), 1);
|
||||
});
|
||||
|
||||
test('a snapshot written before the ladder lands on a sensible rung', () {
|
||||
final state = AppState();
|
||||
state.restoreForTest({
|
||||
'wordKnowledge': [
|
||||
{
|
||||
'id': 't-ready',
|
||||
'status': 'familiar',
|
||||
'recognizedAt': DateTime.now()
|
||||
.subtract(const Duration(days: 40))
|
||||
.toIso8601String(),
|
||||
'dueAt': DateTime.now().toIso8601String(),
|
||||
'misses': 0,
|
||||
},
|
||||
{'id': 't-busy', 'status': 'recognized', 'misses': 1},
|
||||
{'id': 't-later', 'status': 'newWord', 'misses': 0},
|
||||
],
|
||||
});
|
||||
expect(state.wordKnowledge['t-ready']!.step, 4);
|
||||
expect(state.wordKnowledge['t-busy']!.step, 1);
|
||||
expect(state.wordKnowledge['t-later']!.step, 0);
|
||||
});
|
||||
|
||||
test('a question shows the word and offers the meaning among others', () {
|
||||
final question = receptiveQuestion('t-ready')!;
|
||||
expect(question.shown, 'ready');
|
||||
expect(question.options, contains('准备好'));
|
||||
expect(question.options.length, greaterThanOrEqualTo(2));
|
||||
expect(question.options.toSet().length, question.options.length);
|
||||
});
|
||||
|
||||
test('a re-ask after a miss uses another set of wrong meanings', () {
|
||||
final first = receptiveQuestion('t-ready', variant: 0)!;
|
||||
final second = receptiveQuestion('t-ready', variant: 1)!;
|
||||
expect(first.options.toSet(), isNot(second.options.toSet()));
|
||||
});
|
||||
|
||||
test('word questions take no more than a third of the review budget', () {
|
||||
final state = AppState();
|
||||
state.dailyMinutes = 20;
|
||||
expect(state.wordBudgetSeconds * 3, lessThanOrEqualTo(state.reviewBudgetSeconds));
|
||||
for (var i = 0; i < 60; i++) {
|
||||
state.wordKnowledge['t-ready$i'] = WordKnowledge(
|
||||
id: 't-ready$i',
|
||||
status: WordStatus.newWord,
|
||||
);
|
||||
registerSavedWord('t-ready$i', en: 'w$i', zh: '意思$i', level: 'A0');
|
||||
}
|
||||
expect(state.todayWordPlan.length, state.wordPlanSize);
|
||||
expect(state.wordPlanSize, lessThan(60));
|
||||
});
|
||||
|
||||
test('a saved lookup word is recognition-only, not a checkpoint task', () {
|
||||
final state = AppState();
|
||||
state.addSavedWord(
|
||||
const VocabularyItem(
|
||||
id: 'saved-ready',
|
||||
word: 'ready',
|
||||
meaning: '准备好',
|
||||
example: '',
|
||||
exampleMeaning: '',
|
||||
),
|
||||
);
|
||||
expect(state.reviewQueue, isEmpty);
|
||||
expect(state.mastery, isEmpty);
|
||||
expect(state.wordStatus('saved-ready'), WordStatus.newWord);
|
||||
expect(isReceptiveWord('saved-ready'), isTrue);
|
||||
expect(state.wordsByUnit[''], contains('saved-ready'));
|
||||
});
|
||||
|
||||
test('word progress survives a save and reload', () async {
|
||||
final state = AppState();
|
||||
state.addSavedWord(
|
||||
const VocabularyItem(
|
||||
id: 'saved-later',
|
||||
word: 'later',
|
||||
meaning: '晚点',
|
||||
example: '',
|
||||
exampleMeaning: '',
|
||||
),
|
||||
);
|
||||
state.recordWordRecognition('saved-later', correct: true);
|
||||
|
||||
final restored = AppState()..restoreForTest(state.snapshotForTest());
|
||||
expect(restored.wordStatus('saved-later'), WordStatus.recognized);
|
||||
expect(restored.savedWords['saved-later']?.en, 'later');
|
||||
expect(wordEnglish('saved-later'), 'later');
|
||||
});
|
||||
|
||||
test('finishing a unit puts its receptive words on the word page', () {
|
||||
registerReceptiveWord(
|
||||
const ReceptiveWord(id: 'u-repeat', en: 'repeat', zh: '重复'),
|
||||
level: 'A0',
|
||||
unit: 'a0-01',
|
||||
);
|
||||
final state = AppState();
|
||||
state.meetWordsOfUnit('a0-01');
|
||||
expect(state.wordsByUnit['a0-01'], contains('u-repeat'));
|
||||
expect(state.wordStatus('u-repeat'), WordStatus.newWord);
|
||||
// Meeting a word is exposure, so nothing is scheduled by itself.
|
||||
expect(state.dueWords, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('the word page asks a word and records the answer', (
|
||||
tester,
|
||||
) async {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
await tester.pumpWidget(MaterialApp(home: WordsPage(state: state)));
|
||||
|
||||
expect(find.text('ready'), findsOneWidget);
|
||||
await tester.tap(find.text('开始认词'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final question = state.todayWordPlan.first;
|
||||
expect(question.id, 't-ready');
|
||||
await tester.tap(find.widgetWithText(OutlinedButton, '准备好'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(state.wordStatus('t-ready'), WordStatus.recognized);
|
||||
expect(find.text('对了。'), findsOneWidget);
|
||||
});
|
||||
|
||||
test('a word is introduced in writing, then heard, then recalled', () {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
// Nothing to hold on to yet, so the first sight of a word is spelled out.
|
||||
expect(state.wordAskMode('t-ready'), WordAskMode.read);
|
||||
for (final expected in [
|
||||
WordAskMode.listen,
|
||||
WordAskMode.recall,
|
||||
WordAskMode.read,
|
||||
WordAskMode.listen,
|
||||
WordAskMode.recall,
|
||||
]) {
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
expect(state.wordAskMode('t-ready'), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('a recall question asks for the word and offers spellings', () {
|
||||
final question = receptiveQuestion('t-ready', mode: WordAskMode.recall)!;
|
||||
expect(question.shown, '准备好');
|
||||
expect(question.answer, 'ready');
|
||||
expect(question.options, contains('ready'));
|
||||
expect(question.options, hasLength(3));
|
||||
for (final option in question.options) {
|
||||
// Options are spellings; a meaning among them would give the answer away.
|
||||
expect(option, matches(RegExp(r'^[a-zA-Z ]+$')), reason: option);
|
||||
}
|
||||
});
|
||||
|
||||
test('no two options mean the same thing', () {
|
||||
// A second word glossed 「准备好」 would make both spellings right.
|
||||
registerSavedWord('t-set', en: 'set', zh: '准备好', level: 'A0');
|
||||
final question = receptiveQuestion('t-ready', mode: WordAskMode.recall)!;
|
||||
expect(question.options, isNot(contains('set')));
|
||||
});
|
||||
|
||||
test('the day after a miss shows the spelling again', () {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
expect(state.wordAskMode('t-ready'), WordAskMode.listen);
|
||||
state.recordWordRecognition('t-ready', correct: false);
|
||||
// A miss is no time to take the spelling away on top of it.
|
||||
expect(state.wordAskMode('t-ready'), WordAskMode.read);
|
||||
});
|
||||
|
||||
test('a round carries the mode each word is due for', () {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
state.meetWord('t-later');
|
||||
state.recordWordRecognition('t-later', correct: true);
|
||||
state.wordKnowledge['t-later'] = state.wordKnowledge['t-later']!.copyWith(
|
||||
dueAt: DateTime.now().subtract(const Duration(hours: 1)),
|
||||
);
|
||||
final modes = {
|
||||
for (final question in state.wordSession()) question.id: question.mode,
|
||||
};
|
||||
expect(modes['t-ready'], WordAskMode.read);
|
||||
expect(modes['t-later'], WordAskMode.listen);
|
||||
});
|
||||
|
||||
testWidgets('a listening question withholds the spelling until answered', (
|
||||
tester,
|
||||
) async {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
state.wordKnowledge['t-ready'] = state.wordKnowledge['t-ready']!.copyWith(
|
||||
dueAt: DateTime.now().subtract(const Duration(hours: 1)),
|
||||
);
|
||||
await tester.pumpWidget(MaterialApp(home: WordsPage(state: state)));
|
||||
await tester.tap(find.text('开始认词'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('听词 · 1 / 1'), findsOneWidget);
|
||||
// Showing 'ready' here would turn the question back into a reading one.
|
||||
// The list behind the quiz is gone, so this is the whole screen.
|
||||
expect(find.text('ready'), findsNothing);
|
||||
expect(find.text('· · ·'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.replay_outlined), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(OutlinedButton, '准备好'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Now it is worth seeing: this is where the sound meets the spelling.
|
||||
expect(find.text('ready'), findsOneWidget);
|
||||
expect(state.wordKnowledge['t-ready']!.step, 2);
|
||||
});
|
||||
|
||||
testWidgets('a recall question asks from the meaning, with nothing to play', (
|
||||
tester,
|
||||
) async {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
state.recordWordRecognition('t-ready', correct: true);
|
||||
expect(state.wordAskMode('t-ready'), WordAskMode.recall);
|
||||
state.wordKnowledge['t-ready'] = state.wordKnowledge['t-ready']!.copyWith(
|
||||
dueAt: DateTime.now().subtract(const Duration(hours: 1)),
|
||||
);
|
||||
await tester.pumpWidget(MaterialApp(home: WordsPage(state: state)));
|
||||
await tester.tap(find.text('开始认词'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('想词 · 1 / 1'), findsOneWidget);
|
||||
// The prompt is the meaning, and it is the only Chinese on screen: the
|
||||
// options are spellings.
|
||||
expect(find.text('准备好'), findsOneWidget);
|
||||
// Playing the word here would simply read out the answer.
|
||||
expect(find.byIcon(Icons.volume_up_outlined), findsNothing);
|
||||
|
||||
await tester.tap(find.widgetWithText(OutlinedButton, 'ready'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Once the answer is in: the prompt turns into the spelling, which now
|
||||
// stands beside the option that was picked, and the word can be played.
|
||||
expect(find.text('ready'), findsNWidgets(2));
|
||||
expect(find.byIcon(Icons.volume_up_outlined), findsOneWidget);
|
||||
expect(state.wordKnowledge['t-ready']!.step, 3);
|
||||
});
|
||||
|
||||
testWidgets('a wrong pick tells the learner the meaning', (tester) async {
|
||||
final state = AppState();
|
||||
state.meetWord('t-ready');
|
||||
await tester.pumpWidget(MaterialApp(home: WordsPage(state: state)));
|
||||
await tester.tap(find.text('开始认词'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final wrong = state.todayWordPlan.first.options.firstWhere(
|
||||
(option) => option != '准备好',
|
||||
);
|
||||
await tester.tap(find.widgetWithText(OutlinedButton, wrong));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(state.wordKnowledge['t-ready']!.misses, 1);
|
||||
expect(find.textContaining('准备好'), findsWidgets);
|
||||
});
|
||||
}
|
||||
@@ -106,6 +106,13 @@ void main() {
|
||||
skill: '回忆表达',
|
||||
),
|
||||
);
|
||||
state.mastery['A0-P12'] = const MasteryItem(
|
||||
id: 'A0-P12',
|
||||
label: "I'm from …",
|
||||
status: MasteryStatus.recall,
|
||||
evidence: [],
|
||||
checkpoint: 1,
|
||||
);
|
||||
final content = jsonEncode({
|
||||
'schemaVersion': 'writing-feedback-1',
|
||||
'verdict': 'rewrite',
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/app_state.dart';
|
||||
import 'package:kouyu_english/core/courses/courses.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
|
||||
/// Learning engine 2.2/2.2a/3.1: the same-day recall, the recognition step in
|
||||
/// front of the first checkpoint, the daily review budget, and the re-ask of
|
||||
/// an item missed earlier in the session.
|
||||
void main() {
|
||||
ReviewItem due(String id) => ReviewItem(
|
||||
id: id,
|
||||
target: coreItemEnglish(id),
|
||||
prompt: coreReviewTemplate(id).prompt,
|
||||
hint: coreItemEnglish(id),
|
||||
dueAt: DateTime.now().subtract(const Duration(hours: 1)),
|
||||
skill: '回忆表达',
|
||||
);
|
||||
|
||||
test('a same-day recap is scheduled for later today, not immediately', () {
|
||||
final state = AppState();
|
||||
state.scheduleSameDayRecap(['A0-W36']);
|
||||
final recap = state.reviewQueue.singleWhere(
|
||||
(item) => item.kind == ReviewKind.recap,
|
||||
);
|
||||
final now = DateTime.now();
|
||||
expect(recap.dueAt.isAfter(now), isTrue);
|
||||
// 「同一天」指的是隔几小时再提取一次,不是明天再说。深夜学习时它会落在
|
||||
// 次日凌晨,那仍然是同一轮学习,所以这里比的是间隔而不是日历日。
|
||||
expect(recap.dueAt.difference(now).inMinutes, closeTo(120, 2));
|
||||
// It is not offered until it comes due: the point is the delay.
|
||||
expect(state.dueRecaps, isEmpty);
|
||||
});
|
||||
|
||||
test('finishing a same-day recap advances no checkpoint', () {
|
||||
final state = AppState();
|
||||
state.reviewQueue.add(
|
||||
due('A0-W36').copyWith(kind: ReviewKind.recap, dueAt: DateTime.now()),
|
||||
);
|
||||
state.completeReview(state.dueRecaps.single, assisted: false);
|
||||
expect(state.mastery['A0-W36']?.checkpoint ?? 0, 0);
|
||||
expect(state.reviewQueue.where((i) => i.kind == ReviewKind.recap), isEmpty);
|
||||
});
|
||||
|
||||
test('missing a same-day recap is not a language failure', () {
|
||||
final state = AppState();
|
||||
state.reviewQueue.add(
|
||||
due('A0-W36').copyWith(kind: ReviewKind.recap, dueAt: DateTime.now()),
|
||||
);
|
||||
state.reportReviewFailure(state.dueRecaps.single);
|
||||
expect(state.mastery['A0-W36']?.needsReview ?? false, isFalse);
|
||||
expect(state.dueRecaps, isEmpty);
|
||||
});
|
||||
|
||||
test('the first checkpoint opens with a recognition question', () {
|
||||
final state = AppState();
|
||||
final item = due('A0-W36');
|
||||
state.reviewQueue.add(item);
|
||||
expect(state.needsRecognitionGate(item), isTrue);
|
||||
expect(recognitionDistractors('A0-W36'), isNotEmpty);
|
||||
});
|
||||
|
||||
test('passing recognition records 认识 but no checkpoint', () {
|
||||
final state = AppState();
|
||||
final item = due('A0-W36');
|
||||
state.reviewQueue.add(item);
|
||||
state.recordRecognitionGate(item, correct: true);
|
||||
final mastery = state.mastery['A0-W36']!;
|
||||
expect(mastery.checkpoint, 0);
|
||||
expect(mastery.status, MasteryStatus.recognize);
|
||||
// The checkpoint is still earned by producing the item afterwards.
|
||||
state.completeReview(state.dueReviews.single, assisted: false);
|
||||
expect(state.mastery['A0-W36']!.checkpoint, 1);
|
||||
});
|
||||
|
||||
test('a recognition miss does not count against the item', () {
|
||||
final state = AppState();
|
||||
final item = due('A0-W36');
|
||||
state.reviewQueue.add(item);
|
||||
state.recordRecognitionGate(item, correct: false);
|
||||
final mastery = state.mastery['A0-W36']!;
|
||||
expect(mastery.needsReview, isFalse);
|
||||
expect(mastery.checkpoint, 0);
|
||||
});
|
||||
|
||||
test('a recognition success survives rebuilding mastery from evidence', () {
|
||||
final state = AppState();
|
||||
final item = due('A0-W36');
|
||||
state.reviewQueue.add(item);
|
||||
state.recordRecognitionGate(item, correct: true);
|
||||
state.rebuildMasteryFromEvidence();
|
||||
expect(state.mastery['A0-W36']!.checkpoint, 0);
|
||||
expect(state.mastery['A0-W36']!.status, MasteryStatus.recognize);
|
||||
});
|
||||
|
||||
test('due reviews beyond the daily budget wait, they are not dropped', () {
|
||||
final state = AppState();
|
||||
state.dailyMinutes = 20;
|
||||
for (final id in a0CoreItems.keys.take(10)) {
|
||||
state.reviewQueue.add(due(id));
|
||||
}
|
||||
expect(state.dueReviews.length, 10);
|
||||
expect(state.todayReviewPlan.length, 5);
|
||||
expect(state.postponedReviewCount, 5);
|
||||
// Nothing was rescheduled behind the learner's back.
|
||||
expect(state.dueReviews.length, 10);
|
||||
});
|
||||
|
||||
test('a missed item comes back later in the same session', () {
|
||||
final state = AppState();
|
||||
state.dailyMinutes = 20;
|
||||
state.reviewQueue.add(due('A0-W36'));
|
||||
state.reviewQueue.add(due('A0-W37'));
|
||||
state.reportReviewFailure(state.dueReviews.first);
|
||||
final session = state.reviewSession;
|
||||
// The retry is asked after the other due tasks, not straight away.
|
||||
expect(session.first.id, 'A0-W37');
|
||||
expect(session.last.id, 'A0-W36');
|
||||
expect(session.last.kind, ReviewKind.recap);
|
||||
|
||||
final checkpointBefore = state.mastery['A0-W36']!.checkpoint;
|
||||
state.completeReview(session.last, assisted: false);
|
||||
// The retry is practice: the real check still happens tomorrow.
|
||||
expect(state.mastery['A0-W36']!.checkpoint, checkpointBefore);
|
||||
expect(state.mastery['A0-W36']!.needsReview, isTrue);
|
||||
});
|
||||
}
|
||||
@@ -169,14 +169,16 @@ void main() {
|
||||
contextSentence: "I'd like to check in please.",
|
||||
);
|
||||
|
||||
expect(state.reviewQueue.any((r) => r.target == 'check in'), isTrue);
|
||||
final saved = state.reviewQueue.firstWhere(
|
||||
(r) => r.target == 'check in',
|
||||
// Learning engine 3.5: a saved phrase is an extension word. It is
|
||||
// recognition-only, so it goes to the word list, not the checkpoint
|
||||
// queue where it would be asked to be produced.
|
||||
expect(state.reviewQueue, isEmpty);
|
||||
final id = state.savedWords.keys.singleWhere(
|
||||
(key) => state.savedWords[key]!.en == 'check in',
|
||||
);
|
||||
|
||||
expect(saved.hint, contains('办理登机或入住'));
|
||||
expect(saved.hint, contains('酒店机场高频词'));
|
||||
expect(saved.prompt, "I'd like to check in please.");
|
||||
expect(state.savedWords[id]!.zh, contains('办理登机或入住'));
|
||||
expect(state.savedWords[id]!.zh, contains('酒店机场高频词'));
|
||||
expect(state.wordStatus(id), WordStatus.newWord);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -234,19 +236,20 @@ void main() {
|
||||
expect(find.text('重点短语与搭配 (1)'), findsOneWidget);
|
||||
expect(find.text('departure gate'), findsOneWidget);
|
||||
expect(find.text('登机口'), findsOneWidget);
|
||||
expect(find.text('加复习'), findsOneWidget);
|
||||
expect(find.text('加到单词'), findsOneWidget);
|
||||
|
||||
// Scroll to '加复习' and tap
|
||||
await tester.ensureVisible(find.text('加复习'));
|
||||
// Scroll to '加到单词' and tap
|
||||
await tester.ensureVisible(find.text('加到单词'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('加复习'));
|
||||
await tester.tap(find.text('加到单词'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
state.reviewQueue.any((r) => r.target == 'departure gate'),
|
||||
state.savedWords.values.any((word) => word.en == 'departure gate'),
|
||||
isTrue,
|
||||
);
|
||||
expect(find.text('已在复习'), findsOneWidget);
|
||||
expect(state.reviewQueue, isEmpty);
|
||||
expect(find.text('已在单词'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/app_state.dart';
|
||||
import 'package:kouyu_english/core/courses/courses.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
import 'package:kouyu_english/features/review/review_page.dart';
|
||||
|
||||
/// Knowing what a sentence means is not knowing when to say it. The packs
|
||||
/// carry that as `usage`; these cover what the app does with it — learning
|
||||
/// engine 3.1 (what counts as which evidence) and 3.5 (recognition inside a
|
||||
/// situation).
|
||||
void main() {
|
||||
ReviewItem due(String id) => ReviewItem(
|
||||
id: id,
|
||||
target: coreItemEnglish(id),
|
||||
prompt: coreReviewTemplate(id).prompt,
|
||||
hint: coreItemEnglish(id),
|
||||
dueAt: DateTime.now().subtract(const Duration(hours: 1)),
|
||||
skill: '回忆表达',
|
||||
);
|
||||
|
||||
test('the pack says when a pattern is used and what it is confused with', () {
|
||||
final usage = coreUsage('A0-P02')!;
|
||||
expect(usage.when, isNotEmpty);
|
||||
expect(usage.reply, isNotEmpty);
|
||||
expect(
|
||||
usage.confuse.map((entry) => entry.id),
|
||||
contains('A0-P05'),
|
||||
reason: 'What\'s your name? 和 How are you? 正是分不清的那一对',
|
||||
);
|
||||
});
|
||||
|
||||
test('a contrast question asks which sentence the situation calls for', () {
|
||||
final question = coreContrastQuestion('A0-P02')!;
|
||||
expect(question.situation, coreUsage('A0-P02')!.when);
|
||||
expect(question.answer, coreItemSpoken('A0-P02'));
|
||||
expect(question.options, contains(coreItemSpoken('A0-P05')));
|
||||
expect(question.note, isNotEmpty);
|
||||
});
|
||||
|
||||
test('an untaught confusable is left out of the question', () {
|
||||
// `How are you?` is taught two units later than `What's your name?`, so
|
||||
// on the day P02 is first reviewed it is not a choice the learner could
|
||||
// make sense of. Its same-unit partner still is.
|
||||
final early = coreContrastQuestion('A0-P02', isTaught: (_) => false)!;
|
||||
expect(early.options, isNot(contains(coreItemSpoken('A0-P05'))));
|
||||
expect(early.options, contains(coreItemSpoken('A0-P01')));
|
||||
// Once the learner has met it, it is the option worth offering.
|
||||
final later = coreContrastQuestion(
|
||||
'A0-P02',
|
||||
isTaught: (id) => id == 'A0-P05',
|
||||
)!;
|
||||
expect(later.options, contains(coreItemSpoken('A0-P05')));
|
||||
});
|
||||
|
||||
test('a contrast question never offers more than three sentences', () {
|
||||
// `What's your name?` is confused with three other taught sentences.
|
||||
expect(coreUsage('A0-P02')!.confuse.length, greaterThan(2));
|
||||
expect(coreContrastQuestion('A0-P02')!.options, hasLength(3));
|
||||
});
|
||||
|
||||
test('answering a contrast question records 认识, not a checkpoint', () {
|
||||
final state = AppState();
|
||||
final item = due('A0-P02');
|
||||
state.reviewQueue.add(item);
|
||||
state.recordContrastAnswer(item, correct: true);
|
||||
final mastery = state.mastery['A0-P02']!;
|
||||
expect(mastery.status, MasteryStatus.recognize);
|
||||
expect(mastery.checkpoint, 0);
|
||||
// It is not recall either: the learner picked the sentence, not said it.
|
||||
expect(mastery.status, isNot(MasteryStatus.recall));
|
||||
});
|
||||
|
||||
test('a contrast answer survives rebuilding mastery from evidence', () {
|
||||
final state = AppState();
|
||||
final item = due('A0-P02');
|
||||
state.reviewQueue.add(item);
|
||||
state.recordContrastAnswer(item, correct: true);
|
||||
state.rebuildMasteryFromEvidence();
|
||||
expect(state.mastery['A0-P02']!.checkpoint, 0);
|
||||
expect(state.mastery['A0-P02']!.status, MasteryStatus.recognize);
|
||||
});
|
||||
|
||||
test('the contrast question cannot undo the miss that led to it', () {
|
||||
final state = AppState();
|
||||
final item = due('A0-P02');
|
||||
state.reviewQueue.add(item);
|
||||
state.reportReviewFailure(item);
|
||||
expect(state.mastery['A0-P02']!.needsReview, isTrue);
|
||||
state.recordContrastAnswer(item, correct: true);
|
||||
expect(state.mastery['A0-P02']!.needsReview, isTrue);
|
||||
expect(state.mastery['A0-P02']!.checkpoint, 0);
|
||||
});
|
||||
|
||||
test('getting the contrast wrong is not a language failure', () {
|
||||
final state = AppState();
|
||||
final item = due('A0-P02');
|
||||
state.reviewQueue.add(item);
|
||||
state.recordContrastAnswer(item, correct: false);
|
||||
expect(state.mastery['A0-P02']!.needsReview, isFalse);
|
||||
expect(state.mastery['A0-P02']!.checkpoint, 0);
|
||||
});
|
||||
|
||||
testWidgets('a miss opens the explanation, then the contrast question', (
|
||||
tester,
|
||||
) async {
|
||||
final messenger = tester.binding.defaultBinaryMessenger;
|
||||
for (final name in const [
|
||||
'com.llfbandit.record/messages',
|
||||
'xyz.luan/audioplayers',
|
||||
'xyz.luan/audioplayers.global',
|
||||
]) {
|
||||
messenger.setMockMethodCallHandler(MethodChannel(name), (_) async => null);
|
||||
}
|
||||
messenger.setMockStreamHandler(
|
||||
const EventChannel('xyz.luan/audioplayers.global/events'),
|
||||
MockStreamHandler.inline(onListen: (_, _) {}),
|
||||
);
|
||||
final reportError = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
if (details.exception is! MissingPluginException) {
|
||||
reportError?.call(details);
|
||||
}
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = reportError);
|
||||
|
||||
final state = AppState();
|
||||
state.reviewQueue.add(due('A0-P02'));
|
||||
// Past the first checkpoint, so the review opens on the production task
|
||||
// rather than the recognition question.
|
||||
state.mastery['A0-P02'] = const MasteryItem(
|
||||
id: 'A0-P02',
|
||||
label: "What's your name?",
|
||||
status: MasteryStatus.recall,
|
||||
evidence: [],
|
||||
checkpoint: 1,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ReviewPage(
|
||||
state: state,
|
||||
onFinished: () {},
|
||||
onOpenAdaptiveLesson: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final giveUp = find.text('暂时想不起来');
|
||||
await tester.ensureVisible(giveUp);
|
||||
await tester.tap(giveUp);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('先弄清楚什么时候用'), findsOneWidget);
|
||||
expect(find.textContaining(coreUsage('A0-P02')!.when), findsWidgets);
|
||||
|
||||
final checkpointAfterMiss = state.mastery['A0-P02']!.checkpoint;
|
||||
final wrong = find.widgetWithText(
|
||||
OutlinedButton,
|
||||
coreItemSpoken('A0-P01'),
|
||||
);
|
||||
await tester.ensureVisible(wrong);
|
||||
await tester.tap(wrong);
|
||||
await tester.pumpAndSettle();
|
||||
// The answer is named and the difference explained, and the miss stands.
|
||||
expect(
|
||||
find.textContaining('这里要说 ${coreItemSpoken('A0-P02')}'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(state.mastery['A0-P02']!.needsReview, isTrue);
|
||||
expect(state.mastery['A0-P02']!.checkpoint, checkpointAfterMiss);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/app_state.dart';
|
||||
import 'package:kouyu_english/core/courses/courses.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
|
||||
/// The word bank exists so vocabulary is not capped by lesson progress: a
|
||||
/// learner still on A0 must have words to recognise from the first day.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
clearCoreItems();
|
||||
WordBank.instance.resetForTest();
|
||||
await WordBank.instance.load();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
clearCoreItems();
|
||||
WordBank.instance.resetForTest();
|
||||
});
|
||||
|
||||
test('the bank carries a few thousand words with gloss and IPA', () {
|
||||
final ids = receptiveWordRegistry.keys.where(
|
||||
(id) => WordBank.isBankUnit(receptiveWordRegistry[id]!.unit),
|
||||
);
|
||||
expect(ids.length, greaterThan(2000));
|
||||
for (final id in ids) {
|
||||
expect(wordEnglish(id), isNotEmpty, reason: id);
|
||||
expect(wordMeaning(id), isNotEmpty, reason: id);
|
||||
}
|
||||
final withIpa = ids.where((id) => wordIpa(id).isNotEmpty);
|
||||
expect(withIpa.length / ids.length, greaterThan(0.98));
|
||||
});
|
||||
|
||||
test('every level pool is filed under its own unit', () {
|
||||
final units = receptiveWordRegistry.values
|
||||
.map((word) => word.unit)
|
||||
.where(WordBank.isBankUnit)
|
||||
.toSet();
|
||||
expect(units, containsAll(['bank-A1', 'bank-A2', 'bank-B1']));
|
||||
expect(WordBank.levelOfUnit('bank-A2'), 'A2');
|
||||
});
|
||||
|
||||
test('new words are handed out by spoken frequency, not alphabetically', () {
|
||||
final state = AppState();
|
||||
final first = state.unstartedBankWords.take(12);
|
||||
final words = first.map(wordEnglish).toList();
|
||||
// Alphabetical order would open with `a.m.`, `ability`, `able`.
|
||||
expect(words, contains('know'));
|
||||
expect(words.first, isNot('a.m.'));
|
||||
final ranks = first.map(WordBank.instance.rankOf).toList();
|
||||
expect(ranks, orderedEquals(ranks.toList()..sort()));
|
||||
});
|
||||
|
||||
test('almost every word has a real frequency rank', () {
|
||||
final ids = receptiveWordRegistry.keys.where(
|
||||
(id) => WordBank.isBankUnit(receptiveWordRegistry[id]!.unit),
|
||||
);
|
||||
final ranked = ids.where(
|
||||
(id) => WordBank.instance.rankOf(id) != WordBank.unranked,
|
||||
);
|
||||
expect(ranked.length / ids.length, greaterThan(0.99));
|
||||
});
|
||||
|
||||
test('a fresh learner has bank words waiting and none started', () {
|
||||
final state = AppState();
|
||||
expect(state.wordKnowledge, isEmpty);
|
||||
expect(state.unstartedBankWordCount, greaterThan(2000));
|
||||
expect(state.wordSessionPreviewSize, state.newWordsPerDay);
|
||||
});
|
||||
|
||||
test('a round hands out new bank words, easiest level first', () {
|
||||
final state = AppState();
|
||||
final plan = state.wordSession(size: 5);
|
||||
expect(plan, hasLength(5));
|
||||
for (final question in plan) {
|
||||
expect(question.options, contains(question.answer));
|
||||
expect(question.options, hasLength(3));
|
||||
expect(receptiveWordRegistry[question.id]!.level, 'A1');
|
||||
expect(state.wordStatus(question.id), WordStatus.newWord);
|
||||
}
|
||||
});
|
||||
|
||||
test('the day has a cap on how many new words the bank starts', () {
|
||||
final state = AppState();
|
||||
expect(state.newWordsPerDay, 15, reason: 'the 20-minute default');
|
||||
final first = state.wordSession(size: 20);
|
||||
expect(first, hasLength(15), reason: 'a round stops at the cap');
|
||||
expect(state.newWordQuota, 0);
|
||||
// A second round adds nothing new. It re-offers the same words, which is
|
||||
// right: a round left half-finished should be finishable.
|
||||
final second = state.wordSession(size: 20);
|
||||
expect(second.map((q) => q.id), first.map((q) => q.id));
|
||||
expect(state.newWordsStartedToday, 15);
|
||||
});
|
||||
|
||||
test('the cap follows the daily time budget', () {
|
||||
final state = AppState();
|
||||
state.setDailyMinutes(10);
|
||||
expect(state.newWordsPerDay, 8);
|
||||
expect(state.wordSession(size: 20), hasLength(8));
|
||||
state.setDailyMinutes(30);
|
||||
// The cap moved, so the rest of today's allowance opens up.
|
||||
expect(state.newWordQuota, 12);
|
||||
});
|
||||
|
||||
test('what is due is asked even after the new-word cap is used up', () {
|
||||
final state = AppState();
|
||||
final started = state.wordSession(size: 20);
|
||||
expect(state.newWordQuota, 0);
|
||||
for (final question in started.take(3)) {
|
||||
state.recordWordRecognition(question.id, correct: false);
|
||||
}
|
||||
// Missed words are due tomorrow, so nothing is due right now -- but the
|
||||
// cap must not be what is stopping them.
|
||||
state.wordKnowledge[started.first.id] = state
|
||||
.wordKnowledge[started.first.id]!
|
||||
.copyWith(dueAt: DateTime.now().subtract(const Duration(hours: 1)));
|
||||
final next = state.wordSession(size: 20);
|
||||
expect(next.map((q) => q.id), contains(started.first.id));
|
||||
});
|
||||
|
||||
test('answering a new word does not give the cap back', () {
|
||||
final state = AppState();
|
||||
for (final question in state.wordSession(size: 20)) {
|
||||
state.recordWordRecognition(question.id, correct: true);
|
||||
}
|
||||
// Answering is what a round is for; if it cleared the word off today's
|
||||
// count, one sitting could start the bank's whole A1 list.
|
||||
expect(state.newWordsStartedToday, 15);
|
||||
expect(state.newWordQuota, 0);
|
||||
expect(state.wordSession(size: 20), isEmpty);
|
||||
});
|
||||
|
||||
test('a word started today counts against the cap after a reload', () {
|
||||
final state = AppState();
|
||||
state.wordSession(size: 5);
|
||||
final restored = AppState()..restoreForTest(state.snapshotForTest());
|
||||
expect(restored.newWordsStartedToday, 5);
|
||||
expect(restored.newWordQuota, 10);
|
||||
});
|
||||
|
||||
test('a word handed out once is not handed out again next round', () {
|
||||
final state = AppState();
|
||||
final first = state.wordSession(size: 5).map((q) => q.id).toSet();
|
||||
for (final id in first) {
|
||||
state.recordWordRecognition(id, correct: true);
|
||||
}
|
||||
final second = state.wordSession(size: 5).map((q) => q.id).toSet();
|
||||
expect(second.intersection(first), isEmpty);
|
||||
});
|
||||
|
||||
test('a missed word waits for tomorrow instead of the same round', () {
|
||||
final state = AppState();
|
||||
final missed = state.wordSession(size: 3).map((q) => q.id).toSet();
|
||||
for (final id in missed) {
|
||||
state.recordWordRecognition(id, correct: false);
|
||||
}
|
||||
final next = state.wordSession(size: 10).map((q) => q.id).toSet();
|
||||
expect(next.intersection(missed), isEmpty);
|
||||
expect(state.shakyWords.toSet(), missed);
|
||||
});
|
||||
|
||||
test('a word met in a unit is asked before a new one from the bank', () {
|
||||
registerSavedWord('t-unit', en: 'ready', zh: '准备好', level: 'A0');
|
||||
final state = AppState();
|
||||
state.meetWord('t-unit');
|
||||
expect(state.wordSession(size: 4).first.id, 't-unit');
|
||||
});
|
||||
|
||||
test('bank words stay out of the checkpoint queue and mastery', () {
|
||||
final state = AppState();
|
||||
final plan = state.wordSession(size: 3);
|
||||
for (final question in plan) {
|
||||
state.recordWordRecognition(question.id, correct: false);
|
||||
}
|
||||
expect(state.reviewQueue, isEmpty);
|
||||
expect(state.mastery, isEmpty);
|
||||
});
|
||||
|
||||
test('every word carries an example sentence with its Chinese', () {
|
||||
final ids = receptiveWordRegistry.keys.where(
|
||||
(id) => WordBank.isBankUnit(receptiveWordRegistry[id]!.unit),
|
||||
);
|
||||
for (final id in ids) {
|
||||
final sample = WordBank.instance.exampleOf(id);
|
||||
expect(sample, isNotNull, reason: id);
|
||||
expect(sample!.en, isNotEmpty, reason: id);
|
||||
expect(sample.zh, isNotEmpty, reason: id);
|
||||
}
|
||||
});
|
||||
|
||||
test('a gloss is short enough to be a quiz option', () {
|
||||
final glosses = receptiveWordRegistry.entries
|
||||
.where((entry) => WordBank.isBankUnit(entry.value.unit))
|
||||
.map((entry) => entry.value.zh);
|
||||
for (final gloss in glosses) {
|
||||
expect(gloss.length, lessThanOrEqualTo(10), reason: gloss);
|
||||
expect(gloss, isNot(contains('(')), reason: gloss);
|
||||
expect(gloss, isNot(contains('(')), reason: gloss);
|
||||
}
|
||||
});
|
||||
|
||||
test('extra senses are kept aside from the quiz answer', () {
|
||||
final withMore = receptiveWordRegistry.keys.where(
|
||||
(id) => WordBank.instance.moreSenses(id).isNotEmpty,
|
||||
);
|
||||
expect(withMore, isNotEmpty);
|
||||
for (final id in withMore.take(50)) {
|
||||
expect(
|
||||
WordBank.instance.moreSenses(id).split(';'),
|
||||
isNot(contains(wordMeaning(id))),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user