Files
English/kouyu_english/test/generated_content_test.dart
T
shenleiandClaude Opus 5 b454e1856c fix: AI 复习变式请求字段与校验一致;转写后删除不保留的录音
- generateReviewVariant 的指令要求 stimulus/answer/acceptedAnswers 等字段,
  而 decodeGeneratedReviewVariant 只接受 schemaVersion、variantId、
  targetItemId、prompt、expectedAnswer 五个字段,导致 AI 变式始终校验失败。
  现在指令只要求这五个字段,并补充了请求与解码一致性的测试。
- finishVoiceInput(keepAudio: false) 转写完成(或页面已关闭)后删除音频,
  补练页和测评页的语音输入不再在本机残留录音文件。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:29:56 +09:00

209 lines
6.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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":"Im 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,
);
},
);
}