## 独立词库 理解词原先只能跟着课程单元走,学完 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>
389 lines
15 KiB
Dart
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);
|
|
});
|
|
}
|