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