Files
English/kouyu_english/test/app_state_test.dart
T

1104 lines
36 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;
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);
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);
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)),
),
);
}
state.rebuildMasteryFromEvidence();
expect(state.mastery['A0-P12']!.checkpoint, 4);
expect(state.mastery['A0-P12']!.status, MasteryStatus.master);
});
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(assisted: true);
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));
expect(state.mastery['A0-P12']!.status, MasteryStatus.recognize);
});
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',
);
expect(speaking, hasLength(1));
expect(speaking.single.itemId, targets.last);
expect(speaking.single.outcome, EvidenceKind.independentSuccess);
});
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.',
);
final primary = lessonById('a0-01').targetItemIds.last;
state.mastery.clear();
state.rebuildMasteryFromEvidence();
expect(state.mastery[primary]!.firstTaughtAt, isNotNull);
expect(state.mastery[primary]!.status, MasteryStatus.use);
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('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(assisted: true);
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(assisted: false);
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');
});
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);
});
}