fix: 修正学习流程中的证据、复习与进度问题

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-17 18:52:01 +09:00
co-authored by Claude Opus 5
parent efdff769e4
commit 9ab08c25ef
19 changed files with 1645 additions and 302 deletions
+285 -15
View File
@@ -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',
() {