import 'package:flutter_test/flutter_test.dart'; import 'package:kouyu_english/core/app_state.dart'; import 'package:kouyu_english/core/a0_core.dart'; import 'package:kouyu_english/core/assessment_bank.dart'; import 'package:kouyu_english/core/generated_content.dart'; import 'package:kouyu_english/core/models.dart'; import 'package:kouyu_english/core/seed_courses.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { ReviewItem dueItem([String id = 'A0-P12']) => ReviewItem( id: id, target: "I'm from [place].", prompt: 'Hi! Where are you from?', hint: "I'm from Hong Kong.", dueAt: DateTime.now(), skill: '口语表达', ); test('an overdue review is always the primary task', () { final state = AppState(); state.reviewQueue.add(dueItem()); expect(state.reviewIsPrimary, isTrue); expect(state.dueReviewCount, 1); }); test('review backlog follows time budget or a seven-day overdue item', () { final overdue = AppState(); overdue.reviewQueue.add( dueItem().copyWith( dueAt: DateTime.now().subtract(const Duration(days: 8)), ), ); expect(overdue.reviewBacklog, isTrue); final crowded = AppState(); for (var index = 0; index < 11; index++) { crowded.reviewQueue.add(dueItem('A0-T$index')); } expect(crowded.dueReviewEstimatedSeconds, 11 * 60); expect(crowded.reviewBacklog, isTrue); }); test( 'A0 reinforcement schedules a stable target with a new prompt variant', () { final state = AppState(); state.scheduleA0Reinforcement(); expect(state.dueReviewCount, 1); final item = state.dueReviews.single; expect(a0CoreItems, contains(item.id)); expect(item.variantIndex, 1); expect(item.prompt, contains('情境')); }, ); test('an approved AI variant remains tied to its original review item', () { final state = AppState(); state.reviewQueue.add(dueItem()); state.applyGeneratedReviewVariant( const GeneratedReviewVariant( variantId: 'ai-1', targetItemId: 'A0-P12', prompt: '换一个地点,用英语说你来自哪里。', expectedAnswer: "I'm from [place].", ), ); expect(state.reviewQueue.single.id, 'A0-P12'); expect(state.reviewQueue.single.isAiGenerated, isTrue); }); test( 'reporting an AI review variant isolates it and invalidates its success', () { final state = AppState(); state.reviewQueue.add(dueItem()); state.applyGeneratedReviewVariant( const GeneratedReviewVariant( variantId: 'ai-report-1', targetItemId: 'A0-P12', prompt: '不合适的题面。', expectedAnswer: "I'm from [place].", ), ); final generated = state.reviewQueue.single; state.completeReview( generated, assisted: false, rawAnswer: "I'm from Hong Kong.", ); state.reportGeneratedReviewVariant(state.reviewQueue.single); expect(state.reviewQueue.single.isAiGenerated, isFalse); expect(state.reportedAiVariantKeys, contains('A0-P12:1')); expect(state.mastery['A0-P12']!.checkpoint, 0); expect( state.attemptEvidence.any((entry) => entry.variantIndex == 1), isFalse, ); }, ); test('a completed review is scheduled again instead of being discarded', () { final state = AppState(); state.reviewQueue.add(dueItem()); final item = state.dueReviews.first; state.completeReview(item, assisted: false); expect(state.reviewQueue, hasLength(1)); final updated = state.reviewQueue.firstWhere( (candidate) => candidate.id == item.id, ); expect(updated.successfulReviews, 1); expect(updated.dueAt.isAfter(DateTime.now()), isTrue); }); test('four independent review checkpoints lead to mastered status', () { final state = AppState(); state.reviewQueue.add(dueItem()); final item = state.reviewQueue.first; state.attemptEvidence.add( AttemptEvidence( id: 'recognized', itemId: item.id, taskId: 'lesson-a0-06-a-listening-check', skill: '听辨识别', inputMode: 'choice', outcome: EvidenceKind.independentSuccess, createdAt: DateTime.now().subtract(const Duration(days: 20)), ), ); for (var index = 0; index < 4; index++) { final current = state.reviewQueue.firstWhere( (candidate) => candidate.id == item.id, ); final queueIndex = state.reviewQueue.indexOf(current); state.reviewQueue[queueIndex] = current.copyWith( dueAt: DateTime.now(), lastProgressedAt: DateTime.now().subtract(const Duration(days: 1)), ); state.completeReview( state.reviewQueue.firstWhere((candidate) => candidate.id == item.id), assisted: false, ); } expect(state.mastery[item.id]!.checkpoint, 4); expect(state.mastery[item.id]!.status, MasteryStatus.master); // Later checks rotate to other reviewed situations. expect( state.reviewQueue .firstWhere((candidate) => candidate.id == item.id) .variantIndex, 4, ); expect( state.reviewQueue .firstWhere((candidate) => candidate.id == item.id) .dueAt .isAfter(DateTime.now().add(const Duration(days: 29))), isTrue, ); }); test('mastery can be rebuilt from spaced review evidence', () { final state = AppState(); final base = DateTime.utc(2026, 9, 1); state.attemptEvidence.add( AttemptEvidence( id: 'recognized', itemId: 'A0-P12', taskId: 'lesson-a0-06-a-listening-check', skill: '听辨识别', inputMode: 'choice', outcome: EvidenceKind.independentSuccess, createdAt: base, ), ); for (var index = 0; index < 4; index++) { state.attemptEvidence.add( AttemptEvidence( id: 'rebuilt-$index', itemId: 'A0-P12', taskId: 'review-A0-P12', skill: '口语表达', inputMode: 'text', outcome: EvidenceKind.independentSuccess, createdAt: base.add(Duration(days: index + 1)), variantIndex: index, ), ); } state.rebuildMasteryFromEvidence(); expect(state.mastery['A0-P12']!.checkpoint, 4); expect(state.mastery['A0-P12']!.status, MasteryStatus.master); }); test('four checkpoints without every kind of evidence are not mastery', () { final state = AppState(); final base = DateTime.utc(2026, 9, 1); for (var index = 0; index < 4; index++) { state.attemptEvidence.add( AttemptEvidence( id: 'same-context-$index', itemId: 'A0-P12', taskId: 'review-A0-P12', skill: '口语表达', inputMode: 'text', outcome: EvidenceKind.independentSuccess, createdAt: base.add(Duration(days: index + 1)), ), ); } state.rebuildMasteryFromEvidence(); // No recognition success and never used outside the original situation. expect(state.mastery['A0-P12']!.checkpoint, 4); expect(state.mastery['A0-P12']!.status, MasteryStatus.recall); }); test('a review success never lowers an existing status', () { final state = AppState(); state.mastery['A0-P12'] = const MasteryItem( id: 'A0-P12', label: "I'm from [place].", status: MasteryStatus.use, evidence: [], checkpoint: 1, ); state.reviewQueue.add(dueItem()); state.completeReview(state.reviewQueue.single, assisted: false); expect(state.mastery['A0-P12']!.status, MasteryStatus.use); }); test('review checkpoints follow days 1, 3, 7 and 14', () { final state = AppState(); state.reviewQueue.add(dueItem()); final gaps = []; for (var index = 0; index < 3; index++) { final current = state.reviewQueue.single; state.reviewQueue[0] = current.copyWith( dueAt: DateTime.now(), lastProgressedAt: DateTime.now().subtract(const Duration(days: 1)), ); state.completeReview(state.reviewQueue.single, assisted: false); gaps.add( state.reviewQueue.single.dueAt.difference(DateTime.now()).inHours ~/ 24 + 1, ); } expect(gaps, [2, 4, 7]); }); test( 'rebuild keeps first failure as review and second failure lowers checkpoint', () { final state = AppState(); final base = DateTime.utc(2026, 9, 1); for (var index = 0; index < 3; index++) { state.attemptEvidence.add( AttemptEvidence( id: 'success-$index', itemId: 'A0-P12', taskId: 'review-A0-P12', skill: '口语表达', inputMode: 'text', outcome: EvidenceKind.independentSuccess, createdAt: base.add(Duration(days: index + 1)), ), ); } for (var index = 0; index < 2; index++) { state.attemptEvidence.add( AttemptEvidence( id: 'failure-$index', itemId: 'A0-P12', taskId: 'review-A0-P12', skill: '口语表达', inputMode: 'text', outcome: EvidenceKind.languageError, createdAt: base.add(Duration(days: index + 10)), ), ); } state.rebuildMasteryFromEvidence(); expect(state.mastery['A0-P12']!.checkpoint, 2); expect(state.mastery['A0-P12']!.needsReview, isTrue); }, ); test('a same-day second success does not advance a review checkpoint', () { final state = AppState(); state.reviewQueue.add(dueItem()); state.completeReview(state.reviewQueue.first, assisted: false); state.completeReview(state.reviewQueue.first, assisted: false); final updated = state.reviewQueue.single; expect(updated.successfulReviews, 1); expect(state.mastery[updated.id]!.checkpoint, 1); }); test( 'a duplicate review submission creates no extra evidence or attempt', () { final state = AppState(); state.reviewQueue.add(dueItem()); final submitted = state.reviewQueue.single; state.completeReview(submitted, assisted: false); state.completeReview(submitted, assisted: false); expect(state.reviewQueue.single.attempts, 1); expect(state.attemptEvidence, hasLength(1)); expect(state.mastery[submitted.id]!.checkpoint, 1); }, ); test('one review failure requests a check, two lower one checkpoint', () { final state = AppState(); state.reviewQueue.add(dueItem()); final item = state.reviewQueue.first; state.mastery[item.id] = MasteryItem( id: item.id, label: item.target, status: MasteryStatus.master, checkpoint: 4, evidence: const [EvidenceKind.independentSuccess], ); state.reportReviewFailure(item); expect(state.mastery[item.id]!.needsReview, isTrue); expect(state.mastery[item.id]!.checkpoint, 4); state.reportReviewFailure(item); expect(state.mastery[item.id]!.checkpoint, 3); expect(state.mastery[item.id]!.status, MasteryStatus.use); }); test( 'lesson dialogue must finish before independent attempt can complete lesson', () { final state = AppState(); state.completePreview(); state.completeListening(); state.completeSpeaking(); state.completeReading(); state.completeWriting(assisted: true); expect(state.lessonCanComplete, isFalse); state.completeLessonDialogue(); expect(state.lessonStep, LessonStep.independent); state.completeIndependentAttempt(assisted: false); expect(state.lessonCanComplete, isTrue); }, ); test( 'restores lesson settings and review schedule from local storage', () async { SharedPreferences.setMockInitialValues({}); final first = AppState(); await first.load(); first.setDailyMinutes(30); first.setGoal(LearningGoal.travel); first.completePreview(); await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); final restored = AppState(); await restored.load(); expect(restored.dailyMinutes, 30); expect(restored.goal, LearningGoal.travel); expect(restored.lessonStep, LessonStep.listening); expect(restored.reviewQueue, isEmpty); }, ); test('restores in-progress writing and independent-answer drafts', () async { SharedPreferences.setMockInitialValues({}); final first = AppState(); await first.load(); first.advanceLesson(LessonStep.writing); first.setLessonWritingDraft("I'm from Hong Kong."); first.setIndependentAttemptDraft('I like tea.'); await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); final restored = AppState(); await restored.load(); expect(restored.lessonStep, LessonStep.writing); expect(restored.lessonWritingDraft, "I'm from Hong Kong."); expect(restored.independentAttemptDraft, 'I like tea.'); }); test('restores an approved cached adaptive lesson', () async { SharedPreferences.setMockInitialValues({}); final first = AppState(); await first.load(); first.cacheApprovedAdaptiveLesson( const GeneratedLesson( lessonId: 'ai-a0-p12-1', revision: 1, stageVersion: 'A0-1.0', abilityIds: ['A0-C06'], prerequisiteIds: ['a0-05'], targetItemIds: ['A0-P12'], receptiveChunks: [], previewItemIds: [], estimatedMinutes: 10, tasks: [ GeneratedLessonTask( taskId: 'l', skill: 'listening', type: 'listenChoice', prompt: '听。', stimulus: 'I am from Hong Kong.', answer: 'Hong Kong', targetItemIds: ['A0-P12'], ), GeneratedLessonTask( taskId: 's', skill: 'speaking', type: 'repeat', prompt: '说。', stimulus: 'I am from Hong Kong.', answer: 'I am from Hong Kong.', targetItemIds: ['A0-P12'], ), GeneratedLessonTask( taskId: 'r', skill: 'reading', type: 'readAnswer', prompt: '读。', stimulus: 'I am from Hong Kong.', answer: 'Hong Kong', targetItemIds: ['A0-P12'], ), GeneratedLessonTask( taskId: 'w', skill: 'writing', type: 'writeAnswer', prompt: '写。', stimulus: '来自香港。', answer: 'I am from Hong Kong.', targetItemIds: ['A0-P12'], ), ], ), auditedAt: DateTime.utc(2026, 9, 13, 9, 30), auditor: 'compatible:test-model', ); await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); final restored = AppState(); await restored.load(); expect(restored.cachedAdaptiveLesson, isNotNull); expect(restored.cachedAdaptiveLesson!.targetItemIds, ['A0-P12']); expect( restored.cachedAdaptiveLessonAuditedAt, DateTime.utc(2026, 9, 13, 9, 30), ); expect(restored.cachedAdaptiveLessonAuditor, 'compatible:test-model'); }); test( 'adaptive lesson draft restores and a report isolates its cache', () async { SharedPreferences.setMockInitialValues({}); final first = AppState(); await first.load(); first.cacheApprovedAdaptiveLesson( const GeneratedLesson( lessonId: 'ai-a0-p12-2', revision: 1, stageVersion: 'A0-1.0', abilityIds: ['A0-C06'], prerequisiteIds: ['a0-05'], targetItemIds: ['A0-P12'], receptiveChunks: [], previewItemIds: [], estimatedMinutes: 10, tasks: [ GeneratedLessonTask( taskId: 'l', skill: 'listening', type: 'listenChoice', prompt: 'Listen.', stimulus: 'I am from Hong Kong.', answer: 'Hong Kong', targetItemIds: ['A0-P12'], ), GeneratedLessonTask( taskId: 's', skill: 'speaking', type: 'repeat', prompt: 'Say it.', stimulus: 'I am from Hong Kong.', answer: 'I am from Hong Kong.', targetItemIds: ['A0-P12'], ), GeneratedLessonTask( taskId: 'r', skill: 'reading', type: 'readAnswer', prompt: 'Read.', stimulus: 'I am from Hong Kong.', answer: 'Hong Kong', targetItemIds: ['A0-P12'], ), GeneratedLessonTask( taskId: 'w', skill: 'writing', type: 'writeAnswer', prompt: 'Write.', stimulus: 'From Hong Kong.', answer: 'I am from Hong Kong.', targetItemIds: ['A0-P12'], ), ], ), ); final lesson = first.cachedAdaptiveLesson!; first.saveAdaptiveLessonDraft( lesson: lesson, taskIndex: 2, answer: 'Hong Kong', referenceShown: true, usedVoice: true, transcriptConfirmed: true, originalTranscript: 'Hong Kong', recordingPath: '/private/local/answer.m4a', ); await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); final restored = AppState(); await restored.load(); expect(restored.adaptiveLessonDraftId, 'ai-a0-p12-2'); expect(restored.adaptiveLessonDraftIndex, 2); expect(restored.adaptiveLessonDraftAnswer, 'Hong Kong'); expect(restored.adaptiveLessonDraftReferenceShown, isTrue); expect(restored.adaptiveLessonDraftUsedVoice, isTrue); expect(restored.adaptiveLessonDraftTranscriptConfirmed, isTrue); expect(restored.adaptiveLessonDraftTranscriptEdited, isFalse); expect(restored.adaptiveLessonDraftOriginalTranscript, 'Hong Kong'); expect( restored.adaptiveLessonDraftRecordingPath, '/private/local/answer.m4a', ); restored.reportAdaptiveLesson(restored.cachedAdaptiveLesson!); expect(restored.cachedAdaptiveLesson, isNull); expect(restored.reportedAdaptiveLessonIds, contains('ai-a0-p12-2')); }, ); test('an edited adaptive transcript is persisted only as text evidence', () { final state = AppState(); const lesson = GeneratedLesson( lessonId: 'ai-a0-p12-boundary', revision: 1, stageVersion: 'A0-1.0', abilityIds: ['A0-C06'], prerequisiteIds: ['a0-05'], targetItemIds: ['A0-P12'], receptiveChunks: [], previewItemIds: [], estimatedMinutes: 10, tasks: [ GeneratedLessonTask( taskId: 'speaking', skill: 'speaking', type: 'repeat', prompt: 'Say it.', stimulus: 'I am from Hong Kong.', answer: 'I am from Hong Kong.', targetItemIds: ['A0-P12'], ), ], ); const task = GeneratedLessonTask( taskId: 'speaking', skill: 'speaking', type: 'repeat', prompt: 'Say it.', stimulus: 'I am from Hong Kong.', answer: 'I am from Hong Kong.', targetItemIds: ['A0-P12'], ); // This deliberately contradictory input models a future caller bug. // The domain layer, rather than only the widget, must protect evidence. state.recordAdaptiveLessonTask( lesson: lesson, task: task, rawAnswer: 'I am from Hong Kong.', assisted: false, correct: true, inputMode: 'speechToText', originalTranscript: 'I am from Home Kong.', transcriptConfirmed: true, transcriptEdited: true, ); final evidence = state.attemptEvidence.single; expect(evidence.inputMode, 'text'); expect(evidence.transcriptConfirmed, isFalse); expect(evidence.transcriptEdited, isTrue); expect(evidence.originalTranscript, 'I am from Home Kong.'); state.saveAdaptiveLessonDraft( lesson: lesson, taskIndex: 0, answer: evidence.rawAnswer ?? '', referenceShown: false, usedVoice: true, transcriptConfirmed: true, transcriptEdited: true, originalTranscript: evidence.originalTranscript ?? '', ); expect(state.adaptiveLessonDraftTranscriptConfirmed, isFalse); expect(state.adaptiveLessonDraftTranscriptEdited, isTrue); state.recordAdaptiveLessonTask( lesson: lesson, task: task, rawAnswer: 'I am from Hong Kong.', assisted: false, correct: true, ); expect(state.attemptEvidence, hasLength(1)); // An unaided adaptive answer is recall in a situation other than the // lesson, which is what usable means. expect(state.mastery['A0-P12']!.status, MasteryStatus.use); }); test( 'temporary definitions remain local and survive state restoration', () async { SharedPreferences.setMockInitialValues({}); final first = AppState(); await first.load(); first.setAiProvider(AiProviderType.compatible); first.saveAiConfiguration( endpoint: 'https://example.test/v1', model: 'demo', ); first.cacheTemporaryDefinition(query: 'library', definition: '图书馆'); expect(first.mastery, isEmpty); expect(first.reviewQueue, isEmpty); expect(first.attemptEvidence, isEmpty); await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); final restored = AppState(); await restored.load(); final entry = restored.temporaryDefinitionFor(' Library '); expect(entry, isNotNull); expect(entry!.definition, '图书馆'); expect(entry.provider, 'compatible'); expect(entry.model, 'demo'); expect(restored.mastery, isEmpty); expect(restored.reviewQueue, isEmpty); expect(restored.attemptEvidence, isEmpty); restored.removeTemporaryDefinition('library'); expect(restored.temporaryDefinitionFor('library'), isNull); expect(restored.mastery, isEmpty); expect(restored.reviewQueue, isEmpty); expect(restored.attemptEvidence, isEmpty); }, ); test('preview exposure alone does not add mastery or a review item', () { final state = AppState(); final reviewCount = state.reviewQueue.length; final masteryCount = state.mastery.length; state.completePreview(); expect(state.reviewQueue, hasLength(reviewCount)); expect(state.mastery, hasLength(masteryCount)); expect(state.lessonStep, LessonStep.listening); }); test( 'each target records first formal teaching before lesson completion', () { final state = AppState(); state.completePreview(); state.completeListening(); for (final id in lessonById('a0-01').targetItemIds) { expect(state.mastery[id]!.firstTaughtAt, isNotNull); } expect(state.lessonCanComplete, isFalse); }, ); test('lesson evidence is task- and target-specific', () { final state = AppState(); final targets = lessonById('a0-01').targetItemIds; const segmentId = 'a0-01-a'; state.completePreview(); state.completeListening(); for (final id in targets) { expect( state.attemptEvidence.any( (entry) => entry.itemId == id && entry.taskId == 'lesson-$segmentId-listening' && entry.outcome == EvidenceKind.exposure, ), isTrue, ); } state.completeSpeaking(); final speaking = state.attemptEvidence.where( (entry) => entry.taskId == 'lesson-$segmentId-speaking', ); // Follow-reading is assisted practice for every target, never // independent evidence for one of them. expect(speaking.map((entry) => entry.itemId), targets); expect( speaking.every((entry) => entry.outcome == EvidenceKind.assisted), isTrue, ); state.completeReading(); state.completeWriting(rawAnswer: 'Hello. My name is Mia.'); final writing = state.attemptEvidence.where( (entry) => entry.taskId == 'lesson-$segmentId-writing', ); expect(writing.map((entry) => entry.itemId).toSet(), {'A0-W36', 'A0-P01'}); }); test('a first-try listening choice is recognition evidence', () { final state = AppState(); state.completePreview(); state.completeListening(recognized: true); for (final id in lessonById('a0-01').targetItemIds) { expect(state.mastery[id]!.status, MasteryStatus.recognize); } }); test('scene dialogue credits taught items the learner actually used', () { final state = AppState(); state.completePreview(); state.completeListening(); state.recordDialogueAttempt( taskId: 'dialogue-scene-a0-meet-0', rawAnswer: 'Hi! My name is Mia.', assisted: false, sceneId: 'a0-meet', ); final used = state.attemptEvidence .where((entry) => entry.taskId == 'dialogue-scene-a0-meet-0') .map((entry) => entry.itemId) .toSet(); expect(used, {'A0-W37', 'A0-P01'}); expect(state.mastery['A0-P01']!.status, MasteryStatus.use); expect(state.mastery['A0-P03']!.status, MasteryStatus.newItem); }); test( 'mastery rebuild derives first teaching and teaching level from evidence', () { final state = AppState(); state.completePreview(); state.completeListening(); state.completeSpeaking(); state.completeReading(); state.completeWriting(rawAnswer: 'Hello. I am Mia.'); state.completeLessonDialogue(); state.completeIndependentAttempt( assisted: false, rawAnswer: 'Hello. I am Mia.', ); const introduced = 'A0-P01'; state.mastery.clear(); state.rebuildMasteryFromEvidence(); expect(state.mastery[introduced]!.firstTaughtAt, isNotNull); // Recalled inside the lesson, not yet used in another situation. expect(state.mastery[introduced]!.status, MasteryStatus.recall); // The answer never said "Nice to meet you", so that target gets no // production evidence from it. expect(state.mastery['A0-P03']!.status, MasteryStatus.newItem); expect( state.mastery[lessonById('a0-01').targetItemIds.first]!.firstTaughtAt, isNotNull, ); }, ); test('every A0 seed lesson has complete local activities', () { expect(a0SeedLessons, hasLength(10)); for (final lesson in a0SeedLessons) { final activity = activityByLessonId(lesson.id); expect(lesson.vocabulary, isNotEmpty); expect(activity.answers, hasLength(3)); expect(activity.answers.first, isNotEmpty); expect(activity.independentPrompt, isNotEmpty); expect(lesson.schemaVersion, '1.0'); expect(lesson.status, ContentStatus.approved); expect(lesson.source, ContentSource.builtInOriginal); expect(lesson.targetItemIds, isNotEmpty); } expect( a0SeedLessons.expand((lesson) => lesson.targetItemIds).toSet(), containsAll(a0CoreItems.keys), ); }); test('every A0 seed lesson has a practical grammar reminder', () { for (final lesson in a0SeedLessons) { expect(grammarNoteForLesson(lesson.id).trim(), isNotEmpty); } }); test('A0 lesson segments stay within new-item budget and cover targets', () { for (final lesson in a0SeedLessons) { final segmented = lesson.segments.expand( (segment) => segment.targetItemIds, ); expect( lesson.segments.every((segment) => segment.targetItemIds.length <= 8), isTrue, ); expect(segmented.toSet(), equals(lesson.targetItemIds.toSet())); } }); test('segment completion advances only within its lesson', () { final state = AppState(); state.completeSegment('a0-04', 0); expect(state.isSegmentComplete('a0-04-a'), isTrue); expect(state.isSegmentComplete('a0-04-b'), isFalse); expect(state.activeSegmentIndexFor('a0-04'), 1); }); test('split lesson segments have their own reviewed activities', () { for (final lessonId in ['a0-04', 'a0-08']) { for (final segment in lessonById(lessonId).segments) { expect(a0SegmentActivities, contains(segment.id)); expect(activityBySegmentId(segment.id, lessonId).listening, isNotEmpty); } } expect(activityBySegmentId('a0-01-a', 'a0-01').writingPrompt, isNotEmpty); }); test('split lesson segments have their own controlled dialogues', () { for (final lessonId in ['a0-04', 'a0-08']) { for (final segment in lessonById(lessonId).segments) { expect(a0SegmentDialogues, contains(segment.id)); expect(dialogueBySegmentId(segment.id, lessonId).prompts, isNotEmpty); } } }); test('split lesson segments have reviewed vocabulary and grammar notes', () { for (final lessonId in ['a0-04', 'a0-08']) { for (final segment in lessonById(lessonId).segments) { final vocabulary = vocabularyBySegmentId(segment.id, lessonId); expect(vocabulary.map((item) => item.id), segment.previewItemIds); expect(vocabulary.every((item) => item.meaning.isNotEmpty), isTrue); expect(vocabulary.every((item) => item.example.isNotEmpty), isTrue); expect(grammarNoteForSegment(segment.id, lessonId).trim(), isNotEmpty); } } }); test('split dialogue validators require the current segment content', () { expect(matchesSegmentDialogue('a0-04-a', 0, 'zero, one, two'), isTrue); expect(matchesSegmentDialogue('a0-04-a', 0, 'hello'), isFalse); expect( matchesSegmentDialogue('a0-04-c', 0, 'My number is one-three-eight'), isTrue, ); expect(matchesSegmentDialogue('a0-04-c', 0, 'one-three-eight'), isFalse); expect(matchesSegmentDialogue('a0-08-c', 1, "It is three o'clock"), isTrue); expect(matchesSegmentDialogue('a0-08-c', 1, 'It is Monday'), isFalse); }); test('split independent attempts need their taught information', () { expect(matchesSegmentIndependent('a0-04-a', 'zero, one, two'), isTrue); expect(matchesSegmentIndependent('a0-04-a', 'zero, one'), isFalse); expect(matchesSegmentIndependent('a0-08-b', 'It is Friday.'), isTrue); expect(matchesSegmentIndependent('a0-08-b', 'It is Monday.'), isFalse); }); test('single-segment independent attempts reject off-task answers', () { const offTask = 'abc'; for (final lessonId in [ 'a0-01', 'a0-02', 'a0-03', 'a0-05', 'a0-06', 'a0-07', 'a0-09', 'a0-10', ]) { expect( matchesSegmentIndependent('$lessonId-a', offTask), isFalse, reason: lessonId, ); } expect( matchesSegmentIndependent('a0-01-a', "Hi, I'm Mia. Nice to meet you."), isTrue, ); expect(matchesSegmentIndependent('a0-01-a', "Hi, I'm Mia."), isFalse); expect(matchesSegmentIndependent('a0-05-a', "It's a pen."), isTrue); expect( matchesSegmentIndependent( 'a0-06-a', "I'm from Guangzhou. Where are you from?", ), isTrue, ); expect( matchesSegmentIndependent('a0-06-a', "I'm from Guangzhou."), isFalse, ); expect( matchesSegmentIndependent( 'a0-10-a', "I'm Mia. I'm from Guangzhou. I like tea. Do you like tea?", ), isTrue, ); }); test('each free scene keeps its own daily recap', () { final state = AppState(); state.addDialogueRecap('My name is Alex.'); state.addDialogueRecap('It is Monday.', sceneId: 'a0-plan'); expect( state.reviewQueue.where( (item) => item.id.startsWith('dialogue-a0-meet-'), ), hasLength(1), ); final plan = state.reviewQueue.singleWhere( (item) => item.id.startsWith('dialogue-a0-plan-'), ); expect(plan.prompt, sceneById('a0-plan').recapPrompt); }); test('scenes open with their lessons and rotate the recommendation', () { final state = AppState(); expect(a0Scenes.where(state.isSceneUnlocked).map((scene) => scene.id), [ 'a0-meet', ]); state.completedLessonIds.addAll(['a0-01', 'a0-02', 'a0-03', 'a0-04']); expect(state.recommendedScene.id, 'a0-number'); state.addDialogueRecap('My number is one two three.', sceneId: 'a0-number'); expect(state.recommendedScene.id, 'a0-meet'); }); test('completed independent dialogue creates one daily non-core recap', () { final state = AppState(); state.addDialogueRecap('My name is Alex.'); state.addDialogueRecap('My name is Mia.'); final recap = state.reviewQueue.where( (item) => item.id.startsWith('dialogue-a0-meet-'), ); expect(recap, hasLength(1)); expect(recap.single.target, 'My name is Alex.'); expect(a0CoreItems, isNot(contains(recap.single.id))); }); test('A0 upgrade denominator is the frozen 60-item core list', () { expect(a0CoreItems, hasLength(60)); expect(a0CoreItems['A0-W01'], 'zero'); expect(a0CoreItems['A0-P20'], 'Please say that again.'); }); test('the ten A0 lessons introduce every frozen core item to review', () { final state = AppState(); for (final lesson in a0SeedLessons) { state.openLesson(lesson.id); for (var segment = 0; segment < lesson.segments.length; segment++) { state.completePreview(); state.completeListening(); state.completeSpeaking(); state.completeReading(); state.completeWriting(assisted: true); state.completeLessonDialogue(); state.completeIndependentAttempt(assisted: true); state.finishCurrentLessonSegment(); } } expect(state.mastery.keys.where(a0CoreItems.containsKey), hasLength(60)); expect( state.reviewQueue.map((item) => item.id).toSet(), containsAll(a0CoreItems.keys), ); final fromReview = state.reviewQueue.firstWhere( (item) => item.id == 'A0-P12', ); expect(fromReview.prompt, contains('来自哪里')); expect(fromReview.prompt, isNot(contains("I'm from"))); }); test('A0 pass requires two different, spaced four-skill assessments', () { final state = AppState(); for (final entry in a0CoreItems.entries) { state.mastery[entry.key] = MasteryItem( id: entry.key, label: entry.value, status: MasteryStatus.master, evidence: const [EvidenceKind.independentSuccess], ); } const results = { AssessmentSkill.listening: true, AssessmentSkill.speaking: true, AssessmentSkill.reading: true, AssessmentSkill.writing: true, }; final now = DateTime.now(); state.recordAssessment( AssessmentRecord( packId: 'A', completedAt: now.subtract(const Duration(hours: 25)), results: results, ), ); expect(state.a0Passed, isFalse); state.recordAssessment( AssessmentRecord(packId: 'B', completedAt: now, results: results), ); expect(state.a0Passed, isTrue); }); test('both A0 assessment packs meet the documented task blueprint', () { expect(a0AssessmentPacks, hasLength(2)); for (final pack in a0AssessmentPacks) { expect(pack.forSkill(AssessmentSkill.listening), hasLength(10)); expect(pack.forSkill(AssessmentSkill.reading), hasLength(5)); expect(pack.forSkill(AssessmentSkill.writing), hasLength(5)); expect(pack.forSkill(AssessmentSkill.speaking), hasLength(8)); expect( pack.tasks.map((task) => task.id).toSet(), hasLength(pack.tasks.length), ); } }); test('open A0 assessment tasks require their actual information slots', () { final pack = a0AssessmentPacks.first; final writing = pack.forSkill(AssessmentSkill.writing); final speaking = pack.forSkill(AssessmentSkill.speaking); expect(checkOpenAssessmentAnswer(writing[1], 'Hong Kong.'), isFalse); expect( checkOpenAssessmentAnswer(writing[1], "I'm from Hong Kong."), isTrue, ); expect(checkOpenAssessmentAnswer(speaking[2], 'one.'), isFalse); expect(checkOpenAssessmentAnswer(speaking[2], 'one-three-eight.'), isTrue); expect( checkOpenAssessmentAnswer( speaking.first, 'Hello. I am Alex. I am from Beijing. I like tea. What do you like?', ), isTrue, ); expect( checkOpenAssessmentAnswer(speaking[5], "It's three o'clock."), isTrue, ); expect( checkOpenAssessmentAnswer(speaking[5], 'It is two o’clock.'), isTrue, ); expect( checkOpenAssessmentAnswer(speaking[6], 'Please speak slowly.'), isTrue, ); expect(checkOpenAssessmentAnswer(speaking[6], 'Repeat, please.'), isTrue); }); test('finishLesson advances activeLessonId to next available lesson', () { final state = AppState(); expect(state.activeLessonId, 'a0-01'); state.completePreview(); state.completeListening(); state.completeSpeaking(); state.completeReading(); state.completeWriting(assisted: false); state.completeLessonDialogue(); state.completeIndependentAttempt(assisted: false); expect(state.lessonCanComplete, isTrue); state.finishLesson(); expect(state.completedLessonIds, contains('a0-01')); expect(state.activeLessonId, 'a0-02'); }); void finishActiveLesson(AppState state) { state.completePreview(); state.completeListening(); state.completeSpeaking(); state.completeReading(); state.completeWriting(assisted: true); state.completeLessonDialogue(); state.completeIndependentAttempt(assisted: true); state.finishLesson(); } test('switching lessons clears step flags from the previous lesson', () { final state = AppState(); state.completePreview(); state.completeListening(); state.completeSpeaking(); state.completeReading(); state.completeWriting(assisted: true); state.completeLessonDialogue(); state.completeIndependentAttempt(assisted: true); state.completedLessonIds.add('a0-01'); state.openLesson('a0-02'); expect(state.lessonCanComplete, isFalse); expect(state.lessonListeningComplete, isFalse); }); test('placement opens its start lesson without passing earlier ones', () { final state = AppState(); state.setPlacementStartLesson('a0-02'); expect(state.activeLessonId, 'a0-02'); expect(state.isLessonUnlocked('a0-01'), isTrue); expect(state.isLessonUnlocked('a0-02'), isTrue); expect(state.isLessonUnlocked('a0-03'), isFalse); expect(state.completedLessonIds, isEmpty); finishActiveLesson(state); expect(state.activeLessonId, 'a0-03'); }); test('after the last lesson the learner is sent to unfinished lessons', () { final state = AppState(); state.setPlacementStartLesson('a0-02'); for (var index = 1; index < a0SeedLessons.length; index++) { final lesson = a0SeedLessons[index]; state.openLesson(lesson.id); for (var segment = 0; segment < lesson.segments.length; segment++) { state.completeSegment(lesson.id, segment); } finishActiveLesson(state); } expect(state.activeLessonId, 'a0-01'); expect(state.allLessonsComplete, isFalse); finishActiveLesson(state); expect(state.allLessonsComplete, isTrue); expect(state.a0AssessmentReady, isFalse); expect(state.nextAssessmentPackId, 'A0-E1'); }); test( 'a passed assessment skill remains valid during the seven-day retest window', () { final state = AppState(); final now = DateTime.now(); state.recordAssessment( AssessmentRecord( packId: 'A0-E1', completedAt: now.subtract(const Duration(days: 1)), results: const { AssessmentSkill.listening: true, AssessmentSkill.speaking: false, AssessmentSkill.reading: false, AssessmentSkill.writing: false, }, ), ); final merged = state.recordAssessment( AssessmentRecord( packId: 'A0-E1', completedAt: now, results: const { AssessmentSkill.listening: false, AssessmentSkill.speaking: true, AssessmentSkill.reading: false, AssessmentSkill.writing: false, }, ), ); expect(merged.results[AssessmentSkill.listening], isTrue); expect(merged.results[AssessmentSkill.speaking], isTrue); }, ); test('a technical speaking gap remains pending instead of failed', () { final state = AppState(); final record = state.recordAssessment( AssessmentRecord( packId: 'A0-E1', completedAt: DateTime.now(), results: const { AssessmentSkill.listening: true, AssessmentSkill.reading: true, AssessmentSkill.writing: true, AssessmentSkill.speaking: false, }, pendingSkills: const {AssessmentSkill.speaking}, ), ); expect(record.pendingSkills, contains(AssessmentSkill.speaking)); expect(record.failedSkills, isNot(contains(AssessmentSkill.speaking))); }); test('replacement assessment packs use a different task value set', () { expect(a0ReplacementPacks, hasLength(2)); expect(replacementFor('A0-E1')!.id, 'A0-E1R'); expect( a0ReplacementPacks.first.tasks.first.audio, isNot(a0AssessmentPacks.first.tasks.first.audio), ); }); test( 'replacement results merge into their original seven-day skill window', () { final state = AppState(); final now = DateTime.now(); state.recordAssessment( AssessmentRecord( packId: 'A0-E1', completedAt: now.subtract(const Duration(days: 1)), results: const {AssessmentSkill.listening: true}, ), ); final merged = state.recordAssessment( AssessmentRecord( packId: 'A0-E1R', completedAt: now, results: const {AssessmentSkill.speaking: true}, ), ); expect(merged.packId, 'A0-E1'); expect(merged.results[AssessmentSkill.listening], isTrue); expect(merged.results[AssessmentSkill.speaking], isTrue); }, ); test('assessment draft survives a local-state restore', () async { SharedPreferences.setMockInitialValues({}); final first = AppState(); await first.load(); first.saveAssessmentDraft( const AssessmentDraft( packId: 'A0-E1', taskIndex: 4, results: {'A0-A-L1': true}, ), ); await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); final restored = AppState(); await restored.load(); expect(restored.assessmentDraft!.packId, 'A0-E1'); expect(restored.assessmentDraft!.taskIndex, 4); expect(restored.assessmentDraft!.results['A0-A-L1'], isTrue); }); test('review attempt evidence survives a local-state restore', () async { SharedPreferences.setMockInitialValues({}); final first = AppState(); await first.load(); final item = dueItem(); first.reviewQueue.add(item); first.completeReview( item, assisted: true, rawAnswer: "I'm from Hong Kong.", ); await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); final restored = AppState(); await restored.load(); expect(restored.attemptEvidence, hasLength(1)); expect(restored.attemptEvidence.single.itemId, 'A0-P12'); expect(restored.attemptEvidence.single.rawAnswer, "I'm from Hong Kong."); expect(restored.attemptEvidence.single.assisted, isTrue); expect(restored.attemptEvidence.single.outcome, EvidenceKind.assisted); }); test('clearing progress also removes local learning evidence', () { final state = AppState(); final item = dueItem(); state.reviewQueue.add(item); state.completeReview(item, assisted: false, rawAnswer: "I'm from London."); state.clearProgress(); expect(state.attemptEvidence, isEmpty); expect(state.reviewQueue, isEmpty); expect(state.mastery, isEmpty); }); test('clearing progress keeps AI service configuration', () { final state = AppState(); state.setAiProvider(AiProviderType.compatible); state.saveAiConfiguration( endpoint: 'https://example.test/v1', model: 'test-model', ); state.clearProgress(); expect(state.aiProvider, AiProviderType.compatible); expect(state.aiEndpoint, 'https://example.test/v1'); expect(state.aiModel, 'test-model'); }); test('lesson dialogue draft survives a local-state restore', () async { SharedPreferences.setMockInitialValues({}); final first = AppState(); await first.load(); first.saveDialogueDraft( const DialogueDraft( lessonId: 'a0-03', stage: 2, usedHelp: true, turns: [ DialogueTurn(text: 'Hi! How are you?', isLearner: false), DialogueTurn(text: 'I am good.', isLearner: true), ], ), ); await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); final restored = AppState(); await restored.load(); expect(restored.dialogueDraft!.lessonId, 'a0-03'); expect(restored.dialogueDraft!.stage, 2); expect(restored.dialogueDraft!.usedHelp, isTrue); expect(restored.dialogueDraft!.turns, hasLength(2)); expect(restored.dialogueDraft!.turns.last.isLearner, isTrue); }); }