feat: 应用名称更新为灵可析并增强 Chat 功能
- 更新 Android/iOS 应用名称和图标为灵可析 - Chat 支持取消正在运行的 Agent 对话 - 改进 ChatBloc 状态管理(区分发送/等待/流式/取消状态) - HomeScreen 支持外部注入 ChatBloc 和显示等待指示器 - 后端 Agent 运行服务优化(消息处理、usage 追踪) - 补充相关单元测试和 Widget 测试
This commit is contained in:
@@ -12,6 +12,15 @@ class MockAgUiService extends AgUiService {
|
||||
Future<void> sendMessage(String content) async {}
|
||||
}
|
||||
|
||||
class _ThrowingAgUiService extends AgUiService {
|
||||
_ThrowingAgUiService() : super(onEvent: (_) {});
|
||||
|
||||
@override
|
||||
Future<void> sendMessage(String content) async {
|
||||
throw StateError('network down');
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late ChatBloc chatBloc;
|
||||
late AgUiService service;
|
||||
@@ -29,6 +38,9 @@ void main() {
|
||||
test('initial state is empty', () {
|
||||
expect(chatBloc.state.items, isEmpty);
|
||||
expect(chatBloc.state.isLoading, false);
|
||||
expect(chatBloc.state.isSending, false);
|
||||
expect(chatBloc.state.isWaitingFirstToken, false);
|
||||
expect(chatBloc.state.isStreaming, false);
|
||||
expect(chatBloc.state.currentMessageId, isNull);
|
||||
expect(chatBloc.state.error, isNull);
|
||||
});
|
||||
@@ -40,6 +52,12 @@ void main() {
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((state) => state.items.length, 'items length', 1)
|
||||
.having((state) => state.isSending, 'isSending', true)
|
||||
.having(
|
||||
(state) => state.isWaitingFirstToken,
|
||||
'isWaitingFirstToken',
|
||||
true,
|
||||
)
|
||||
.having(
|
||||
(state) => state.items.first,
|
||||
'first item',
|
||||
@@ -56,15 +74,13 @@ void main() {
|
||||
'textMessageStart event adds AI message with streaming',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) {
|
||||
bloc.emit(chatBloc.state.copyWith(isLoading: true));
|
||||
bloc.emit(chatBloc.state.copyWith(isStreaming: true));
|
||||
service.onEvent(
|
||||
TextMessageStartEvent(messageId: 'msg_1', role: 'assistant'),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isLoading, 'isLoading', true)
|
||||
.having((s) => s.isLoading, 'isLoading', true),
|
||||
isA<ChatState>().having((s) => s.isStreaming, 'isStreaming', true),
|
||||
isA<ChatState>()
|
||||
.having((s) => s.items.length, 'items length', 1)
|
||||
.having((s) => s.currentMessageId, 'currentMessageId', 'msg_1')
|
||||
@@ -128,6 +144,7 @@ void main() {
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.currentMessageId, 'currentMessageId', isNull)
|
||||
.having((s) => s.isStreaming, 'isStreaming', false)
|
||||
.having(
|
||||
(s) => (s.items.first as TextMessageItem).isStreaming,
|
||||
'isStreaming',
|
||||
@@ -145,6 +162,7 @@ void main() {
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isLoading, 'isLoading', true)
|
||||
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', true)
|
||||
.having((s) => s.error, 'error', isNull),
|
||||
],
|
||||
);
|
||||
@@ -152,7 +170,7 @@ void main() {
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'runFinished sets isLoading to false',
|
||||
build: () => chatBloc,
|
||||
seed: () => const ChatState(isLoading: true),
|
||||
seed: () => const ChatState(isWaitingFirstToken: true),
|
||||
act: (bloc) {
|
||||
service.onEvent(RunFinishedEvent(threadId: 't1', runId: 'r1'));
|
||||
},
|
||||
@@ -166,7 +184,7 @@ void main() {
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'runError sets error message',
|
||||
build: () => chatBloc,
|
||||
seed: () => const ChatState(isLoading: true),
|
||||
seed: () => const ChatState(isWaitingFirstToken: true),
|
||||
act: (bloc) {
|
||||
service.onEvent(
|
||||
RunErrorEvent(message: 'Something went wrong', code: 'ERR'),
|
||||
@@ -175,10 +193,40 @@ void main() {
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isLoading, 'isLoading', false)
|
||||
.having((s) => s.currentMessageId, 'currentMessageId', isNull)
|
||||
.having((s) => s.error, 'error', 'Something went wrong'),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'cancelCurrentRun exits waiting states',
|
||||
build: () => chatBloc,
|
||||
seed: () => const ChatState(isWaitingFirstToken: true),
|
||||
act: (bloc) => bloc.cancelCurrentRun(),
|
||||
expect: () => [
|
||||
isA<ChatState>().having((s) => s.isCancelling, 'isCancelling', true),
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', false)
|
||||
.having((s) => s.isStreaming, 'isStreaming', false)
|
||||
.having((s) => s.isCancelling, 'isCancelling', false),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'sendMessage failure emits error and exits waiting state',
|
||||
build: () => ChatBloc(service: _ThrowingAgUiService()),
|
||||
act: (bloc) => bloc.sendMessage('hello'),
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isSending, 'isSending', true)
|
||||
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', true),
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isSending, 'isSending', false)
|
||||
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', false)
|
||||
.having((s) => s.error, 'error', contains('network down')),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'clearError removes error',
|
||||
build: () => chatBloc,
|
||||
|
||||
@@ -5,6 +5,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:social_app/core/api/api_exception.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';
|
||||
|
||||
@@ -29,7 +32,26 @@ class _FakeVoiceRecorder implements VoiceRecorder {
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
class _WaitingAgUiService extends AgUiService {
|
||||
_WaitingAgUiService() : super(onEvent: (_) {});
|
||||
|
||||
final Completer<void> _pending = Completer<void>();
|
||||
|
||||
@override
|
||||
Future<void> sendMessage(String content) async {
|
||||
onEvent(RunStartedEvent(threadId: 't1', runId: 'r1'));
|
||||
return _pending.future;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
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(
|
||||
@@ -79,8 +101,7 @@ void main() {
|
||||
|
||||
expect(fakeRecorder.started, true);
|
||||
expect(find.text('正在聆听...'), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.square), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.send), findsOneWidget);
|
||||
expect(_inputActionIcon(tester), LucideIcons.square);
|
||||
});
|
||||
|
||||
testWidgets('tap send while recording transcribes and auto sends message', (
|
||||
@@ -105,9 +126,9 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.mic));
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(LucideIcons.send));
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(sentTranscript, '语音自动发送');
|
||||
@@ -127,18 +148,19 @@ void main() {
|
||||
expect(filePath.endsWith('.wav'), true);
|
||||
return '语音转文字结果';
|
||||
},
|
||||
onAutoSendTranscript: (_) async {},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.mic));
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(LucideIcons.square));
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('语音识别中...'), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsAtLeastNWidgets(1));
|
||||
});
|
||||
|
||||
testWidgets('tap stop shows readable unauthorized message', (
|
||||
@@ -158,9 +180,9 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.mic));
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(LucideIcons.square));
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text('请重新登录'), findsOneWidget);
|
||||
@@ -182,9 +204,9 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.mic));
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(LucideIcons.square));
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text('未识别到有效语音,请靠近麦克风并连续说话后重试'), findsOneWidget);
|
||||
@@ -203,14 +225,15 @@ void main() {
|
||||
voiceRecorder: fakeRecorder,
|
||||
autoLoadHistory: false,
|
||||
onTranscribeAudio: (_) => completer.future,
|
||||
onAutoSendTranscript: (_) async {},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.mic));
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(LucideIcons.square));
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('语音识别中...'), findsOneWidget);
|
||||
@@ -237,7 +260,7 @@ void main() {
|
||||
);
|
||||
expect(editableBefore.widget.focusNode.hasFocus, isTrue);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.send));
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
|
||||
final editableAfter = tester.state<EditableTextState>(
|
||||
@@ -247,5 +270,33 @@ void main() {
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
});
|
||||
|
||||
testWidgets('shows stop icon and waiting indicator while waiting agent', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final chatBloc = ChatBloc(service: _WaitingAgUiService());
|
||||
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);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user