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/core/courses/courses.dart'; import 'package:kouyu_english/features/lesson/lesson_flow.dart'; void main() { group('independentShortfall', () { test('is null when the attempt passes', () { expect(independentShortfall('a0-04-a', 'zero, one, two'), isNull); expect( independentShortfall('a0-01-a', "My name is Alex. Nice to meet you."), isNull, ); }); test('names the missing part of a single-segment task', () { final message = independentShortfall('a0-01-a', 'My name is Alex.'); expect(message, contains('nice to meet you')); expect(message, isNot(contains('my name is'))); }); test('counts the numbers still needed', () { expect( independentShortfall('a0-04-a', 'zero, one'), contains('目前用上 2 个'), ); }); test('lists the taught expressions of a split segment', () { expect( independentShortfall('a0-08-b', 'It is Monday.'), contains('friday'), ); }); }); group('assessmentAnswerGuide', () { final pack = a0AssessmentPacks.first; test('shows the correct choice', () { final task = pack.forSkill(AssessmentSkill.listening).first; expect(assessmentAnswerGuide(task, '买东西'), '正确答案:自我介绍'); expect(assessmentAnswerGuide(task, '自我介绍'), contains('没有播放音频')); }); test('explains open speaking and writing tasks', () { final speaking = pack.forSkill(AssessmentSkill.speaking)[3]; expect( assessmentAnswerGuide(speaking, 'my mother'), contains('This is my'), ); final writing = pack.forSkill(AssessmentSkill.writing).first; expect(assessmentAnswerGuide(writing, 'abc'), isNotEmpty); }); }); testWidgets('finishing step 6 after an AI correction counts as assisted', ( 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' ..activeLessonId = 'a0-01' ..lessonStep = LessonStep.independent; final segmentId = lessonById('a0-01').segments.first.id; final check = jsonEncode({ 'schemaVersion': 'writing-feedback-1', 'verdict': 'rewrite', 'feedback': '内容完整,有一个语法错误。', 'suggestion': 'My name is Alex. Nice to meet you. I am happy.', 'missing': ['I is 应为 I am'], 'lessonId': segmentId, }); final prompts = []; await http.runWithClient( () async { await tester.pumpWidget( MaterialApp( home: LessonFlow( state: state, onOpenDialogue: () {}, onFinish: () {}, ), ), ); await tester.pump(); expect(find.text('提交独立尝试'), findsOneWidget); await tester.enterText( find.byType(TextField), 'My name is Alex. Nice to meet you. I is happy.', ); await tester.pump(); final submit = find.text('提交独立尝试'); await tester.ensureVisible(submit); await tester.tap(submit); await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 50)), ); await tester.pump(); expect(find.text('· I is 应为 I am'), findsOneWidget); expect(state.lessonStep, LessonStep.independent); await tester.enterText( find.byType(TextField), 'My name is Alex. Nice to meet you. I am happy.', ); await tester.pump(); await tester.ensureVisible(submit); await tester.tap(submit); await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 50)), ); await tester.pump(); }, () => MockClient((request) async { prompts.add(request.body); final accepted = jsonEncode({ 'schemaVersion': 'writing-feedback-1', 'verdict': 'accepted', 'feedback': '表达正确。', 'suggestion': null, 'missing': [], 'lessonId': segmentId, }); return http.Response( jsonEncode({ 'choices': [ { 'message': {'content': prompts.length == 1 ? check : accepted}, }, ], }), 200, headers: {'content-type': 'application/json; charset=utf-8'}, ); }), ); expect(prompts, hasLength(2)); expect(prompts.first, contains('I is happy')); expect(state.lessonStep, LessonStep.complete); expect(state.independentAttemptAssisted, isTrue); }); testWidgets('writing submission runs AI check without a separate button', ( tester, ) async { AiService.instance.setFallbackApiKey('test-key'); final state = AppState() ..aiProvider = AiProviderType.compatible ..aiEndpoint = 'https://api.deepseek.com' ..aiModel = 'deepseek-flash' ..lessonStep = LessonStep.writing; var requests = 0; await http.runWithClient( () async { await tester.pumpWidget( MaterialApp( home: LessonFlow( state: state, onOpenDialogue: () {}, onFinish: () {}, ), ), ); await tester.tap(find.text('收起示例,自己试一次')); await tester.pump(); await tester.enterText(find.byType(TextField), 'Hello. I am Alex.'); await tester.pump(); expect(find.text('获取 AI 写作建议(可选)'), findsNothing); final submit = find.text('提交并继续'); await tester.ensureVisible(submit); await tester.tap(submit); await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 50)), ); await tester.pump(); }, () => MockClient((request) async { requests++; return http.Response( jsonEncode({ 'choices': [ { 'message': { 'content': jsonEncode({ 'schemaVersion': 'writing-feedback-1', 'verdict': 'accepted', 'feedback': '表达正确。', 'suggestion': null, 'missing': [], 'lessonId': 'a0-01', }), }, }, ], }), 200, headers: {'content-type': 'application/json; charset=utf-8'}, ); }), ); expect(requests, 1); expect(state.lessonStep, LessonStep.dialogue); expect( state.attemptEvidence .where((entry) => entry.taskId == 'lesson-a0-01-a-writing') .every((entry) => entry.outcome == EvidenceKind.assisted), isTrue, ); }); testWidgets('a locally matching bad sentence waits for AI correction', ( tester, ) async { AiService.instance.setFallbackApiKey('test-key'); final state = AppState() ..aiProvider = AiProviderType.compatible ..aiEndpoint = 'https://api.deepseek.com' ..aiModel = 'deepseek-flash' ..lessonStep = LessonStep.writing; await http.runWithClient( () async { await tester.pumpWidget( MaterialApp( home: LessonFlow( state: state, onOpenDialogue: () {}, onFinish: () {}, ), ), ); await tester.enterText(find.byType(TextField), 'Hello. I am is Alex.'); await tester.pump(); final submit = find.text('提交并继续'); await tester.ensureVisible(submit); await tester.tap(submit); await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 50)), ); await tester.pump(); }, () => MockClient( (_) async => http.Response( jsonEncode({ 'choices': [ { 'message': { 'content': jsonEncode({ 'schemaVersion': 'writing-feedback-1', 'verdict': 'rewrite', 'feedback': '这里多了 is。', 'suggestion': 'Hello. I am Alex.', 'missing': ['删去多余的 is'], 'lessonId': 'a0-01', }), }, }, ], }), 200, headers: {'content-type': 'application/json; charset=utf-8'}, ), ), ); expect(state.lessonStep, LessonStep.writing); expect(find.text('AI 建议:这里多了 is。'), findsOneWidget); expect(find.text('请按自己的意思修改,再提交检查。看过改正后,本次只算辅助练习。'), findsOneWidget); }); testWidgets('independent submission without AI stays unverified', ( tester, ) async { final state = AppState()..lessonStep = LessonStep.independent; await tester.pumpWidget( MaterialApp( home: LessonFlow(state: state, onOpenDialogue: () {}, onFinish: () {}), ), ); await tester.enterText( find.byType(TextField), 'My name is Alex. Nice to meet you.', ); await tester.pump(); final submit = find.text('提交独立尝试'); await tester.ensureVisible(submit); await tester.tap(submit); await tester.pump(); expect(state.lessonStep, LessonStep.complete); expect(state.independentAttemptPending, isTrue); expect( state.attemptEvidence .where((entry) => entry.taskId == 'lesson-a0-01-a-independent') .every((entry) => entry.outcome == EvidenceKind.pending), isTrue, ); expect(find.textContaining('未经 AI 核验'), findsOneWidget); }); test('unverified completion survives a local snapshot', () { final first = AppState() ..completeIndependentAttempt( assisted: false, verified: false, rawAnswer: 'My name is Alex. Nice to meet you.', ); final resumed = AppState()..restoreForTest(first.snapshotForTest()); expect(resumed.independentAttemptPending, isTrue); expect(resumed.independentAttemptComplete, isTrue); expect( resumed.attemptEvidence .where((entry) => entry.taskId == 'lesson-a0-01-a-independent') .every((entry) => entry.outcome == EvidenceKind.pending), isTrue, ); }); testWidgets('AI outage offers an unverified continuation', (tester) async { AiService.instance.setFallbackApiKey('test-key'); final state = AppState() ..aiProvider = AiProviderType.compatible ..aiEndpoint = 'https://api.deepseek.com' ..aiModel = 'deepseek-flash' ..lessonStep = LessonStep.independent; await http.runWithClient(() async { await tester.pumpWidget( MaterialApp( home: LessonFlow( state: state, onOpenDialogue: () {}, onFinish: () {}, ), ), ); await tester.enterText( find.byType(TextField), 'My name is Alex. Nice to meet you.', ); await tester.pump(); final submit = find.text('提交独立尝试'); await tester.ensureVisible(submit); await tester.tap(submit); await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 50)), ); await tester.pump(); expect(state.lessonStep, LessonStep.independent); final fallback = find.text('未核验,保存练习并继续'); await tester.ensureVisible(fallback); await tester.tap(fallback); await tester.pump(); }, () => MockClient((_) async => http.Response('{}', 503))); expect(state.lessonStep, LessonStep.complete); expect(state.independentAttemptPending, isTrue); }); }