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'; /// Learning engine 2.2/2.2a/3.1: the same-day recall, the recognition step in /// front of the first checkpoint, the daily review budget, and the re-ask of /// an item missed earlier in the session. void main() { ReviewItem due(String id) => ReviewItem( id: id, target: coreItemEnglish(id), prompt: coreReviewTemplate(id).prompt, hint: coreItemEnglish(id), dueAt: DateTime.now().subtract(const Duration(hours: 1)), skill: '回忆表达', ); test('a same-day recap is scheduled for later today, not immediately', () { final state = AppState(); state.scheduleSameDayRecap(['A0-W36']); final recap = state.reviewQueue.singleWhere( (item) => item.kind == ReviewKind.recap, ); final now = DateTime.now(); expect(recap.dueAt.isAfter(now), isTrue); // 「同一天」指的是隔几小时再提取一次,不是明天再说。深夜学习时它会落在 // 次日凌晨,那仍然是同一轮学习,所以这里比的是间隔而不是日历日。 expect(recap.dueAt.difference(now).inMinutes, closeTo(120, 2)); // It is not offered until it comes due: the point is the delay. expect(state.dueRecaps, isEmpty); }); test('finishing a same-day recap advances no checkpoint', () { final state = AppState(); state.reviewQueue.add( due('A0-W36').copyWith(kind: ReviewKind.recap, dueAt: DateTime.now()), ); state.completeReview(state.dueRecaps.single, assisted: false); expect(state.mastery['A0-W36']?.checkpoint ?? 0, 0); expect(state.reviewQueue.where((i) => i.kind == ReviewKind.recap), isEmpty); }); test('missing a same-day recap is not a language failure', () { final state = AppState(); state.reviewQueue.add( due('A0-W36').copyWith(kind: ReviewKind.recap, dueAt: DateTime.now()), ); state.reportReviewFailure(state.dueRecaps.single); expect(state.mastery['A0-W36']?.needsReview ?? false, isFalse); expect(state.dueRecaps, isEmpty); }); test('the first checkpoint opens with a recognition question', () { final state = AppState(); final item = due('A0-W36'); state.reviewQueue.add(item); expect(state.needsRecognitionGate(item), isTrue); expect(recognitionDistractors('A0-W36'), isNotEmpty); }); test('passing recognition records 认识 but no checkpoint', () { final state = AppState(); final item = due('A0-W36'); state.reviewQueue.add(item); state.recordRecognitionGate(item, correct: true); final mastery = state.mastery['A0-W36']!; expect(mastery.checkpoint, 0); expect(mastery.status, MasteryStatus.recognize); // The checkpoint is still earned by producing the item afterwards. state.completeReview(state.dueReviews.single, assisted: false); expect(state.mastery['A0-W36']!.checkpoint, 1); }); test('a recognition miss does not count against the item', () { final state = AppState(); final item = due('A0-W36'); state.reviewQueue.add(item); state.recordRecognitionGate(item, correct: false); final mastery = state.mastery['A0-W36']!; expect(mastery.needsReview, isFalse); expect(mastery.checkpoint, 0); }); test('a recognition success survives rebuilding mastery from evidence', () { final state = AppState(); final item = due('A0-W36'); state.reviewQueue.add(item); state.recordRecognitionGate(item, correct: true); state.rebuildMasteryFromEvidence(); expect(state.mastery['A0-W36']!.checkpoint, 0); expect(state.mastery['A0-W36']!.status, MasteryStatus.recognize); }); test('due reviews beyond the daily budget wait, they are not dropped', () { final state = AppState(); state.dailyMinutes = 20; for (final id in a0CoreItems.keys.take(10)) { state.reviewQueue.add(due(id)); } expect(state.dueReviews.length, 10); expect(state.todayReviewPlan.length, 5); expect(state.postponedReviewCount, 5); // Nothing was rescheduled behind the learner's back. expect(state.dueReviews.length, 10); }); test('a missed item comes back later in the same session', () { final state = AppState(); state.dailyMinutes = 20; state.reviewQueue.add(due('A0-W36')); state.reviewQueue.add(due('A0-W37')); state.reportReviewFailure(state.dueReviews.first); final session = state.reviewSession; // The retry is asked after the other due tasks, not straight away. expect(session.first.id, 'A0-W37'); expect(session.last.id, 'A0-W36'); expect(session.last.kind, ReviewKind.recap); final checkpointBefore = state.mastery['A0-W36']!.checkpoint; state.completeReview(session.last, assisted: false); // The retry is practice: the real check still happens tomorrow. expect(state.mastery['A0-W36']!.checkpoint, checkpointBefore); expect(state.mastery['A0-W36']!.needsReview, isTrue); }); }