351 lines
12 KiB
Dart
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'},
|
|
);
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
}
|