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:
shenlei
2026-09-20 23:54:17 +09:00
co-authored by Claude Opus 5
parent 1a10ca88e0
commit 6b42b7abc3
52 changed files with 4044 additions and 82 deletions
@@ -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);
});
}
+16 -13
View File
@@ -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);
});
});
}
+175
View File
@@ -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);
});
}
+216
View File
@@ -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))),
);
}
});
}