feat: 完善听力口语辅助状态记录、练习分块与复习抽查间隔机制
This commit is contained in:
@@ -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('下一项');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user