Files

1466 lines
48 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/core/courses/course_repository.dart';
import 'package:kouyu_english/core/courses/courses.dart';
import 'package:kouyu_english/core/generated_content.dart';
import 'package:kouyu_english/core/models.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);
// The first rotation turns the written recall into dictation.
expect(item.skill, dictationSkill);
expect(item.prompt, contains('听'));
},
);
test('an approved AI variant remains tied to its original review item', () {
final state = AppState();
state.reviewQueue.add(dueItem().copyWith(skill: dictationSkill));
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);
expect(state.reviewQueue.single.skill, isNot(dictationSkill));
});
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)),
),
);
state.attemptEvidence.add(
AttemptEvidence(
id: 'scene-use',
itemId: item.id,
taskId: 'dialogue-scene-a0-meet-0',
skill: '情境使用',
inputMode: 'text',
outcome: EvidenceKind.independentSuccess,
createdAt: DateTime.now().subtract(const Duration(days: 19)),
),
);
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,
),
);
}
// Reworded reviews alone only show recall, never use in a situation.
state.rebuildMasteryFromEvidence();
expect(state.mastery['A0-P12']!.checkpoint, 4);
expect(state.mastery['A0-P12']!.status, MasteryStatus.recall);
state.attemptEvidence.add(
AttemptEvidence(
id: 'scene-use',
itemId: 'A0-P12',
taskId: 'dialogue-scene-a0-meet-1',
skill: '情境使用',
inputMode: 'text',
outcome: EvidenceKind.independentSuccess,
createdAt: base,
),
);
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 = <int>[];
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<void>.delayed(Duration.zero);
await Future<void>.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<void>.delayed(Duration.zero);
await Future<void>.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<void>.delayed(Duration.zero);
await Future<void>.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<void>.delayed(Duration.zero);
await Future<void>.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<void>.delayed(Duration.zero);
await Future<void>.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) {
expect(lesson.vocabulary, isNotEmpty);
for (final segment in lesson.segments) {
final activity = activityBySegmentId(segment.id);
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) {
for (final segment in lesson.segments) {
expect(grammarNoteForSegment(segment.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(segmentActivities, contains(segment.id));
expect(activityBySegmentId(segment.id).listening, isNotEmpty);
}
}
expect(activityBySegmentId('a0-01-a').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(segmentDialogues, contains(segment.id));
expect(dialogueBySegmentId(segment.id).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);
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).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',
"Please say that again. I'm Mia. I like water. Do you like tea?",
),
isTrue,
);
expect(
matchesSegmentIndependent(
'a0-10-a',
"I'm Mia. I'm from Guangzhou. I like tea. Do you like tea?",
),
isFalse,
);
});
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(allScenes.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 oclock.'),
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', () {
// Only A0: earlier tests' AppState.load() may have registered A1+ units.
CourseRepository.instance.resetForTest();
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<void>.delayed(Duration.zero);
await Future<void>.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<void>.delayed(Duration.zero);
await Future<void>.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<void>.delayed(Duration.zero);
await Future<void>.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);
});
test('a spoken review answer keeps its transcript and input mode', () {
final state = AppState();
state.reviewQueue.add(dueItem());
state.completeReview(
state.reviewQueue.single,
assisted: false,
rawAnswer: "I'm from Hong Kong.",
inputMode: 'speechToText',
originalTranscript: "I'm from Hong Kong.",
);
final spoken = state.attemptEvidence.last;
expect(spoken.inputMode, 'speechToText');
expect(spoken.transcriptConfirmed, isTrue);
expect(spoken.transcriptEdited, isFalse);
final typed = AppState();
typed.reviewQueue.add(dueItem());
typed.completeReview(
typed.reviewQueue.single,
assisted: false,
rawAnswer: "I'm from Guangzhou.",
originalTranscript: "I'm from one show.",
);
expect(typed.attemptEvidence.last.inputMode, 'text');
expect(typed.attemptEvidence.last.transcriptEdited, isTrue);
});
test('each lesson practises the core items it targets', () {
for (final lesson in a0SeedLessons) {
final segments = lesson.segments;
final text = [
for (final segment in segments) ...[
activityBySegmentId(segment.id).listening,
activityBySegmentId(segment.id).speaking,
activityBySegmentId(segment.id).reading,
activityBySegmentId(segment.id).writingExample,
for (final item in vocabularyBySegmentId(segment.id))
'${item.word}\n${item.example}',
],
].join('\n');
for (final id in lesson.targetItemIds) {
expect(
coreItemUsedIn(id, text),
isTrue,
reason: '${lesson.id}$id ${a0CoreItems[id]}',
);
}
}
});
}