feat: 完善听力口语辅助状态记录、练习分块与复习抽查间隔机制

This commit is contained in:
shen
2026-09-23 18:33:22 -07:00
parent 6b42b7abc3
commit d740a9e19c
106 changed files with 2933 additions and 845 deletions
@@ -128,12 +128,12 @@ void main() {
);
await tester.enterText(find.byType(TextField), 'I am from Hong Kong.');
await tester.pump();
final submit = find.text('我能独立回答');
final submit = find.text('检查并提交');
await tester.ensureVisible(submit);
await tester.tap(submit);
await tester.pump();
expect(find.text('答对了!'), findsOneWidget);
expect(find.text('完成练习(未计独立掌握)'), findsOneWidget);
expect(find.text('你的回答:I am from Hong Kong.'), findsOneWidget);
expect(find.textContaining("参考说法:I'm from …"), findsOneWidget);
await tester.tap(find.text('完成今天的复习'));
@@ -208,7 +208,7 @@ void main() {
await tester.ensureVisible(check);
await tester.tap(check);
await tester.pump();
expect(find.text('答对了!'), findsOneWidget);
expect(find.text('完成练习(不计独立掌握)'), findsOneWidget);
expect(find.text('AI 补练 · 1/4'), findsOneWidget);
final next = find.text('下一项');
+40 -6
View File
@@ -757,13 +757,16 @@ void main() {
isTrue,
);
}
state.completeSpeaking();
state.completeSpeaking(practiced: true);
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);
// Follow-reading only credits core items contained in the model line.
expect(speaking.map((entry) => entry.itemId).toSet(), {
'A0-W36',
'A0-P01',
'A0-P03',
});
expect(
speaking.every((entry) => entry.outcome == EvidenceKind.assisted),
isTrue,
@@ -780,13 +783,44 @@ void main() {
test('a first-try listening choice is recognition evidence', () {
final state = AppState();
state.completePreview();
state.recordListeningPlayback();
state.completeListening(recognized: true);
for (final id in lessonById('a0-01').targetItemIds) {
expect(state.mastery[id]!.status, MasteryStatus.recognize);
expect(state.mastery['A0-P01']!.status, MasteryStatus.recognize);
expect(state.mastery['A0-P02']!.status, MasteryStatus.newItem);
expect(state.mastery['A0-P03']!.status, MasteryStatus.newItem);
});
test('segment comprehension does not award unrelated core items', () {
final state = AppState()..setPlacementStartLesson('a0-09');
state.completePreview();
state.recordListeningPlayback();
state.completeListening(recognized: true);
final evidence = state.attemptEvidence.where(
(entry) => entry.taskId == 'lesson-a0-09-a-listening-check',
);
expect(evidence.single.itemId, 'segment:a0-09-a');
expect(evidence.single.skill, '听力理解');
for (final id in lessonById('a0-09').targetItemIds) {
expect(state.mastery[id]!.status, MasteryStatus.newItem);
}
});
test('a model sentence without a core link stays segment evidence', () {
final state = AppState()..setPlacementStartLesson('a0-02');
state.completePreview();
state.completeListening();
state.completeSpeaking(practiced: true);
final evidence = state.attemptEvidence.where(
(entry) => entry.taskId == 'lesson-a0-02-a-speaking',
);
expect(evidence.single.itemId, 'segment:a0-02-a');
expect(evidence.single.skill, '句子跟读');
expect(state.mastery['A0-P04']!.status, MasteryStatus.newItem);
});
test('scene dialogue credits taught items the learner actually used', () {
final state = AppState();
state.completePreview();
@@ -1,7 +1,9 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/courses/courses.dart';
import 'package:kouyu_english/core/courses/course_pack.dart';
import '../tool/courses/course_validator.dart';
@@ -56,4 +58,16 @@ void main() {
).validate();
expect(report.errors.map((issue) => '$issue'), isEmpty);
});
test('the course parser keeps item links and extra model sentences', () {
final unit =
jsonDecode(File('assets/courses/A1/A1-U01.json').readAsStringSync())
as Map<String, dynamic>;
final segment = PackSegment.fromJson(
(unit['segments'] as List).first as Map<String, dynamic>,
);
expect(segment.speakingItemIds, ['A1-U01-P01', 'A1-U01-P04']);
expect(segment.speakingExamples, isNotEmpty);
expect(segment.speakingChunks.join(' '), segment.speakingText);
});
}
@@ -83,6 +83,18 @@ void main() {
expect(decision.validationSource, DialogueValidationSource.unavailable);
});
test('local-only free scene cannot grant independent mastery', () {
final decision = resolveDialogueTurnDecision(
local: LocalDialogueVerdict.accepted,
ai: null,
aiWasAttempted: false,
isFreeScene: true,
supportLevel: DialogueSupportLevel.none,
);
expect(decision.canAdvance, isTrue);
expect(decision.evidenceOutcome, EvidenceKind.pending);
});
test('explicit AI correction stops a local false positive', () {
const correction = DialogueAiIntervention(
accepted: false,
+238 -9
View File
@@ -117,33 +117,49 @@ void main() {
),
);
await tester.pump();
expect(find.text('独立完成'), findsOneWidget);
expect(find.text('提交独立尝试'), findsOneWidget);
await tester.enterText(
find.byType(TextField),
'My name is Alex. Nice to meet you. I is happy.',
);
await tester.pump();
final aiButton = find.text('提交前 AI 检查语法和拼写(可选)');
await tester.ensureVisible(aiButton);
await tester.tap(aiButton);
final submit = find.text('提交独立尝试');
await tester.ensureVisible(submit);
await tester.tap(submit);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
expect(find.text('· I is 应为 I am'), findsOneWidget);
final finish = find.text('带帮助完成');
await tester.ensureVisible(finish);
await tester.tap(finish);
expect(state.lessonStep, LessonStep.independent);
await tester.enterText(
find.byType(TextField),
'My name is Alex. Nice to meet you. I am happy.',
);
await tester.pump();
await tester.ensureVisible(submit);
await tester.tap(submit);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
},
() => MockClient((request) async {
prompts.add(request.body);
final accepted = jsonEncode({
'schemaVersion': 'writing-feedback-1',
'verdict': 'accepted',
'feedback': '表达正确。',
'suggestion': null,
'missing': <String>[],
'lessonId': segmentId,
});
return http.Response(
jsonEncode({
'choices': [
{
'message': {'content': check},
'message': {'content': prompts.length == 1 ? check : accepted},
},
],
}),
@@ -153,8 +169,221 @@ void main() {
}),
);
expect(prompts.single, contains('I is happy'));
expect(prompts, hasLength(2));
expect(prompts.first, contains('I is happy'));
expect(state.lessonStep, LessonStep.complete);
expect(state.independentAttemptAssisted, isTrue);
});
testWidgets('writing submission runs AI check without a separate button', (
tester,
) async {
AiService.instance.setFallbackApiKey('test-key');
final state = AppState()
..aiProvider = AiProviderType.compatible
..aiEndpoint = 'https://api.deepseek.com'
..aiModel = 'deepseek-flash'
..lessonStep = LessonStep.writing;
var requests = 0;
await http.runWithClient(
() async {
await tester.pumpWidget(
MaterialApp(
home: LessonFlow(
state: state,
onOpenDialogue: () {},
onFinish: () {},
),
),
);
await tester.tap(find.text('收起示例,自己试一次'));
await tester.pump();
await tester.enterText(find.byType(TextField), 'Hello. I am Alex.');
await tester.pump();
expect(find.text('获取 AI 写作建议(可选)'), findsNothing);
final submit = find.text('提交并继续');
await tester.ensureVisible(submit);
await tester.tap(submit);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
},
() => MockClient((request) async {
requests++;
return http.Response(
jsonEncode({
'choices': [
{
'message': {
'content': jsonEncode({
'schemaVersion': 'writing-feedback-1',
'verdict': 'accepted',
'feedback': '表达正确。',
'suggestion': null,
'missing': <String>[],
'lessonId': 'a0-01',
}),
},
},
],
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}),
);
expect(requests, 1);
expect(state.lessonStep, LessonStep.dialogue);
expect(
state.attemptEvidence
.where((entry) => entry.taskId == 'lesson-a0-01-a-writing')
.every((entry) => entry.outcome == EvidenceKind.assisted),
isTrue,
);
});
testWidgets('a locally matching bad sentence waits for AI correction', (
tester,
) async {
AiService.instance.setFallbackApiKey('test-key');
final state = AppState()
..aiProvider = AiProviderType.compatible
..aiEndpoint = 'https://api.deepseek.com'
..aiModel = 'deepseek-flash'
..lessonStep = LessonStep.writing;
await http.runWithClient(
() async {
await tester.pumpWidget(
MaterialApp(
home: LessonFlow(
state: state,
onOpenDialogue: () {},
onFinish: () {},
),
),
);
await tester.enterText(find.byType(TextField), 'Hello. I am is Alex.');
await tester.pump();
final submit = find.text('提交并继续');
await tester.ensureVisible(submit);
await tester.tap(submit);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
},
() => MockClient(
(_) async => http.Response(
jsonEncode({
'choices': [
{
'message': {
'content': jsonEncode({
'schemaVersion': 'writing-feedback-1',
'verdict': 'rewrite',
'feedback': '这里多了 is。',
'suggestion': 'Hello. I am Alex.',
'missing': ['删去多余的 is'],
'lessonId': 'a0-01',
}),
},
},
],
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
),
),
);
expect(state.lessonStep, LessonStep.writing);
expect(find.text('AI 建议:这里多了 is。'), findsOneWidget);
expect(find.text('请按自己的意思修改,再提交检查。看过改正后,本次只算辅助练习。'), findsOneWidget);
});
testWidgets('independent submission without AI stays unverified', (
tester,
) async {
final state = AppState()..lessonStep = LessonStep.independent;
await tester.pumpWidget(
MaterialApp(
home: LessonFlow(state: state, onOpenDialogue: () {}, onFinish: () {}),
),
);
await tester.enterText(
find.byType(TextField),
'My name is Alex. Nice to meet you.',
);
await tester.pump();
final submit = find.text('提交独立尝试');
await tester.ensureVisible(submit);
await tester.tap(submit);
await tester.pump();
expect(state.lessonStep, LessonStep.complete);
expect(state.independentAttemptPending, isTrue);
expect(
state.attemptEvidence
.where((entry) => entry.taskId == 'lesson-a0-01-a-independent')
.every((entry) => entry.outcome == EvidenceKind.pending),
isTrue,
);
expect(find.textContaining('未经 AI 核验'), findsOneWidget);
});
test('unverified completion survives a local snapshot', () {
final first = AppState()
..completeIndependentAttempt(
assisted: false,
verified: false,
rawAnswer: 'My name is Alex. Nice to meet you.',
);
final resumed = AppState()..restoreForTest(first.snapshotForTest());
expect(resumed.independentAttemptPending, isTrue);
expect(resumed.independentAttemptComplete, isTrue);
expect(
resumed.attemptEvidence
.where((entry) => entry.taskId == 'lesson-a0-01-a-independent')
.every((entry) => entry.outcome == EvidenceKind.pending),
isTrue,
);
});
testWidgets('AI outage offers an unverified continuation', (tester) async {
AiService.instance.setFallbackApiKey('test-key');
final state = AppState()
..aiProvider = AiProviderType.compatible
..aiEndpoint = 'https://api.deepseek.com'
..aiModel = 'deepseek-flash'
..lessonStep = LessonStep.independent;
await http.runWithClient(() async {
await tester.pumpWidget(
MaterialApp(
home: LessonFlow(
state: state,
onOpenDialogue: () {},
onFinish: () {},
),
),
);
await tester.enterText(
find.byType(TextField),
'My name is Alex. Nice to meet you.',
);
await tester.pump();
final submit = find.text('提交独立尝试');
await tester.ensureVisible(submit);
await tester.tap(submit);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
expect(state.lessonStep, LessonStep.independent);
final fallback = find.text('未核验,保存练习并继续');
await tester.ensureVisible(fallback);
await tester.tap(fallback);
await tester.pump();
}, () => MockClient((_) async => http.Response('{}', 503)));
expect(state.lessonStep, LessonStep.complete);
expect(state.independentAttemptPending, isTrue);
});
}
@@ -0,0 +1,180 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/core/models.dart';
void main() {
test(
'listening help survives resume and cannot earn independent evidence',
() {
final first = AppState()..lessonStep = LessonStep.listening;
first.recordListeningPlayback(slow: true);
first.revealListeningTranscript();
first.recordListeningMiss();
final resumed = AppState()..restoreForTest(first.snapshotForTest());
expect(resumed.listeningAudioPlayed, isTrue);
expect(resumed.listeningSupport, ListeningSupport.transcript);
expect(resumed.listeningAttempts, 1);
resumed.completeListening(recognized: true);
final evidence = resumed.attemptEvidence
.where((item) => item.taskId.endsWith('listening-check'))
.toList();
expect(evidence, isNotEmpty);
expect(
evidence.every((item) => item.outcome == EvidenceKind.assisted),
isTrue,
);
expect(evidence.first.listeningSupport, ListeningSupport.transcript);
expect(evidence.first.firstTry, isFalse);
expect(evidence.first.attemptCount, 2);
},
);
test('unaided first listening answer earns recognition evidence', () {
final state = AppState()..lessonStep = LessonStep.listening;
state.recordListeningPlayback();
state.completeListening(recognized: true);
final evidence = state.attemptEvidence
.where((item) => item.taskId.endsWith('listening-check'))
.toList();
expect(evidence, isNotEmpty);
expect(
evidence.every((item) => item.outcome == EvidenceKind.independentSuccess),
isTrue,
);
expect(evidence.first.firstTry, isTrue);
});
test('a choice without playable audio cannot earn listening recognition', () {
final state = AppState()..lessonStep = LessonStep.listening;
state.completeListening(recognized: true);
final evidence = state.attemptEvidence
.where((item) => item.taskId.endsWith('listening-check'))
.toList();
expect(
evidence.every((item) => item.outcome == EvidenceKind.assisted),
isTrue,
);
});
test(
'TTS failure survives resume and gives only assisted listening credit',
() {
final state = AppState()..lessonStep = LessonStep.listening;
state.recordListeningAudioUnavailable();
final resumed = AppState()..restoreForTest(state.snapshotForTest());
expect(resumed.listeningAudioUnavailable, isTrue);
expect(resumed.listeningSupport, ListeningSupport.transcript);
resumed.completeListening(recognized: true);
expect(resumed.lessonStep, LessonStep.speaking);
expect(resumed.listeningAudioUnavailable, isFalse);
expect(
resumed.attemptEvidence
.where((entry) => entry.taskId.endsWith('listening-check'))
.every((entry) => entry.outcome == EvidenceKind.assisted),
isTrue,
);
},
);
test('skipping follow-reading does not create speaking evidence', () {
final state = AppState()..lessonStep = LessonStep.speaking;
state.completeSpeaking();
expect(state.lessonStep, LessonStep.reading);
expect(
state.attemptEvidence.where(
(entry) => entry.taskId.endsWith('-speaking'),
),
isEmpty,
);
});
test('the fourth checkpoint keeps its 30-day audit interval', () {
final state = AppState();
state.reviewQueue.add(
ReviewItem(
id: 'A0-P02',
target: "What's your name?",
prompt: '问名字',
hint: "What's your name?",
dueAt: DateTime.now().subtract(const Duration(days: 1)),
skill: '回忆表达',
successfulReviews: 3,
),
);
state.completeReview(
state.reviewQueue.single,
assisted: false,
firstTry: true,
attemptCount: 1,
rawAnswer: "What's your name?",
);
final next = state.reviewQueue.single;
expect(next.successfulReviews, 4);
expect(
next.dueAt.difference(DateTime.now()).inDays,
inInclusiveRange(29, 30),
);
expect(state.attemptEvidence.last.firstTry, isTrue);
expect(state.attemptEvidence.last.attemptCount, 1);
});
test('later clean mastery audits lengthen to 45 days', () {
final state = AppState();
state.reviewQueue.add(
ReviewItem(
id: 'A0-P02',
target: "What's your name?",
prompt: '问名字',
hint: "What's your name?",
dueAt: DateTime.now().subtract(const Duration(days: 1)),
skill: '回忆表达',
successfulReviews: 4,
),
);
state.completeReview(
state.reviewQueue.single,
assisted: false,
firstTry: true,
attemptCount: 1,
rawAnswer: "What's your name?",
);
expect(
state.reviewQueue.single.dueAt.difference(DateTime.now()).inDays,
inInclusiveRange(44, 45),
);
});
test(
'slow dictation cannot advance a checkpoint even if caller says independent',
() {
final state = AppState();
state.reviewQueue.add(
ReviewItem(
id: 'A0-P02',
target: "What's your name?",
prompt: '听写',
hint: "What's your name?",
dueAt: DateTime.now().subtract(const Duration(days: 1)),
skill: '听写',
),
);
state.completeReview(
state.reviewQueue.single,
assisted: false,
firstTry: true,
attemptCount: 1,
listeningSupport: ListeningSupport.slow,
rawAnswer: "What's your name?",
);
expect(state.reviewQueue.single.successfulReviews, 0);
expect(state.attemptEvidence.last.outcome, EvidenceKind.assisted);
expect(
state.attemptEvidence.last.listeningSupport,
ListeningSupport.slow,
);
},
);
}
+10 -2
View File
@@ -63,17 +63,25 @@ void main() {
expect(await store.read(), isNull);
const first = '''{
"revision": 1,
"attemptEvidence": [{"id":"a1","itemId":"A0-P01","taskId":"t1","skill":"speaking","inputMode":"speechToText","outcome":"independentSuccess","createdAt":"2026-09-14T00:00:00.000Z","assisted":false,"variantIndex":0,"originalTranscript":"Hello","transcriptConfirmed":true,"transcriptEdited":false}],
"attemptEvidence": [{"id":"a1","itemId":"A0-P01","taskId":"t1","skill":"speaking","inputMode":"speechToText","outcome":"independentSuccess","createdAt":"2026-09-14T00:00:00.000Z","assisted":false,"variantIndex":0,"originalTranscript":"Hello","transcriptConfirmed":true,"transcriptEdited":false,"firstTry":true,"attemptCount":1,"listeningSupport":"slow","usedSentenceBuild":true}],
"mastery": [{"id":"A0-P01","label":"Hello","status":"recognize","checkpoint":1,"needsReview":false}],
"reviews": [{"id":"A0-P01","dueAt":"2026-09-15T00:00:00.000Z","attempts":1,"successfulReviews":1,"variantIndex":0}],
"temporaryLexicon": [{"query":"library","definition":"图书馆","provider":"compatible","model":"demo","createdAt":"2026-09-14T00:00:00.000Z"}],
"lessonStep":"speaking", "activeLessonId":"a0-01"
"lessonStep":"speaking", "activeLessonId":"a0-01",
"listeningAudioPlayed":true,"listeningSupport":"slow","listeningAttempts":1,
"sentenceBuildUsed":true,"sentenceBuildIndex":2
}''';
await store.write(first);
final restored =
jsonDecode((await store.read())!) as Map<String, dynamic>;
expect(restored['lessonStep'], 'speaking');
expect(restored['attemptEvidence'], hasLength(1));
expect(restored['attemptEvidence'][0]['firstTry'], isTrue);
expect(restored['attemptEvidence'][0]['attemptCount'], 1);
expect(restored['attemptEvidence'][0]['listeningSupport'], 'slow');
expect(restored['attemptEvidence'][0]['usedSentenceBuild'], isTrue);
expect(restored['listeningSupport'], 'slow');
expect(restored['sentenceBuildIndex'], 2);
expect(restored['mastery'], hasLength(1));
expect(
await executor.runSelect(
@@ -70,6 +70,32 @@ void main() {
expect(find.text('从更简单内容开始'), findsNothing);
});
testWidgets('self-reported basics move a learner who passes both checks', (
tester,
) async {
final state = AppState();
await tester.pumpWidget(
MaterialApp(
home: PlacementPage(state: state, onStart: () {}),
),
);
await tester.tap(find.text('能说一点'));
await tester.tap(find.text('开始 3 分钟定位'));
await tester.pumpAndSettle();
await tester.tap(find.text('你好,我叫 Mia,很高兴认识你。'));
await tester.pump();
await tester.tap(find.text('下一题'));
await tester.pumpAndSettle();
await tester.tap(find.text('现在不方便开口,跳过'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'My name is Mia.');
await tester.tap(find.text('查看建议'));
await tester.pumpAndSettle();
expect(find.text('建议从 A0 第 3 课开始'), findsOneWidget);
await tester.tap(find.text('开始今天学习'));
expect(state.placementStartLessonId, 'a0-03');
});
testWidgets('scene page lists open and locked scenes', (tester) async {
final state = AppState()
..completedLessonIds.addAll(['a0-01', 'a0-02', 'a0-03', 'a0-04']);
+38 -8
View File
@@ -136,9 +136,9 @@ void main() {
);
await tester.enterText(find.byType(TextField), 'I am from Chna');
await tester.pump();
final aiButton = find.text('AI 检查语法和拼写(可选)');
await tester.ensureVisible(aiButton);
await tester.tap(aiButton);
final submit = find.text('检查并提交');
await tester.ensureVisible(submit);
await tester.tap(submit);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
@@ -161,12 +161,42 @@ void main() {
expect(find.text('· Chna 拼写应为 China'), findsOneWidget);
expect(find.text('参考改正:I am from China.'), findsOneWidget);
final submit = find.text('带提示完成');
expect(submit, findsOneWidget);
await tester.ensureVisible(submit);
await tester.tap(submit);
expect(state.attemptEvidence, isEmpty);
await tester.enterText(find.byType(TextField), 'I am from China.');
await tester.pump();
final submit = find.text('带提示完成');
await http.runWithClient(
() async {
await tester.ensureVisible(submit);
await tester.tap(submit);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
},
() => MockClient(
(request) async => http.Response(
jsonEncode({
'choices': [
{
'message': {
'content': jsonEncode({
'schemaVersion': 'writing-feedback-1',
'verdict': 'accepted',
'feedback': '表达正确。',
'suggestion': null,
'missing': <String>[],
'lessonId': 'A0-P12',
}),
},
},
],
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
),
),
);
expect(state.attemptEvidence.single.outcome, EvidenceKind.assisted);
expect(state.attemptEvidence.single.assisted, isTrue);
});
+14 -18
View File
@@ -4,9 +4,7 @@ import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/core/models.dart';
import 'package:kouyu_english/core/courses/course_repository.dart';
/// The A0 → A1 → A2 → B1 unlock chain. Crossing out of A0 into A1 additionally
/// requires passing the A0 stage assessment; higher levels then chain linearly
/// (finishing the previous unit unlocks the next), with no extra assessment.
/// Draft packs are loaded for editing and validation but are not learnable.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -72,13 +70,13 @@ void main() {
reason: 'A1 stays locked until A0 is passed',
);
// Passing the A0 stage assessment opens the first A1 unit.
// Passing the A0 stage assessment does not bypass editorial approval.
makeA0Passed(state);
expect(state.a0Passed, isTrue);
expect(state.isLessonUnlocked(firstA1.id), isTrue);
expect(state.isLessonUnlocked(firstA1.id), isFalse);
});
test('A1 → A2 → B1 chain linearly by finishing the previous unit', () {
test('draft packs stay locked even when prerequisites are complete', () {
final state = AppState();
makeA0Passed(state);
for (final lesson in a0SeedLessons) {
@@ -89,34 +87,32 @@ void main() {
final a2 = byLevel('A2');
final b1 = byLevel('B1');
// A1 opened, but only the first unit; the second waits on the first.
expect(state.isLessonUnlocked(a1.first.id), isTrue);
expect(state.isLessonUnlocked(a1.first.id), isFalse);
expect(state.isLessonUnlocked(a1[1].id), isFalse);
// Finishing the first A1 unit unlocks the second — no extra gate.
state.completedLessonIds.add(a1.first.id);
expect(state.isLessonUnlocked(a1[1].id), isTrue);
// A2 stays locked until the last A1 unit is finished.
expect(state.isLessonUnlocked(a1[1].id), isFalse);
expect(state.isLessonUnlocked(a2.first.id), isFalse);
for (final lesson in a1) {
state.completedLessonIds.add(lesson.id);
}
expect(
state.isLessonUnlocked(a2.first.id),
isTrue,
reason: 'A1 → A2 chains linearly, no separate assessment',
isFalse,
reason: 'Completing draft prerequisites must not open another draft',
);
// B1 likewise unlocks only after the last A2 unit.
expect(state.isLessonUnlocked(b1.first.id), isFalse);
for (final lesson in a2) {
state.completedLessonIds.add(lesson.id);
}
expect(
state.isLessonUnlocked(b1.first.id),
isTrue,
reason: 'A2 → B1 chains linearly',
isFalse,
reason: 'B1 remains closed until approved',
);
expect(state.nextIncompleteLessonId, isNull);
expect(state.allLessonsComplete, isTrue);
state.setPlacementStartLesson(a1.first.id);
expect(state.placementStartLessonId, 'a0-01');
});
}
+1 -1
View File
@@ -38,7 +38,7 @@ void main() {
expect(state.lessonWritingDraft, 'Hello. I am Shen.');
final check = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, '检查句子'),
find.widgetWithText(FilledButton, '提交并继续'),
);
expect(check.onPressed, isNotNull);
});