refactor: 移除前端 Mock API,新增共享组件,优化认证流程
- 删除 mock_api_client、mock_calendar_service、mock_history_service - 新增 fixed_length_code_input、link_button、message_composer 共享组件 - 优化登录/注册/密码重置页面使用新组件 - 简化 injection.dart 移除 mock 分支 - 更新 env.dart 配置(BACKEND_URL 替换 API_URL) - 后端 agentscope 工具和测试更新 - 重构 AGENTS.md 文档结构 - 新增 deploy/ 目录和 protocol 文档
This commit is contained in:
@@ -1,323 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:social_app/core/api/api_exception.dart';
|
||||
import 'package:social_app/core/api/mock_api_client.dart';
|
||||
import 'package:social_app/core/di/injection.dart';
|
||||
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
|
||||
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
|
||||
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
|
||||
import 'package:social_app/features/home/data/voice_recorder.dart';
|
||||
import 'package:social_app/features/home/ui/screens/home_screen.dart';
|
||||
import 'package:social_app/features/messages/data/inbox_api.dart';
|
||||
|
||||
class _FakeVoiceRecorder implements VoiceRecorder {
|
||||
bool started = false;
|
||||
String? stoppedPath;
|
||||
|
||||
@override
|
||||
Future<void> start() async {
|
||||
started = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> stop() async {
|
||||
started = false;
|
||||
stoppedPath ??=
|
||||
'${Directory.systemTemp.path}/test-audio-${DateTime.now().microsecondsSinceEpoch}.wav';
|
||||
return stoppedPath;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
class _WaitingAgUiService extends AgUiService {
|
||||
_WaitingAgUiService() : super(onEvent: (_) {});
|
||||
|
||||
final Completer<void> _pending = Completer<void>();
|
||||
|
||||
@override
|
||||
Future<void> sendMessage(String content, {List<XFile>? images}) async {
|
||||
onEvent(RunStartedEvent(threadId: 't1', runId: 'r1'));
|
||||
return _pending.future;
|
||||
}
|
||||
|
||||
void emitStepStarted(String stepName) {
|
||||
onEvent(StepStartedEvent(stepName: stepName));
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
if (!sl.isRegistered<InboxApi>()) {
|
||||
sl.registerSingleton<InboxApi>(InboxApi(MockApiClient()));
|
||||
}
|
||||
});
|
||||
|
||||
IconData _inputActionIcon(WidgetTester tester) {
|
||||
final icon = tester.widget<Icon>(
|
||||
find.byKey(const ValueKey('home_input_action_icon')),
|
||||
);
|
||||
return icon.icon!;
|
||||
}
|
||||
|
||||
group('HomeScreen Widget Tests', () {
|
||||
testWidgets('displays input field', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('输入消息...'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays header icons', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(LucideIcons.settings), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.calendar), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.messageSquare), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays send or mic icon based on input', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(LucideIcons.mic), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tap mic starts recording and shows listening state', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final fakeRecorder = _FakeVoiceRecorder();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomeScreen(voiceRecorder: fakeRecorder, autoLoadHistory: false),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.mic));
|
||||
await tester.pump();
|
||||
|
||||
expect(fakeRecorder.started, true);
|
||||
expect(find.text('正在聆听...'), findsOneWidget);
|
||||
expect(_inputActionIcon(tester), LucideIcons.send);
|
||||
});
|
||||
|
||||
testWidgets('tap send while recording transcribes and auto sends message', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final fakeRecorder = _FakeVoiceRecorder();
|
||||
String? sentTranscript;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomeScreen(
|
||||
voiceRecorder: fakeRecorder,
|
||||
autoLoadHistory: false,
|
||||
onTranscribeAudio: (filePath) async {
|
||||
expect(filePath.endsWith('.wav'), true);
|
||||
return '语音自动发送';
|
||||
},
|
||||
onAutoSendTranscript: (transcript) async {
|
||||
sentTranscript = transcript;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(sentTranscript, '语音自动发送');
|
||||
expect(find.byIcon(LucideIcons.plus), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tap stop enters transcribing state', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final fakeRecorder = _FakeVoiceRecorder();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomeScreen(
|
||||
voiceRecorder: fakeRecorder,
|
||||
autoLoadHistory: false,
|
||||
onTranscribeAudio: (filePath) async {
|
||||
expect(filePath.endsWith('.wav'), true);
|
||||
return '语音转文字结果';
|
||||
},
|
||||
onAutoSendTranscript: (_) async {},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('语音识别中...'), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsAtLeastNWidgets(1));
|
||||
});
|
||||
|
||||
testWidgets('tap stop shows readable unauthorized message', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final fakeRecorder = _FakeVoiceRecorder();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomeScreen(
|
||||
voiceRecorder: fakeRecorder,
|
||||
autoLoadHistory: false,
|
||||
onTranscribeAudio: (_) async {
|
||||
throw const UnauthorizedException();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text('请重新登录'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
});
|
||||
|
||||
testWidgets('tap stop shows message when transcript is empty', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final fakeRecorder = _FakeVoiceRecorder();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomeScreen(
|
||||
voiceRecorder: fakeRecorder,
|
||||
autoLoadHistory: false,
|
||||
onTranscribeAudio: (_) async => '',
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text('未识别到有效语音,请靠近麦克风并连续说话后重试'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
});
|
||||
|
||||
testWidgets('shows transcribing indicator while waiting ASR result', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final fakeRecorder = _FakeVoiceRecorder();
|
||||
final completer = Completer<String>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomeScreen(
|
||||
voiceRecorder: fakeRecorder,
|
||||
autoLoadHistory: false,
|
||||
onTranscribeAudio: (_) => completer.future,
|
||||
onAutoSendTranscript: (_) async {},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('语音识别中...'), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
completer.complete('识别完成');
|
||||
});
|
||||
|
||||
testWidgets('tap send unfocuses text input after sending', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(TextField));
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(TextField), 'hello');
|
||||
await tester.pump();
|
||||
|
||||
final editableBefore = tester.state<EditableTextState>(
|
||||
find.byType(EditableText),
|
||||
);
|
||||
expect(editableBefore.widget.focusNode.hasFocus, isTrue);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
|
||||
final editableAfter = tester.state<EditableTextState>(
|
||||
find.byType(EditableText),
|
||||
);
|
||||
expect(editableAfter.widget.focusNode.hasFocus, isFalse);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
});
|
||||
|
||||
testWidgets('shows stop icon and waiting indicator while waiting agent', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final waitingService = _WaitingAgUiService();
|
||||
final chatBloc = ChatBloc(service: waitingService);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomeScreen(autoLoadHistory: false, chatBloc: chatBloc),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'hello');
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
|
||||
expect(_inputActionIcon(tester), LucideIcons.square);
|
||||
expect(find.text('正在思考...'), findsOneWidget);
|
||||
|
||||
waitingService.emitStepStarted('intent');
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('意图识别中'), findsOneWidget);
|
||||
expect(find.text('正在思考...'), findsNothing);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('已停止等待回复'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
|
||||
await chatBloc.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:social_app/shared/widgets/message_composer.dart';
|
||||
|
||||
Widget _buildTestApp({
|
||||
required MessageComposerMode mode,
|
||||
required MessageComposerProcess process,
|
||||
required bool hasMessage,
|
||||
required bool isWaitingAgent,
|
||||
VoidCallback? onHoldStart,
|
||||
VoidCallback? onHoldEnd,
|
||||
VoidCallback? onHoldCancel,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MessageComposer(
|
||||
mode: mode,
|
||||
process: process,
|
||||
hasMessage: hasMessage,
|
||||
isWaitingAgent: isWaitingAgent,
|
||||
iconSize: 24,
|
||||
composerMinHeight: 48,
|
||||
onTapPlus: () {},
|
||||
onTapRightAction: () {},
|
||||
onHoldToSpeakStart: onHoldStart ?? () {},
|
||||
onHoldToSpeakEnd: onHoldEnd ?? () {},
|
||||
onHoldToSpeakMoveUpdate: (_) {},
|
||||
onHoldToSpeakCancel: onHoldCancel ?? () {},
|
||||
textInputChild: const SizedBox.shrink(),
|
||||
recordingAnimation: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('MessageComposer', () {
|
||||
testWidgets('renders one unified rounded composer container', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
mode: MessageComposerMode.text,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: false,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byKey(messageComposerContainerKey), findsOneWidget);
|
||||
|
||||
final containerFinder = find.byKey(messageComposerContainerKey);
|
||||
final plusFinder = find.byKey(messageComposerPlusButtonKey);
|
||||
final rightFinder = find.byKey(messageComposerRightButtonKey);
|
||||
|
||||
expect(
|
||||
find.descendant(of: containerFinder, matching: plusFinder),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: containerFinder, matching: rightFinder),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('right action icon follows state priority', (tester) async {
|
||||
Future<IconData> rightIconFor({
|
||||
required MessageComposerMode mode,
|
||||
required MessageComposerProcess process,
|
||||
required bool hasMessage,
|
||||
required bool isWaitingAgent,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
mode: mode,
|
||||
process: process,
|
||||
hasMessage: hasMessage,
|
||||
isWaitingAgent: isWaitingAgent,
|
||||
),
|
||||
);
|
||||
|
||||
final iconFinder = find.descendant(
|
||||
of: find.byKey(messageComposerRightButtonKey),
|
||||
matching: find.byType(Icon),
|
||||
);
|
||||
final iconWidget = tester.widget<Icon>(iconFinder.first);
|
||||
return iconWidget.icon!;
|
||||
}
|
||||
|
||||
expect(
|
||||
await rightIconFor(
|
||||
mode: MessageComposerMode.text,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: true,
|
||||
),
|
||||
LucideIcons.square,
|
||||
);
|
||||
|
||||
expect(
|
||||
await rightIconFor(
|
||||
mode: MessageComposerMode.holdToSpeak,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: true,
|
||||
isWaitingAgent: false,
|
||||
),
|
||||
LucideIcons.send,
|
||||
);
|
||||
|
||||
expect(
|
||||
await rightIconFor(
|
||||
mode: MessageComposerMode.holdToSpeak,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: false,
|
||||
),
|
||||
LucideIcons.keyboard,
|
||||
);
|
||||
|
||||
expect(
|
||||
await rightIconFor(
|
||||
mode: MessageComposerMode.text,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: false,
|
||||
),
|
||||
LucideIcons.mic,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('recording hint appears only while recording', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
mode: MessageComposerMode.holdToSpeak,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: false,
|
||||
),
|
||||
);
|
||||
expect(find.byKey(messageComposerRecordingHintKey), findsNothing);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
mode: MessageComposerMode.holdToSpeak,
|
||||
process: MessageComposerProcess.recording,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: false,
|
||||
),
|
||||
);
|
||||
expect(find.byKey(messageComposerRecordingHintKey), findsOneWidget);
|
||||
expect(find.text('松开发送,上滑取消'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('composer height remains stable across mode switches', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
mode: MessageComposerMode.text,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: false,
|
||||
),
|
||||
);
|
||||
final textHeight = tester.getSize(
|
||||
find.byKey(messageComposerContainerKey),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
mode: MessageComposerMode.holdToSpeak,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: false,
|
||||
),
|
||||
);
|
||||
final holdHeight = tester.getSize(
|
||||
find.byKey(messageComposerContainerKey),
|
||||
);
|
||||
|
||||
expect(textHeight.height, holdHeight.height);
|
||||
});
|
||||
|
||||
testWidgets('invokes long press start/end callbacks in hold mode', (
|
||||
tester,
|
||||
) async {
|
||||
var started = false;
|
||||
var ended = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
mode: MessageComposerMode.holdToSpeak,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: false,
|
||||
onHoldStart: () => started = true,
|
||||
onHoldEnd: () => ended = true,
|
||||
),
|
||||
);
|
||||
|
||||
final center = tester.getCenter(find.byKey(messageComposerHoldAreaKey));
|
||||
final gesture = await tester.startGesture(center);
|
||||
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 10));
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
|
||||
expect(started, isTrue);
|
||||
expect(ended, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('invokes long press cancel callback when gesture canceled', (
|
||||
tester,
|
||||
) async {
|
||||
var canceled = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
mode: MessageComposerMode.holdToSpeak,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: false,
|
||||
onHoldCancel: () => canceled = true,
|
||||
),
|
||||
);
|
||||
|
||||
final center = tester.getCenter(find.byKey(messageComposerHoldAreaKey));
|
||||
final gesture = await tester.startGesture(center);
|
||||
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 10));
|
||||
await gesture.cancel();
|
||||
await tester.pump();
|
||||
|
||||
expect(canceled, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user