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