feat: 增加AI配置文件与TTS自动朗读,修复二级页面返回按钮与音频播放
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/ai_config.dart';
|
||||
import 'package:kouyu_english/core/ai_service.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('AiService resolveEndpointUri (/v1/chat/completions)', () {
|
||||
test('resolves OpenAI endpoint ending in /v1 to /v1/chat/completions', () {
|
||||
final uri = AiService.resolveEndpointUri(
|
||||
provider: AiProviderType.openAi,
|
||||
endpoint: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o-mini',
|
||||
);
|
||||
expect(uri.toString(), equals('https://api.openai.com/v1/chat/completions'));
|
||||
});
|
||||
|
||||
test('resolves OpenAI endpoint without /v1 to /v1/chat/completions', () {
|
||||
final uri = AiService.resolveEndpointUri(
|
||||
provider: AiProviderType.openAi,
|
||||
endpoint: 'https://api.openai.com',
|
||||
model: 'gpt-4o-mini',
|
||||
);
|
||||
expect(uri.toString(), equals('https://api.openai.com/v1/chat/completions'));
|
||||
});
|
||||
|
||||
test('resolves custom compatible endpoint to /v1/chat/completions', () {
|
||||
final uri = AiService.resolveEndpointUri(
|
||||
provider: AiProviderType.compatible,
|
||||
endpoint: 'https://codex.slcydia.fun/v1',
|
||||
model: 'gpt-4o-mini',
|
||||
);
|
||||
expect(uri.toString(), equals('https://codex.slcydia.fun/v1/chat/completions'));
|
||||
});
|
||||
|
||||
test('preserves endpoint already ending in /v1/chat/completions', () {
|
||||
final uri = AiService.resolveEndpointUri(
|
||||
provider: AiProviderType.compatible,
|
||||
endpoint: 'https://codex.slcydia.fun/v1/chat/completions',
|
||||
model: 'gpt-4o-mini',
|
||||
);
|
||||
expect(uri.toString(), equals('https://codex.slcydia.fun/v1/chat/completions'));
|
||||
});
|
||||
|
||||
test('converts /responses endpoint to /chat/completions', () {
|
||||
final uri = AiService.resolveEndpointUri(
|
||||
provider: AiProviderType.compatible,
|
||||
endpoint: 'https://codex.slcydia.fun/v1/responses',
|
||||
model: 'gpt-4o-mini',
|
||||
);
|
||||
expect(uri.toString(), equals('https://codex.slcydia.fun/v1/chat/completions'));
|
||||
});
|
||||
|
||||
test('resolves Gemini endpoint to :generateContent', () {
|
||||
final uri = AiService.resolveEndpointUri(
|
||||
provider: AiProviderType.gemini,
|
||||
endpoint: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
model: 'gemini-2.5-flash',
|
||||
);
|
||||
expect(
|
||||
uri.toString(),
|
||||
equals('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('AiConfigFile', () {
|
||||
test('parses standard JSON configuration correctly', () {
|
||||
const rawJson = '''{
|
||||
"provider": "compatible",
|
||||
"endpoint": "https://api.openai.com/v1",
|
||||
"model": "gpt-4o-mini",
|
||||
"apiKey": "sk-test-key-12345",
|
||||
"description": "Default AI configuration"
|
||||
}''';
|
||||
|
||||
final config = AiConfigFile.parse(rawJson);
|
||||
expect(config.provider, equals(AiProviderType.compatible));
|
||||
expect(config.endpoint, equals('https://api.openai.com/v1'));
|
||||
expect(config.model, equals('gpt-4o-mini'));
|
||||
expect(config.apiKey, equals('sk-test-key-12345'));
|
||||
expect(config.description, equals('Default AI configuration'));
|
||||
});
|
||||
|
||||
test('handles case-insensitive provider mapping and fallbacks', () {
|
||||
const openAiJson = '{"provider": "openAi", "endpoint": "https://api.openai.com/v1", "model": "gpt-4o"}';
|
||||
expect(AiConfigFile.parse(openAiJson).provider, equals(AiProviderType.openAi));
|
||||
|
||||
const geminiJson = '{"provider": "GEMINI", "endpoint": "https://generativelanguage.googleapis.com/v1beta", "model": "gemini-2.5-flash"}';
|
||||
expect(AiConfigFile.parse(geminiJson).provider, equals(AiProviderType.gemini));
|
||||
|
||||
const mockJson = '{"provider": "mock", "endpoint": "", "model": ""}';
|
||||
expect(AiConfigFile.parse(mockJson).provider, equals(AiProviderType.mock));
|
||||
|
||||
const unknownJson = '{"provider": "unknown_provider", "endpoint": "", "model": ""}';
|
||||
expect(AiConfigFile.parse(unknownJson).provider, equals(AiProviderType.compatible));
|
||||
});
|
||||
|
||||
test('serializes to JSON correctly', () {
|
||||
const config = AiConfigFile(
|
||||
provider: AiProviderType.compatible,
|
||||
endpoint: 'http://localhost:8000/v1',
|
||||
model: 'llama3',
|
||||
apiKey: 'sk-local',
|
||||
description: 'Local proxy',
|
||||
);
|
||||
final json = config.toJson();
|
||||
expect(json['provider'], equals('compatible'));
|
||||
expect(json['endpoint'], equals('http://localhost:8000/v1'));
|
||||
expect(json['model'], equals('llama3'));
|
||||
expect(json['apiKey'], equals('sk-local'));
|
||||
expect(json['description'], equals('Local proxy'));
|
||||
});
|
||||
});
|
||||
|
||||
group('AiService API Key Resolution', () {
|
||||
test('uses explicit key over fallback key', () async {
|
||||
AiService.instance.setFallbackApiKey('fallback-key');
|
||||
final key = await AiService.instance.resolveApiKey('explicit-override');
|
||||
expect(key, equals('explicit-override'));
|
||||
});
|
||||
|
||||
test('falls back to fallbackApiKey when no explicit or secure key is set', () async {
|
||||
AiService.instance.setFallbackApiKey('config-file-key');
|
||||
final key = await AiService.instance.resolveApiKey();
|
||||
expect(key, equals('config-file-key'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/app_state.dart';
|
||||
import 'package:kouyu_english/core/generated_content.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
import 'package:kouyu_english/features/dialogue/dialogue_flow.dart';
|
||||
import 'package:kouyu_english/features/review/review_page.dart';
|
||||
import 'package:kouyu_english/features/shell/learning_shell.dart';
|
||||
import 'package:kouyu_english/main.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
testWidgets('Onboarding PlacementPage back button returns to WelcomePage', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const KouyuEnglishApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('每天 20 分钟,\n说出能用的英语。'), findsOneWidget);
|
||||
await tester.tap(find.text('继续'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('从哪里开始?'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('每天 20 分钟,\n说出能用的英语。'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('SettingsPage back button returns to ProgressPage', (
|
||||
tester,
|
||||
) async {
|
||||
final state = AppState()..finishOnboarding();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: LearningShell(state: state)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Navigate to "我的" (progress tab)
|
||||
await tester.tap(find.text('我的'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final settingsButton = find.text('调整学习与 AI 设置');
|
||||
await tester.ensureVisible(settingsButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(settingsButton, findsOneWidget);
|
||||
await tester.tap(settingsButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// In SettingsPage
|
||||
expect(find.text('学习设置'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
|
||||
// Tap back button
|
||||
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('调整学习与 AI 设置'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('LessonFlow back button returns to tab', (tester) async {
|
||||
final state = AppState()..finishOnboarding();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: LearningShell(state: state)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('开始今天的学习'), findsOneWidget);
|
||||
await tester.tap(find.text('开始今天的学习'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// In LessonFlow
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
|
||||
// Tap back button
|
||||
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('开始今天的学习'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('DialogueScenePage from Home has back button and returns to Home', (
|
||||
tester,
|
||||
) async {
|
||||
final state = AppState()..finishOnboarding();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: LearningShell(state: state)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('开始情境对话'), findsOneWidget);
|
||||
await tester.tap(find.text('开始情境对话'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// In DialogueScenePage as secondary route
|
||||
expect(find.text('AI 情境对话'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('开始情境对话'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('DialogueSummaryPage has back button', (tester) async {
|
||||
var backCalled = false;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: DialogueSummaryPage(
|
||||
summary: const DialogueSummaryData(
|
||||
completedTasks: ['介绍姓名'],
|
||||
personalSentence: 'My name is Shen.',
|
||||
usedHelp: false,
|
||||
),
|
||||
onHome: () {},
|
||||
onLesson: () {},
|
||||
onRetry: () {},
|
||||
onBack: () => backCalled = true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('对话完成'), findsNWidgets(2)); // AppBar title and Eyebrow
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(backCalled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('AdaptiveLessonPage has back button in all states', (tester) async {
|
||||
final state = AppState()..finishOnboarding();
|
||||
|
||||
// 1. Empty state
|
||||
var finished = false;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: AdaptiveLessonPage(
|
||||
state: state,
|
||||
onFinished: () => finished = true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('AI 四技能补练'), findsNWidgets(2));
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||
expect(finished, isTrue);
|
||||
|
||||
// 2. Active lesson state
|
||||
state.cacheApprovedAdaptiveLesson(
|
||||
const GeneratedLesson(
|
||||
lessonId: 'adapt-1',
|
||||
revision: 1,
|
||||
stageVersion: 'A0',
|
||||
abilityIds: ['greeting'],
|
||||
prerequisiteIds: [],
|
||||
targetItemIds: ['name'],
|
||||
receptiveChunks: ['My name is Mia.'],
|
||||
previewItemIds: ['name'],
|
||||
estimatedMinutes: 5,
|
||||
tasks: [
|
||||
GeneratedLessonTask(
|
||||
taskId: 't1',
|
||||
skill: 'listening',
|
||||
type: 'listen',
|
||||
prompt: '听并写出名字',
|
||||
stimulus: 'My name is Mia.',
|
||||
answer: 'Mia',
|
||||
targetItemIds: ['name'],
|
||||
),
|
||||
],
|
||||
),
|
||||
auditor: 'test',
|
||||
);
|
||||
|
||||
finished = false;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: AdaptiveLessonPage(
|
||||
state: state,
|
||||
onFinished: () => finished = true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||
expect(finished, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('AssessmentPreparationPage and AssessmentPage have back buttons', (
|
||||
tester,
|
||||
) async {
|
||||
final state = AppState()..finishOnboarding();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: LearningShell(state: state)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Navigate to "我的"
|
||||
await tester.tap(find.text('我的'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Open assessment pack A0-E1
|
||||
final packAButton = find.text('开始 A0-E1');
|
||||
await tester.ensureVisible(packAButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(packAButton, findsOneWidget);
|
||||
await tester.tap(packAButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// In AssessmentPreparationPage
|
||||
expect(find.text('评估准备'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
|
||||
// Start assessment
|
||||
await tester.tap(find.text('开始评估'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// In AssessmentPage
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
|
||||
// Tap back button in AssessmentPage
|
||||
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Returns to Progress tab
|
||||
expect(find.text('A0 四技能评估'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user