Files
English/kouyu_english/test/receptive_words_test.dart
T
shenleiandClaude Opus 5 6b42b7abc3 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>
2026-09-20 23:54:17 +09:00

389 lines
15 KiB
Dart

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