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