- generateReviewVariant 的指令要求 stimulus/answer/acceptedAnswers 等字段, 而 decodeGeneratedReviewVariant 只接受 schemaVersion、variantId、 targetItemId、prompt、expectedAnswer 五个字段,导致 AI 变式始终校验失败。 现在指令只要求这五个字段,并补充了请求与解码一致性的测试。 - finishVoiceInput(keepAudio: false) 转写完成(或页面已关闭)后删除音频, 补练页和测评页的语音输入不再在本机残留录音文件。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
6.5 KiB
Dart
209 lines
6.5 KiB
Dart
import 'dart:convert';
|
||
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:http/testing.dart';
|
||
import 'package:kouyu_english/core/ai_service.dart';
|
||
import 'package:kouyu_english/core/generated_content.dart';
|
||
import 'package:kouyu_english/core/models.dart';
|
||
|
||
void main() {
|
||
const valid = '''{
|
||
"schemaVersion":"review-variant-1",
|
||
"variantId":"ai-a0-p12-001",
|
||
"targetItemId":"A0-P12",
|
||
"prompt":"请用英语说你来自哪里。",
|
||
"expectedAnswer":"I’m from [place]."
|
||
}''';
|
||
|
||
test('accepts a bounded variant for its requested frozen target', () {
|
||
final item = decodeGeneratedReviewVariant(
|
||
valid,
|
||
expectedTargetItemId: 'A0-P12',
|
||
);
|
||
|
||
expect(item, isNotNull);
|
||
expect(item!.targetItemId, 'A0-P12');
|
||
});
|
||
|
||
test('rejects an AI variant that changes the target identity', () {
|
||
expect(
|
||
decodeGeneratedReviewVariant(valid, expectedTargetItemId: 'A0-P01'),
|
||
isNull,
|
||
);
|
||
});
|
||
|
||
test('rejects unknown schema fields', () {
|
||
final invalid = valid.replaceFirst('}', ',"unsafe":"ignore"}');
|
||
expect(
|
||
decodeGeneratedReviewVariant(invalid, expectedTargetItemId: 'A0-P12'),
|
||
isNull,
|
||
);
|
||
});
|
||
|
||
test(
|
||
'review variant request asks for exactly the fields it accepts',
|
||
() async {
|
||
AiService.instance.setFallbackApiKey('test-key');
|
||
final prompts = <String>[];
|
||
final variant = await http.runWithClient(
|
||
() => AiService.instance.generateReviewVariant(
|
||
provider: AiProviderType.openAi,
|
||
endpoint: 'https://example.test/v1',
|
||
model: 'test-model',
|
||
targetItemId: 'A0-P12',
|
||
basePrompt: '请用英语说你来自哪里。',
|
||
),
|
||
() => MockClient((request) async {
|
||
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
||
final messages = body['messages'] as List<dynamic>;
|
||
prompts.add((messages.single as Map<String, dynamic>)['content']);
|
||
return http.Response(
|
||
jsonEncode({
|
||
'choices': [
|
||
{
|
||
'message': {'content': valid},
|
||
},
|
||
],
|
||
}),
|
||
200,
|
||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||
);
|
||
}),
|
||
);
|
||
|
||
expect(variant, isNotNull);
|
||
expect(prompts, hasLength(1));
|
||
for (final field in [
|
||
'schemaVersion',
|
||
'variantId',
|
||
'targetItemId',
|
||
'prompt',
|
||
'expectedAnswer',
|
||
]) {
|
||
expect(prompts.single, contains(field));
|
||
}
|
||
for (final field in ['stimulus', 'acceptedAnswers', 'forbiddenPhrases']) {
|
||
expect(prompts.single, isNot(contains(field)));
|
||
}
|
||
},
|
||
);
|
||
|
||
const writing = '''{
|
||
"schemaVersion":"writing-feedback-1",
|
||
"lessonId":"a0-06",
|
||
"verdict":"rewrite",
|
||
"feedback":"加上 from 就能说清来自哪里。",
|
||
"suggestion":"I'm from Hong Kong.",
|
||
"missing":["来自哪里"]
|
||
}''';
|
||
|
||
test('accepts bounded writing feedback for its lesson', () {
|
||
final feedback = decodeWritingAiFeedback(
|
||
writing,
|
||
expectedLessonId: 'a0-06',
|
||
);
|
||
|
||
expect(feedback, isNotNull);
|
||
expect(feedback!.verdict, 'rewrite');
|
||
});
|
||
|
||
test('rejects writing feedback for a different lesson', () {
|
||
expect(decodeWritingAiFeedback(writing, expectedLessonId: 'a0-07'), isNull);
|
||
});
|
||
|
||
const lesson = '''{
|
||
"schemaVersion":"lesson-1",
|
||
"lessonId":"ai-a0-p12-1",
|
||
"revision":1,
|
||
"stageVersion":"A0-1.0",
|
||
"source":"aiGenerated",
|
||
"status":"validated",
|
||
"abilityIds":["A0-C06"],
|
||
"prerequisiteIds":["a0-05"],
|
||
"targetItemIds":["A0-P12"],
|
||
"receptiveChunks":[],
|
||
"newItemIds":[],
|
||
"previewItemIds":[],
|
||
"estimatedMinutes":10,
|
||
"tasks":[
|
||
{"taskId":"l","skill":"listening","type":"listenChoice","prompt":"听后选择。","stimulus":"I am from Hong Kong.","answer":"Hong Kong","targetItemIds":["A0-P12"]},
|
||
{"taskId":"s","skill":"speaking","type":"repeat","prompt":"跟读。","stimulus":"I am from Hong Kong.","answer":"I am from Hong Kong.","targetItemIds":["A0-P12"]},
|
||
{"taskId":"r","skill":"reading","type":"readAnswer","prompt":"读后回答。","stimulus":"I am from Hong Kong.","answer":"Hong Kong","targetItemIds":["A0-P12"]},
|
||
{"taskId":"w","skill":"writing","type":"writeAnswer","prompt":"写一句。","stimulus":"来自香港。","answer":"I am from Hong Kong.","targetItemIds":["A0-P12"]}
|
||
]
|
||
}''';
|
||
|
||
test('accepts a bounded four-skill adaptive lesson', () {
|
||
final decoded = decodeGeneratedLesson(
|
||
lesson,
|
||
expectedTargetItemId: 'A0-P12',
|
||
);
|
||
|
||
expect(decoded, isNotNull);
|
||
expect(decoded!.tasks.map((task) => task.skill).toSet(), hasLength(4));
|
||
});
|
||
|
||
test('adaptive answer check accepts harmless punctuation variation', () {
|
||
final task = decodeGeneratedLesson(
|
||
lesson,
|
||
expectedTargetItemId: 'A0-P12',
|
||
)!.tasks[3];
|
||
|
||
expect(matchesAdaptiveLessonAnswer(task, "i am from hong kong!"), isTrue);
|
||
});
|
||
|
||
test('adaptive answer check rejects a missing target token', () {
|
||
final task = decodeGeneratedLesson(
|
||
lesson,
|
||
expectedTargetItemId: 'A0-P12',
|
||
)!.tasks[3];
|
||
|
||
expect(matchesAdaptiveLessonAnswer(task, 'I am from Hong.'), isFalse);
|
||
});
|
||
|
||
test('adaptive answer spec accepts listed slots and rejects exclusions', () {
|
||
const task = GeneratedLessonTask(
|
||
taskId: 'speaking',
|
||
skill: 'speaking',
|
||
type: 'repeat',
|
||
prompt: '说一句。',
|
||
stimulus: 'I am from Hong Kong.',
|
||
answer: 'I am from Hong Kong.',
|
||
targetItemIds: ['A0-P12'],
|
||
answerSpec: GeneratedAnswerSpec(
|
||
requiredAnyPhrases: [
|
||
['I am', "I'm"],
|
||
['from'],
|
||
['Hong Kong'],
|
||
],
|
||
acceptedAnswers: ['I am from Hong Kong.', "I'm from Hong Kong."],
|
||
forbiddenPhrases: ['Beijing'],
|
||
),
|
||
);
|
||
|
||
expect(matchesAdaptiveLessonAnswer(task, "I'm from Hong Kong!"), isTrue);
|
||
expect(matchesAdaptiveLessonAnswer(task, 'I am from Beijing.'), isFalse);
|
||
expect(matchesAdaptiveLessonAnswer(task, 'Hong Kong.'), isFalse);
|
||
});
|
||
|
||
test('rejects an adaptive lesson with a different frozen target', () {
|
||
expect(
|
||
decodeGeneratedLesson(lesson, expectedTargetItemId: 'A0-P11'),
|
||
isNull,
|
||
);
|
||
});
|
||
|
||
test(
|
||
'rejects an adaptive lesson that introduces an unknown English word',
|
||
() {
|
||
final invalid = lesson.replaceFirst('Hong Kong', 'library');
|
||
|
||
expect(
|
||
decodeGeneratedLesson(invalid, expectedTargetItemId: 'A0-P12'),
|
||
isNull,
|
||
);
|
||
},
|
||
);
|
||
}
|