课程内容 - 重写全部 21 个 A1 单元的听读材料,把本单元理解词织进听/读文本, 单元内理解词复现率从约 20% 提升到约 77%(各单元 56–96%)。 - 修正 U01 房间号与机场大巴同为 thirty 的撞车(改为 17/30/40)。 - 校验器容差按级别读取(A1 为 8%),materials 覆盖率、字数、 选项子串等校验全部通过;course_content_test 通过。 黑夜模式 - app_theme 拆分明/暗两套调色板,AppColors 随亮度切换; main 用 theme/darkTheme/themeMode + builder 镜像已解析亮度; 主题偏好持久化到快照;进度页新增“外观主题”切换。 文档 - COURSE-PACK-JSON.md 更新 A1 词池覆盖(840/933)与材料复现约定。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1462 lines
48 KiB
Dart
1462 lines
48 KiB
Dart
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/review_feedback.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);
|
||
// 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) {
|
||
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',
|
||
"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(a0Scenes.where(state.isSceneUnlocked).map((scene) => scene.id), [
|
||
'a0-meet',
|
||
]);
|
||
state.completedLessonIds.addAll(['a0-01', 'a0-02', 'a0-03', 'a0-04']);
|
||
expect(state.recommendedScene.id, 'a0-number');
|
||
state.addDialogueRecap('My number is one two three.', sceneId: 'a0-number');
|
||
expect(state.recommendedScene.id, 'a0-meet');
|
||
});
|
||
|
||
test('completed independent dialogue creates one daily non-core recap', () {
|
||
final state = AppState();
|
||
|
||
state.addDialogueRecap('My name is Alex.');
|
||
state.addDialogueRecap('My name is Mia.');
|
||
|
||
final recap = state.reviewQueue.where(
|
||
(item) => item.id.startsWith('dialogue-a0-meet-'),
|
||
);
|
||
expect(recap, hasLength(1));
|
||
expect(recap.single.target, 'My name is Alex.');
|
||
expect(a0CoreItems, isNot(contains(recap.single.id)));
|
||
});
|
||
|
||
test('A0 upgrade denominator is the frozen 60-item core list', () {
|
||
expect(a0CoreItems, hasLength(60));
|
||
expect(a0CoreItems['A0-W01'], 'zero');
|
||
expect(a0CoreItems['A0-P20'], 'Please say that again.');
|
||
});
|
||
|
||
test('the ten A0 lessons introduce every frozen core item to review', () {
|
||
final state = AppState();
|
||
for (final lesson in a0SeedLessons) {
|
||
state.openLesson(lesson.id);
|
||
for (var segment = 0; segment < lesson.segments.length; segment++) {
|
||
state.completePreview();
|
||
state.completeListening();
|
||
state.completeSpeaking();
|
||
state.completeReading();
|
||
state.completeWriting(assisted: true);
|
||
state.completeLessonDialogue();
|
||
state.completeIndependentAttempt(assisted: true);
|
||
state.finishCurrentLessonSegment();
|
||
}
|
||
}
|
||
|
||
expect(state.mastery.keys.where(a0CoreItems.containsKey), hasLength(60));
|
||
expect(
|
||
state.reviewQueue.map((item) => item.id).toSet(),
|
||
containsAll(a0CoreItems.keys),
|
||
);
|
||
final fromReview = state.reviewQueue.firstWhere(
|
||
(item) => item.id == 'A0-P12',
|
||
);
|
||
expect(fromReview.prompt, contains('来自哪里'));
|
||
expect(fromReview.prompt, isNot(contains("I'm from")));
|
||
});
|
||
|
||
test('A0 pass requires two different, spaced four-skill assessments', () {
|
||
final state = AppState();
|
||
for (final entry in a0CoreItems.entries) {
|
||
state.mastery[entry.key] = MasteryItem(
|
||
id: entry.key,
|
||
label: entry.value,
|
||
status: MasteryStatus.master,
|
||
evidence: const [EvidenceKind.independentSuccess],
|
||
);
|
||
}
|
||
const results = {
|
||
AssessmentSkill.listening: true,
|
||
AssessmentSkill.speaking: true,
|
||
AssessmentSkill.reading: true,
|
||
AssessmentSkill.writing: true,
|
||
};
|
||
final now = DateTime.now();
|
||
state.recordAssessment(
|
||
AssessmentRecord(
|
||
packId: 'A',
|
||
completedAt: now.subtract(const Duration(hours: 25)),
|
||
results: results,
|
||
),
|
||
);
|
||
expect(state.a0Passed, isFalse);
|
||
state.recordAssessment(
|
||
AssessmentRecord(packId: 'B', completedAt: now, results: results),
|
||
);
|
||
expect(state.a0Passed, isTrue);
|
||
});
|
||
|
||
test('both A0 assessment packs meet the documented task blueprint', () {
|
||
expect(a0AssessmentPacks, hasLength(2));
|
||
for (final pack in a0AssessmentPacks) {
|
||
expect(pack.forSkill(AssessmentSkill.listening), hasLength(10));
|
||
expect(pack.forSkill(AssessmentSkill.reading), hasLength(5));
|
||
expect(pack.forSkill(AssessmentSkill.writing), hasLength(5));
|
||
expect(pack.forSkill(AssessmentSkill.speaking), hasLength(8));
|
||
expect(
|
||
pack.tasks.map((task) => task.id).toSet(),
|
||
hasLength(pack.tasks.length),
|
||
);
|
||
}
|
||
});
|
||
|
||
test('open A0 assessment tasks require their actual information slots', () {
|
||
final pack = a0AssessmentPacks.first;
|
||
final writing = pack.forSkill(AssessmentSkill.writing);
|
||
final speaking = pack.forSkill(AssessmentSkill.speaking);
|
||
expect(checkOpenAssessmentAnswer(writing[1], 'Hong Kong.'), isFalse);
|
||
expect(
|
||
checkOpenAssessmentAnswer(writing[1], "I'm from Hong Kong."),
|
||
isTrue,
|
||
);
|
||
expect(checkOpenAssessmentAnswer(speaking[2], 'one.'), isFalse);
|
||
expect(checkOpenAssessmentAnswer(speaking[2], 'one-three-eight.'), isTrue);
|
||
expect(
|
||
checkOpenAssessmentAnswer(
|
||
speaking.first,
|
||
'Hello. I am Alex. I am from Beijing. I like tea. What do you like?',
|
||
),
|
||
isTrue,
|
||
);
|
||
expect(
|
||
checkOpenAssessmentAnswer(speaking[5], "It's three o'clock."),
|
||
isTrue,
|
||
);
|
||
expect(
|
||
checkOpenAssessmentAnswer(speaking[5], 'It is two o’clock.'),
|
||
isTrue,
|
||
);
|
||
expect(
|
||
checkOpenAssessmentAnswer(speaking[6], 'Please speak slowly.'),
|
||
isTrue,
|
||
);
|
||
expect(checkOpenAssessmentAnswer(speaking[6], 'Repeat, please.'), isTrue);
|
||
});
|
||
|
||
test('finishLesson advances activeLessonId to next available lesson', () {
|
||
final state = AppState();
|
||
expect(state.activeLessonId, 'a0-01');
|
||
state.completePreview();
|
||
state.completeListening();
|
||
state.completeSpeaking();
|
||
state.completeReading();
|
||
state.completeWriting(assisted: false);
|
||
state.completeLessonDialogue();
|
||
state.completeIndependentAttempt(assisted: false);
|
||
expect(state.lessonCanComplete, isTrue);
|
||
state.finishLesson();
|
||
|
||
expect(state.completedLessonIds, contains('a0-01'));
|
||
expect(state.activeLessonId, 'a0-02');
|
||
});
|
||
|
||
void finishActiveLesson(AppState state) {
|
||
state.completePreview();
|
||
state.completeListening();
|
||
state.completeSpeaking();
|
||
state.completeReading();
|
||
state.completeWriting(assisted: true);
|
||
state.completeLessonDialogue();
|
||
state.completeIndependentAttempt(assisted: true);
|
||
state.finishLesson();
|
||
}
|
||
|
||
test('switching lessons clears step flags from the previous lesson', () {
|
||
final state = AppState();
|
||
state.completePreview();
|
||
state.completeListening();
|
||
state.completeSpeaking();
|
||
state.completeReading();
|
||
state.completeWriting(assisted: true);
|
||
state.completeLessonDialogue();
|
||
state.completeIndependentAttempt(assisted: true);
|
||
state.completedLessonIds.add('a0-01');
|
||
|
||
state.openLesson('a0-02');
|
||
|
||
expect(state.lessonCanComplete, isFalse);
|
||
expect(state.lessonListeningComplete, isFalse);
|
||
});
|
||
|
||
test('placement opens its start lesson without passing earlier ones', () {
|
||
final state = AppState();
|
||
state.setPlacementStartLesson('a0-02');
|
||
|
||
expect(state.activeLessonId, 'a0-02');
|
||
expect(state.isLessonUnlocked('a0-01'), isTrue);
|
||
expect(state.isLessonUnlocked('a0-02'), isTrue);
|
||
expect(state.isLessonUnlocked('a0-03'), isFalse);
|
||
expect(state.completedLessonIds, isEmpty);
|
||
|
||
finishActiveLesson(state);
|
||
expect(state.activeLessonId, 'a0-03');
|
||
});
|
||
|
||
test('after the last lesson the learner is sent to unfinished lessons', () {
|
||
final state = AppState();
|
||
state.setPlacementStartLesson('a0-02');
|
||
for (var index = 1; index < a0SeedLessons.length; index++) {
|
||
final lesson = a0SeedLessons[index];
|
||
state.openLesson(lesson.id);
|
||
for (var segment = 0; segment < lesson.segments.length; segment++) {
|
||
state.completeSegment(lesson.id, segment);
|
||
}
|
||
finishActiveLesson(state);
|
||
}
|
||
expect(state.activeLessonId, 'a0-01');
|
||
expect(state.allLessonsComplete, isFalse);
|
||
|
||
finishActiveLesson(state);
|
||
expect(state.allLessonsComplete, isTrue);
|
||
expect(state.a0AssessmentReady, isFalse);
|
||
expect(state.nextAssessmentPackId, 'A0-E1');
|
||
});
|
||
|
||
test(
|
||
'a passed assessment skill remains valid during the seven-day retest window',
|
||
() {
|
||
final state = AppState();
|
||
final now = DateTime.now();
|
||
state.recordAssessment(
|
||
AssessmentRecord(
|
||
packId: 'A0-E1',
|
||
completedAt: now.subtract(const Duration(days: 1)),
|
||
results: const {
|
||
AssessmentSkill.listening: true,
|
||
AssessmentSkill.speaking: false,
|
||
AssessmentSkill.reading: false,
|
||
AssessmentSkill.writing: false,
|
||
},
|
||
),
|
||
);
|
||
final merged = state.recordAssessment(
|
||
AssessmentRecord(
|
||
packId: 'A0-E1',
|
||
completedAt: now,
|
||
results: const {
|
||
AssessmentSkill.listening: false,
|
||
AssessmentSkill.speaking: true,
|
||
AssessmentSkill.reading: false,
|
||
AssessmentSkill.writing: false,
|
||
},
|
||
),
|
||
);
|
||
expect(merged.results[AssessmentSkill.listening], isTrue);
|
||
expect(merged.results[AssessmentSkill.speaking], isTrue);
|
||
},
|
||
);
|
||
|
||
test('a technical speaking gap remains pending instead of failed', () {
|
||
final state = AppState();
|
||
final record = state.recordAssessment(
|
||
AssessmentRecord(
|
||
packId: 'A0-E1',
|
||
completedAt: DateTime.now(),
|
||
results: const {
|
||
AssessmentSkill.listening: true,
|
||
AssessmentSkill.reading: true,
|
||
AssessmentSkill.writing: true,
|
||
AssessmentSkill.speaking: false,
|
||
},
|
||
pendingSkills: const {AssessmentSkill.speaking},
|
||
),
|
||
);
|
||
expect(record.pendingSkills, contains(AssessmentSkill.speaking));
|
||
expect(record.failedSkills, isNot(contains(AssessmentSkill.speaking)));
|
||
});
|
||
|
||
test('replacement assessment packs use a different task value set', () {
|
||
expect(a0ReplacementPacks, hasLength(2));
|
||
expect(replacementFor('A0-E1')!.id, 'A0-E1R');
|
||
expect(
|
||
a0ReplacementPacks.first.tasks.first.audio,
|
||
isNot(a0AssessmentPacks.first.tasks.first.audio),
|
||
);
|
||
});
|
||
|
||
test(
|
||
'replacement results merge into their original seven-day skill window',
|
||
() {
|
||
final state = AppState();
|
||
final now = DateTime.now();
|
||
state.recordAssessment(
|
||
AssessmentRecord(
|
||
packId: 'A0-E1',
|
||
completedAt: now.subtract(const Duration(days: 1)),
|
||
results: const {AssessmentSkill.listening: true},
|
||
),
|
||
);
|
||
final merged = state.recordAssessment(
|
||
AssessmentRecord(
|
||
packId: 'A0-E1R',
|
||
completedAt: now,
|
||
results: const {AssessmentSkill.speaking: true},
|
||
),
|
||
);
|
||
expect(merged.packId, 'A0-E1');
|
||
expect(merged.results[AssessmentSkill.listening], isTrue);
|
||
expect(merged.results[AssessmentSkill.speaking], isTrue);
|
||
},
|
||
);
|
||
|
||
test('assessment draft survives a local-state restore', () async {
|
||
SharedPreferences.setMockInitialValues({});
|
||
final first = AppState();
|
||
await first.load();
|
||
first.saveAssessmentDraft(
|
||
const AssessmentDraft(
|
||
packId: 'A0-E1',
|
||
taskIndex: 4,
|
||
results: {'A0-A-L1': true},
|
||
),
|
||
);
|
||
await Future<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 entry in a0TargetItemIdsByLesson.entries) {
|
||
final segments = lessonById(entry.key).segments;
|
||
final text = [
|
||
for (final segment in segments) ...[
|
||
activityBySegmentId(segment.id, entry.key).listening,
|
||
activityBySegmentId(segment.id, entry.key).speaking,
|
||
activityBySegmentId(segment.id, entry.key).reading,
|
||
activityBySegmentId(segment.id, entry.key).writingExample,
|
||
for (final item in vocabularyBySegmentId(segment.id, entry.key))
|
||
'${item.word}\n${item.example}',
|
||
],
|
||
].join('\n');
|
||
for (final id in entry.value) {
|
||
expect(
|
||
coreItemUsedIn(id, text, word: a0CoreItems[id]),
|
||
isTrue,
|
||
reason: '${entry.key} → $id ${a0CoreItems[id]}',
|
||
);
|
||
}
|
||
}
|
||
});
|
||
}
|