Files
English/kouyu_english/test/app_state_test.dart
T
shenleiandClaude Opus 5 9ab08c25ef fix: 修正学习流程中的证据、复习与进度问题
- 独立尝试按课程要求校验内容,不再任意输入即算独立成功
- 复习节点改为学后第 1/3/7/14 天,之后每 30 天抽查;复习轮换情境
- 掌握状态按作答记录推导:需认识、可回忆、跨情境使用及最近两次无帮助
- 课程证据按实际用到的目标记录;跟读与课程对话只算辅助练习
- 复习成功不再降低已有状态
- 学完课程后进入下一节未完成课程;全部学完后引导巩固与阶段评估
- 新增 3 题基础定位,按结果设置起始课程
- 新增按课程开放的对话场景,首页推荐最少练习的场景
- 切换课程时清空上一课的步骤进度
- AI 返回 JSON 格式并按 JSON 回放历史;拼写校验忽略大小写与连字符;移除学习目标选择

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:52:01 +09:00

1374 lines
45 KiB
Dart
Raw 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/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 = <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) {
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 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', () {
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);
});
}