fix: 修正学习流程中的证据、复习与进度问题
- 独立尝试按课程要求校验内容,不再任意输入即算独立成功 - 复习节点改为学后第 1/3/7/14 天,之后每 30 天抽查;复习轮换情境 - 掌握状态按作答记录推导:需认识、可回忆、跨情境使用及最近两次无帮助 - 课程证据按实际用到的目标记录;跟读与课程对话只算辅助练习 - 复习成功不再降低已有状态 - 学完课程后进入下一节未完成课程;全部学完后引导巩固与阶段评估 - 新增 3 题基础定位,按结果设置起始课程 - 新增按课程开放的对话场景,首页推荐最少练习的场景 - 切换课程时清空上一课的步骤进度 - AI 返回 JSON 格式并按 JSON 回放历史;拼写校验忽略大小写与连字符;移除学习目标选择 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -123,6 +123,17 @@ void main() {
|
||||
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(
|
||||
@@ -141,6 +152,13 @@ void main() {
|
||||
|
||||
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)
|
||||
@@ -153,6 +171,17 @@ void main() {
|
||||
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(
|
||||
@@ -163,14 +192,75 @@ void main() {
|
||||
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.master);
|
||||
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(
|
||||
@@ -265,7 +355,7 @@ void main() {
|
||||
final state = AppState();
|
||||
state.completePreview();
|
||||
state.completeListening();
|
||||
state.completeSpeaking(assisted: true);
|
||||
state.completeSpeaking();
|
||||
state.completeReading();
|
||||
state.completeWriting(assisted: true);
|
||||
|
||||
@@ -553,7 +643,9 @@ void main() {
|
||||
correct: true,
|
||||
);
|
||||
expect(state.attemptEvidence, hasLength(1));
|
||||
expect(state.mastery['A0-P12']!.status, MasteryStatus.recognize);
|
||||
// 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(
|
||||
@@ -640,9 +732,51 @@ void main() {
|
||||
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);
|
||||
// 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(
|
||||
@@ -659,13 +793,17 @@ void main() {
|
||||
assisted: false,
|
||||
rawAnswer: 'Hello. I am Mia.',
|
||||
);
|
||||
final primary = lessonById('a0-01').targetItemIds.last;
|
||||
const introduced = 'A0-P01';
|
||||
|
||||
state.mastery.clear();
|
||||
state.rebuildMasteryFromEvidence();
|
||||
|
||||
expect(state.mastery[primary]!.firstTaughtAt, isNotNull);
|
||||
expect(state.mastery[primary]!.status, MasteryStatus.use);
|
||||
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,
|
||||
@@ -770,6 +908,79 @@ void main() {
|
||||
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();
|
||||
|
||||
@@ -797,7 +1008,7 @@ void main() {
|
||||
for (var segment = 0; segment < lesson.segments.length; segment++) {
|
||||
state.completePreview();
|
||||
state.completeListening();
|
||||
state.completeSpeaking(assisted: true);
|
||||
state.completeSpeaking();
|
||||
state.completeReading();
|
||||
state.completeWriting(assisted: true);
|
||||
state.completeLessonDialogue();
|
||||
@@ -893,10 +1104,7 @@ void main() {
|
||||
checkOpenAssessmentAnswer(speaking[6], 'Please speak slowly.'),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
checkOpenAssessmentAnswer(speaking[6], 'Repeat, please.'),
|
||||
isTrue,
|
||||
);
|
||||
expect(checkOpenAssessmentAnswer(speaking[6], 'Repeat, please.'), isTrue);
|
||||
});
|
||||
|
||||
test('finishLesson advances activeLessonId to next available lesson', () {
|
||||
@@ -904,7 +1112,7 @@ void main() {
|
||||
expect(state.activeLessonId, 'a0-01');
|
||||
state.completePreview();
|
||||
state.completeListening();
|
||||
state.completeSpeaking(assisted: false);
|
||||
state.completeSpeaking();
|
||||
state.completeReading();
|
||||
state.completeWriting(assisted: false);
|
||||
state.completeLessonDialogue();
|
||||
@@ -916,6 +1124,68 @@ void main() {
|
||||
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',
|
||||
() {
|
||||
|
||||
@@ -43,6 +43,7 @@ void main() {
|
||||
final body = await captureBody('https://api.deepseek.com');
|
||||
|
||||
expect(body['thinking'], {'type': 'disabled'});
|
||||
expect(body['response_format'], {'type': 'json_object'});
|
||||
expect(body.containsKey('reasoning_effort'), isFalse);
|
||||
expect(body['max_tokens'], 200);
|
||||
});
|
||||
@@ -51,6 +52,9 @@ void main() {
|
||||
final body = await captureBody('https://api.deepseek.com/v1/responses');
|
||||
|
||||
expect(body['reasoning'], {'effort': 'none'});
|
||||
expect(body['text'], {
|
||||
'format': {'type': 'json_object'},
|
||||
});
|
||||
expect(body.containsKey('reasoning_effort'), isFalse);
|
||||
expect(body.containsKey('thinking'), isFalse);
|
||||
});
|
||||
@@ -59,6 +63,48 @@ void main() {
|
||||
final body = await captureBody('https://example.test/v1');
|
||||
|
||||
expect(body.containsKey('thinking'), isFalse);
|
||||
expect(body.containsKey('response_format'), isFalse);
|
||||
expect(body['reasoning_effort'], 'low');
|
||||
});
|
||||
|
||||
test('dialogue history replays earlier AI lines as JSON', () async {
|
||||
AiService.instance.setFallbackApiKey('test-key');
|
||||
List<dynamic>? messages;
|
||||
final reply = await http.runWithClient(
|
||||
() => AiService.instance.dialogueReply(
|
||||
provider: AiProviderType.compatible,
|
||||
endpoint: 'https://api.deepseek.com',
|
||||
model: 'deepseek-flash',
|
||||
aiGoal: 'Ask where the learner is from.',
|
||||
learnerTask: 'say where they are from',
|
||||
history: const [
|
||||
{'role': 'assistant', 'content': "Hi! What's your name?"},
|
||||
{'role': 'user', 'content': 'My name is Alex.'},
|
||||
],
|
||||
),
|
||||
() => MockClient((request) async {
|
||||
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
messages = body['messages'] as List<dynamic>;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'choices': [
|
||||
{
|
||||
'message': {'content': '{"reply":"Where are you from?"}'},
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
expect(reply?.reply, 'Where are you from?');
|
||||
expect(messages, hasLength(3));
|
||||
expect(messages![0]['role'], 'system');
|
||||
expect(jsonDecode(messages![1]['content'] as String), {
|
||||
'reply': "Hi! What's your name?",
|
||||
});
|
||||
expect(messages![2], {'role': 'user', 'content': 'My name is Alex.'});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@ void main() {
|
||||
);
|
||||
}
|
||||
expect(a0MeetDialogue.taskLabels.length, a0MeetDialogue.prompts.length);
|
||||
expect(a0MeetDialogue.requiredTerms.length, a0MeetDialogue.prompts.length);
|
||||
expect(
|
||||
a0MeetDialogue.requiredTerms.length,
|
||||
a0MeetDialogue.prompts.length,
|
||||
);
|
||||
});
|
||||
|
||||
test('示范答案能通过对应回合的校验', () {
|
||||
@@ -46,37 +49,101 @@ void main() {
|
||||
|
||||
test('跑题回答不再因为关键词沾边而通过', () {
|
||||
// 旧实现用 text.contains('it'),下面这些句子全部会被判为完成任务。
|
||||
expect(matchesDialogueStage(a0Dialogues['a0-05']!, 0, 'I did it.'), isFalse);
|
||||
expect(matchesDialogueStage(a0Dialogues['a0-08']!, 0, 'It is good.'), isFalse);
|
||||
expect(matchesDialogueStage(a0Dialogues['a0-09']!, 2, 'I like tea.'), isFalse);
|
||||
expect(
|
||||
matchesDialogueStage(a0Dialogues['a0-05']!, 0, 'I did it.'),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
matchesDialogueStage(a0Dialogues['a0-08']!, 0, 'It is good.'),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
matchesDialogueStage(a0Dialogues['a0-09']!, 2, 'I like tea.'),
|
||||
isFalse,
|
||||
);
|
||||
expect(matchesDialogueStage(a0MeetDialogue, 0, 'Hello.'), isFalse);
|
||||
expect(matchesDialogueStage(a0MeetDialogue, 1, 'I am good.'), isFalse);
|
||||
});
|
||||
|
||||
test('自由场景接受同样正确的其他说法', () {
|
||||
// 旧正则只认 good/okay/tired,也不认 my name's。
|
||||
expect(matchesDialogueStage(a0MeetDialogue, 0, "My name's Shen."), isTrue);
|
||||
expect(
|
||||
matchesDialogueStage(a0MeetDialogue, 0, "My name's Shen."),
|
||||
isTrue,
|
||||
);
|
||||
expect(matchesDialogueStage(a0MeetDialogue, 0, 'I am Shen.'), isTrue);
|
||||
expect(matchesDialogueStage(a0MeetDialogue, 2, "I'm fine, thanks."), isTrue);
|
||||
expect(
|
||||
matchesDialogueStage(a0MeetDialogue, 2, "I'm fine, thanks."),
|
||||
isTrue,
|
||||
);
|
||||
expect(matchesDialogueStage(a0MeetDialogue, 2, 'I am great!'), isTrue);
|
||||
expect(matchesDialogueStage(a0MeetDialogue, 3, 'Where are you from?'), isTrue);
|
||||
expect(matchesDialogueStage(a0MeetDialogue, 3, 'Do you like tea?'), isTrue);
|
||||
expect(
|
||||
matchesDialogueStage(a0MeetDialogue, 3, 'Where are you from?'),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
matchesDialogueStage(a0MeetDialogue, 3, 'Do you like tea?'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('只写半句不算完成任务', () {
|
||||
expect(matchesDialogueStage(a0MeetDialogue, 0, "I'm"), isFalse);
|
||||
expect(matchesDialogueStage(a0Dialogues['a0-04']!, 0, 'My number is'), isFalse);
|
||||
expect(
|
||||
matchesDialogueStage(a0Dialogues['a0-04']!, 0, 'My number is one-three-eight.'),
|
||||
matchesDialogueStage(a0Dialogues['a0-04']!, 0, 'My number is'),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
matchesDialogueStage(
|
||||
a0Dialogues['a0-04']!,
|
||||
0,
|
||||
'My number is one-three-eight.',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('拼读和数字这类结构化要求可以识别', () {
|
||||
expect(matchesDialogueStage(a0Dialogues['a0-02']!, 1, 'S-H-E-N'), isTrue);
|
||||
expect(matchesDialogueStage(a0Dialogues['a0-02']!, 1, 'Shen'), isFalse);
|
||||
expect(matchesDialogueStage(a0Dialogues['a0-08']!, 2, "It's three o'clock."), isTrue);
|
||||
expect(matchesDialogueStage(a0Dialogues['a0-08']!, 2, "It's Monday."), isFalse);
|
||||
});
|
||||
|
||||
test('拼读忽略大小写和连字符', () {
|
||||
final spelling = a0Dialogues['a0-02']!;
|
||||
for (final answer in [
|
||||
'A-A-A',
|
||||
'aaa',
|
||||
'AAA',
|
||||
'a-a-a',
|
||||
'A - A - A',
|
||||
'a a a',
|
||||
'Shen',
|
||||
'S-H-E-N.',
|
||||
'She-n',
|
||||
'L-I',
|
||||
'Li',
|
||||
'My name is Alex. A-L-E-X.',
|
||||
]) {
|
||||
expect(
|
||||
matchesDialogueStage(spelling, 1, answer),
|
||||
isTrue,
|
||||
reason: answer,
|
||||
);
|
||||
}
|
||||
for (final answer in ['a', 'My name is Alex.', '123', '---']) {
|
||||
expect(
|
||||
matchesDialogueStage(spelling, 1, answer),
|
||||
isFalse,
|
||||
reason: answer,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
matchesDialogueStage(a0Dialogues['a0-08']!, 2, "It's three o'clock."),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
matchesDialogueStage(a0Dialogues['a0-08']!, 2, "It's Monday."),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('任务标签可用于提示和总结', () {
|
||||
@@ -86,7 +153,11 @@ void main() {
|
||||
|
||||
test('输入校验忽略多余空格与大小写', () {
|
||||
expect(
|
||||
matchesDialogueStage(a0MeetDialogue, 0, " MY NAME IS SHEN "),
|
||||
matchesDialogueStage(
|
||||
a0MeetDialogue,
|
||||
0,
|
||||
" MY NAME IS SHEN ",
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
@@ -94,11 +165,19 @@ void main() {
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
matchesDialogueStage(a0Dialogues["a0-04"]!, 0, "MY NUMBER IS ONE-THREE-EIGHT"),
|
||||
matchesDialogueStage(
|
||||
a0Dialogues["a0-04"]!,
|
||||
0,
|
||||
"MY NUMBER IS ONE-THREE-EIGHT",
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
matchesDialogueStage(a0Dialogues["a0-08"]!, 2, "IT'S THREE O'CLOCK"),
|
||||
matchesDialogueStage(
|
||||
a0Dialogues["a0-08"]!,
|
||||
2,
|
||||
"IT'S THREE O'CLOCK",
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
@@ -110,7 +189,10 @@ void main() {
|
||||
expect(later.length, greaterThan(first.length));
|
||||
expect(first.every(later.contains), isTrue);
|
||||
expect(later.length, lessThanOrEqualTo(allTaughtLanguage.length));
|
||||
expect(first.any((word) => word.toLowerCase().contains('coffee')), isFalse);
|
||||
expect(
|
||||
first.any((word) => word.toLowerCase().contains('coffee')),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/app_state.dart';
|
||||
import 'package:kouyu_english/features/dialogue/dialogue_flow.dart';
|
||||
import 'package:kouyu_english/features/onboarding/onboarding_pages.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
testWidgets('three-question placement suggests a starting lesson', (
|
||||
tester,
|
||||
) async {
|
||||
final state = AppState();
|
||||
var started = false;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: PlacementPage(state: state, onStart: () => started = true),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('开始 3 分钟定位'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('定位 · 1 / 3'), findsOneWidget);
|
||||
await tester.tap(find.text('你好,我叫 Mia,很高兴认识你。'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('下一题'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('定位 · 2 / 3'), findsOneWidget);
|
||||
await tester.tap(find.text('现在不方便开口,跳过'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('定位 · 3 / 3'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField), 'My name is Mia.');
|
||||
await tester.tap(find.text('查看建议'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('建议从 A0 第 2 课开始'), findsOneWidget);
|
||||
expect(find.text('从更简单内容开始'), findsOneWidget);
|
||||
await tester.tap(find.text('开始今天学习'));
|
||||
await tester.pump();
|
||||
|
||||
expect(started, isTrue);
|
||||
expect(state.activeLessonId, 'a0-02');
|
||||
expect(state.placementStartLessonId, 'a0-02');
|
||||
expect(state.completedLessonIds, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('an unanswered placement starts from lesson one', (tester) async {
|
||||
final state = AppState();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: PlacementPage(state: state, onStart: () {}),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('开始 3 分钟定位'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('听不懂,下一题'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('我读完了'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('还不会说,查看建议'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('建议从 A0 第 1 课开始'), findsOneWidget);
|
||||
expect(find.text('从更简单内容开始'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('scene page lists open and locked scenes', (tester) async {
|
||||
final state = AppState()
|
||||
..completedLessonIds.addAll(['a0-01', 'a0-02', 'a0-03', 'a0-04']);
|
||||
String? startedScene;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: DialogueScenePage(
|
||||
state: state,
|
||||
onStart: (id) => startedScene = id,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('初次见面'), findsOneWidget);
|
||||
expect(find.text('留个电话'), findsOneWidget);
|
||||
expect(find.text('A0 · 学完第 7 课后开放'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, '开始对话').first);
|
||||
expect(startedScene, isNotNull);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user