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