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 = []; 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.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.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.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.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.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.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.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'}, ); }), ); }, ); }); }