Files
English/kouyu_english/test/dialogue_ai_intervention_test.dart
T

351 lines
12 KiB
Dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
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/app_state.dart';
import 'package:kouyu_english/core/models.dart';
import 'package:kouyu_english/features/dialogue/dialogue_flow.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('AiService.checkDialogueIntervention', () {
test('parses accepted semantic match cleanly', () async {
AiService.instance.setFallbackApiKey('test-key');
final result = await http.runWithClient(
() => AiService.instance.checkDialogueIntervention(
provider: AiProviderType.compatible,
endpoint: 'https://api.deepseek.com',
model: 'deepseek-flash',
partnerLine: 'How are you today, Shen?',
taskLabel: '表达状态或喜好',
learnerText: 'I feel wonderful today.',
),
() => MockClient((request) async {
return http.Response(
jsonEncode({
'choices': [
{
'message': {
'content': jsonEncode({
'accepted': true,
'suggestion': null,
'explanation': '表达非常自然,符合本轮交流目标。',
}),
},
},
],
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}),
);
expect(result, isNotNull);
expect(result!.accepted, isTrue);
expect(result.suggestion, isNull);
expect(result.explanation, contains('表达非常自然'));
});
test(
'parses ASR acoustic slip (e.g. third -> tired) with suggestion',
() async {
AiService.instance.setFallbackApiKey('test-key');
final result = await http.runWithClient(
() => AiService.instance.checkDialogueIntervention(
provider: AiProviderType.compatible,
endpoint: 'https://api.deepseek.com',
model: 'deepseek-flash',
partnerLine: 'How are you today, Shen?',
taskLabel: '表达状态或喜好',
learnerText: "I'm third today.",
hint: "I'm good, thanks. / I like coffee.",
),
() => MockClient((request) async {
return http.Response(
jsonEncode({
'choices': [
{
'message': {
'content': jsonEncode({
'accepted': false,
'suggestion': "I'm tired today.",
'explanation': '识别为 third,你可能是想表达 tired(今天很累)吗?',
}),
},
},
],
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}),
);
expect(result, isNotNull);
expect(result!.accepted, isFalse);
expect(result.suggestion, "I'm tired today.");
expect(result.explanation, contains('tired'));
},
);
});
group('DialoguePage AI Intervention Flow', () {
testWidgets(
'shows intervention card when input has ASR slip and allows one-click fix and send',
(tester) async {
final messenger = tester.binding.defaultBinaryMessenger;
for (final name in const [
'com.llfbandit.record/messages',
'xyz.luan/audioplayers',
'xyz.luan/audioplayers.global',
]) {
messenger.setMockMethodCallHandler(
MethodChannel(name),
(_) async => null,
);
}
messenger.setMockStreamHandler(
const EventChannel('xyz.luan/audioplayers.global/events'),
MockStreamHandler.inline(onListen: (_, _) {}),
);
final reportError = FlutterError.onError;
FlutterError.onError = (details) {
if (details.exception is! MissingPluginException) {
reportError?.call(details);
}
};
addTearDown(() => FlutterError.onError = reportError);
AiService.instance.setFallbackApiKey('test-key');
final state = AppState()
..aiProvider = AiProviderType.compatible
..aiEndpoint = 'https://api.deepseek.com'
..aiModel = 'deepseek-flash';
final requests = <String>[];
final interventionJson = jsonEncode({
'accepted': false,
'suggestion': "I'm tired today.",
'explanation': '语音识别为 third,你可能想表达的是 tired(今天很累)哦。',
});
final replyJson = jsonEncode({
'reply': 'Oh, take a good rest today!',
'translation': '噢,今天好好休息一下吧!',
'feedback': null,
});
await http.runWithClient(
() async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DialoguePage(state: state, onFinished: (_) {}),
),
),
);
await tester.pump();
// Stage 0: meet -> "What's your name?" -> answer "My name is Alex."
await tester.enterText(find.byType(TextField), 'My name is Alex.');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
// Stage 1: meet -> "Where are you from?" -> answer "I'm from Beijing."
await tester.enterText(find.byType(TextField), "I'm from Beijing.");
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
// Stage 2: meet -> "How are you today?"
// Enter "I'm third today." which fails local preset regex
await tester.enterText(find.byType(TextField), "I'm third today.");
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
// Expect AI Intervention Card to appear
expect(find.text('AI 助手干预与建议'), findsOneWidget);
expect(find.textContaining('语音识别为 third'), findsOneWidget);
expect(find.text('建议表达:'), findsOneWidget);
expect(find.text("I'm tired today."), findsOneWidget);
// Tap "修正为 ... 并发送"
final fixButton = find.textContaining('修正为');
expect(fixButton, findsOneWidget);
await tester.ensureVisible(fixButton);
await tester.tap(fixButton);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
// Turns now should include the fixed line and Mia's reply
expect(find.text("I'm tired today."), findsOneWidget);
expect(
find.text('Oh, take a good rest today!'),
findsAtLeastNWidgets(1),
);
},
() => MockClient((request) async {
requests.add(request.body);
if (request.body.contains('ASR') ||
request.body.contains('oral English coach')) {
return http.Response(
jsonEncode({
'choices': [
{
'message': {'content': interventionJson},
},
],
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}
return http.Response(
jsonEncode({
'choices': [
{
'message': {'content': replyJson},
},
],
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}),
);
},
);
testWidgets(
'automatically proceeds when AI evaluates alternative reply as semantically acceptable',
(tester) async {
final messenger = tester.binding.defaultBinaryMessenger;
for (final name in const [
'com.llfbandit.record/messages',
'xyz.luan/audioplayers',
'xyz.luan/audioplayers.global',
]) {
messenger.setMockMethodCallHandler(
MethodChannel(name),
(_) async => null,
);
}
messenger.setMockStreamHandler(
const EventChannel('xyz.luan/audioplayers.global/events'),
MockStreamHandler.inline(onListen: (_, _) {}),
);
AiService.instance.setFallbackApiKey('test-key');
final state = AppState()
..aiProvider = AiProviderType.compatible
..aiEndpoint = 'https://api.deepseek.com'
..aiModel = 'deepseek-flash';
final acceptJson = jsonEncode({
'accepted': true,
'suggestion': null,
'explanation': '回答自然得体,符合交流目标。',
});
final replyJson = jsonEncode({
'reply': 'Wonderful to hear that!',
'translation': '很高兴听到这个!',
'feedback': null,
});
await http.runWithClient(
() async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DialoguePage(state: state, onFinished: (_) {}),
),
),
);
await tester.pump();
// Stage 0: "What's your name?"
await tester.enterText(find.byType(TextField), 'My name is Alex.');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
// Stage 1: "Where are you from?"
await tester.enterText(find.byType(TextField), "I'm from Beijing.");
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
// Stage 2: "How are you today?"
// Enter alternative expression "I feel wonderful today." which is NOT in preset accept list
await tester.enterText(
find.byType(TextField),
"I feel wonderful today.",
);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
// Turns now should directly include "I feel wonderful today." and Mia's reply without error
expect(find.text("I feel wonderful today."), findsOneWidget);
expect(
find.text('Wonderful to hear that!'),
findsAtLeastNWidgets(1),
);
expect(find.textContaining('这一轮要'), findsNothing);
expect(state.sceneDialogueDraft?.usedHelp, isFalse);
},
() => MockClient((request) async {
if (request.body.contains('ASR') ||
request.body.contains('oral English coach')) {
return http.Response(
jsonEncode({
'choices': [
{
'message': {'content': acceptJson},
},
],
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}
return http.Response(
jsonEncode({
'choices': [
{
'message': {'content': replyJson},
},
],
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}),
);
},
);
});
}