7b8865e256
- 新增 stepStarted/stepFinished 事件类型支持 - 前端实现图片附件上传和预览功能 - 后端增强工具结果存储和事件处理 - 完善相关单元测试和集成测试
411 lines
12 KiB
Dart
411 lines
12 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:bloc_test/bloc_test.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
|
|
import 'package:social_app/features/chat/data/models/chat_list_item.dart';
|
|
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
|
|
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
|
|
|
|
class MockAgUiService extends AgUiService {
|
|
MockAgUiService() : super(onEvent: (_) {});
|
|
|
|
int previewCalls = 0;
|
|
|
|
@override
|
|
Future<void> sendMessage(String content, {List<XFile>? images}) async {}
|
|
|
|
@override
|
|
Future<Uint8List> fetchAttachmentPreview(String previewPath) async {
|
|
previewCalls += 1;
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
return Uint8List.fromList(<int>[1, 2, 3]);
|
|
}
|
|
}
|
|
|
|
class _ThrowingAgUiService extends AgUiService {
|
|
_ThrowingAgUiService() : super(onEvent: (_) {});
|
|
|
|
@override
|
|
Future<void> sendMessage(String content, {List<XFile>? images}) async {
|
|
throw StateError('network down');
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
late ChatBloc chatBloc;
|
|
late AgUiService service;
|
|
|
|
setUp(() {
|
|
service = MockAgUiService();
|
|
chatBloc = ChatBloc(service: service);
|
|
});
|
|
|
|
tearDown(() {
|
|
chatBloc.close();
|
|
});
|
|
|
|
group('ChatBloc', () {
|
|
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);
|
|
});
|
|
|
|
blocTest<ChatBloc, ChatState>(
|
|
'sendMessage adds user message to items',
|
|
build: () => chatBloc,
|
|
act: (bloc) => bloc.sendMessage('Hello'),
|
|
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',
|
|
isA<TextMessageItem>().having(
|
|
(item) => item.content,
|
|
'content',
|
|
'Hello',
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
blocTest<ChatBloc, ChatState>(
|
|
'textMessageStart event adds AI message with streaming',
|
|
build: () => chatBloc,
|
|
act: (bloc) {
|
|
bloc.emit(chatBloc.state.copyWith(isStreaming: true));
|
|
service.onEvent(
|
|
TextMessageStartEvent(messageId: 'msg_1', role: 'assistant'),
|
|
);
|
|
},
|
|
expect: () => [
|
|
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')
|
|
.having(
|
|
(s) => s.items.first,
|
|
'first item',
|
|
isA<TextMessageItem>()
|
|
.having((item) => item.isStreaming, 'isStreaming', true)
|
|
.having((item) => item.sender, 'sender', MessageSender.ai),
|
|
),
|
|
],
|
|
);
|
|
|
|
blocTest<ChatBloc, ChatState>(
|
|
'textMessageContent event appends content',
|
|
build: () => chatBloc,
|
|
seed: () => ChatState(
|
|
items: [
|
|
TextMessageItem(
|
|
id: 'msg_1',
|
|
content: '',
|
|
timestamp: DateTime.now(),
|
|
sender: MessageSender.ai,
|
|
isStreaming: true,
|
|
),
|
|
],
|
|
currentMessageId: 'msg_1',
|
|
),
|
|
act: (bloc) {
|
|
service.onEvent(
|
|
TextMessageContentEvent(messageId: 'msg_1', delta: 'Hello'),
|
|
);
|
|
},
|
|
expect: () => [
|
|
isA<ChatState>().having(
|
|
(s) => (s.items.first as TextMessageItem).content,
|
|
'content',
|
|
'Hello',
|
|
),
|
|
],
|
|
);
|
|
|
|
blocTest<ChatBloc, ChatState>(
|
|
'textMessageEnd event sets isStreaming to false',
|
|
build: () => chatBloc,
|
|
seed: () => ChatState(
|
|
items: [
|
|
TextMessageItem(
|
|
id: 'msg_1',
|
|
content: 'Hello World',
|
|
timestamp: DateTime.now(),
|
|
sender: MessageSender.ai,
|
|
isStreaming: true,
|
|
),
|
|
],
|
|
currentMessageId: 'msg_1',
|
|
),
|
|
act: (bloc) {
|
|
service.onEvent(TextMessageEndEvent(messageId: 'msg_1'));
|
|
},
|
|
expect: () => [
|
|
isA<ChatState>()
|
|
.having((s) => s.currentMessageId, 'currentMessageId', isNull)
|
|
.having((s) => s.isStreaming, 'isStreaming', false)
|
|
.having(
|
|
(s) => (s.items.first as TextMessageItem).isStreaming,
|
|
'isStreaming',
|
|
false,
|
|
),
|
|
],
|
|
);
|
|
|
|
blocTest<ChatBloc, ChatState>(
|
|
'runStarted sets isLoading to true',
|
|
build: () => chatBloc,
|
|
act: (bloc) {
|
|
service.onEvent(RunStartedEvent(threadId: 't1', runId: 'r1'));
|
|
},
|
|
expect: () => [
|
|
isA<ChatState>()
|
|
.having((s) => s.isLoading, 'isLoading', true)
|
|
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', true)
|
|
.having((s) => s.error, 'error', isNull),
|
|
],
|
|
);
|
|
|
|
blocTest<ChatBloc, ChatState>(
|
|
'runFinished sets isLoading to false',
|
|
build: () => chatBloc,
|
|
seed: () => const ChatState(isWaitingFirstToken: true),
|
|
act: (bloc) {
|
|
service.onEvent(RunFinishedEvent(threadId: 't1', runId: 'r1'));
|
|
},
|
|
expect: () => [
|
|
isA<ChatState>()
|
|
.having((s) => s.isLoading, 'isLoading', false)
|
|
.having((s) => s.currentMessageId, 'currentMessageId', isNull),
|
|
],
|
|
);
|
|
|
|
blocTest<ChatBloc, ChatState>(
|
|
'step events update currentStage',
|
|
build: () => chatBloc,
|
|
act: (bloc) {
|
|
service.onEvent(StepStartedEvent(stepName: 'execution'));
|
|
service.onEvent(StepFinishedEvent(stepName: 'execution'));
|
|
},
|
|
expect: () => [
|
|
isA<ChatState>().having(
|
|
(s) => s.currentStage,
|
|
'currentStage',
|
|
AgentStage.execution,
|
|
),
|
|
isA<ChatState>().having((s) => s.currentStage, 'currentStage', isNull),
|
|
],
|
|
);
|
|
|
|
blocTest<ChatBloc, ChatState>(
|
|
'runError sets error message',
|
|
build: () => chatBloc,
|
|
seed: () => const ChatState(isWaitingFirstToken: true),
|
|
act: (bloc) {
|
|
service.onEvent(
|
|
RunErrorEvent(message: 'Something went wrong', code: 'ERR'),
|
|
);
|
|
},
|
|
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,
|
|
seed: () => const ChatState(error: 'Some error'),
|
|
act: (bloc) => bloc.clearError(),
|
|
expect: () => [isA<ChatState>().having((s) => s.error, 'error', isNull)],
|
|
);
|
|
|
|
blocTest<ChatBloc, ChatState>(
|
|
'toolCallStart adds ToolCallItem',
|
|
build: () => chatBloc,
|
|
act: (bloc) {
|
|
service.onEvent(
|
|
ToolCallStartEvent(
|
|
toolCallId: 'tc_1',
|
|
toolCallName: 'back.mutate_calendar_event',
|
|
),
|
|
);
|
|
},
|
|
expect: () => [
|
|
isA<ChatState>().having(
|
|
(s) {
|
|
final item = s.items.first;
|
|
return item is ToolCallItem &&
|
|
item.toolName == 'back.mutate_calendar_event' &&
|
|
item.status == ToolCallStatus.pending;
|
|
},
|
|
'has pending tool call',
|
|
true,
|
|
),
|
|
],
|
|
);
|
|
|
|
blocTest<ChatBloc, ChatState>(
|
|
'toolCallResult without ui removes pending tool call and does not add empty card',
|
|
build: () => chatBloc,
|
|
seed: () => ChatState(
|
|
items: [
|
|
ToolCallItem(
|
|
id: 'tc_1',
|
|
callId: 'tc_1',
|
|
toolName: 'front.navigate_to_route',
|
|
args: {'target': '/calendar/dayweek', '__nonce': 'nonce_1'},
|
|
status: ToolCallStatus.executing,
|
|
timestamp: DateTime.now(),
|
|
sender: MessageSender.ai,
|
|
),
|
|
],
|
|
),
|
|
act: (bloc) {
|
|
service.onEvent(
|
|
ToolCallResultEvent(
|
|
messageId: 'msg_tool_1',
|
|
toolCallId: 'tc_1',
|
|
content: '{"result":{"ok":true}}',
|
|
),
|
|
);
|
|
},
|
|
expect: () => [
|
|
isA<ChatState>().having((s) => s.items.isEmpty, 'items empty', true),
|
|
],
|
|
);
|
|
|
|
blocTest<ChatBloc, ChatState>(
|
|
'toolCallResult with ui in payload.result adds ToolResultItem',
|
|
build: () => chatBloc,
|
|
seed: () => ChatState(
|
|
items: [
|
|
ToolCallItem(
|
|
id: 'tc_2',
|
|
callId: 'tc_2',
|
|
toolName: 'back.mutate_calendar_event',
|
|
args: {'operation': 'create'},
|
|
status: ToolCallStatus.executing,
|
|
timestamp: DateTime.now(),
|
|
sender: MessageSender.ai,
|
|
),
|
|
],
|
|
),
|
|
act: (bloc) {
|
|
service.onEvent(
|
|
ToolCallResultEvent(
|
|
messageId: 'msg_tool_2',
|
|
toolCallId: 'tc_2',
|
|
content:
|
|
'{"result":{"type":"calendar_operation.v1","version":"v1","data":{"operation":"delete","ok":true,"message":"done"},"actions":[]}}',
|
|
),
|
|
);
|
|
},
|
|
expect: () => [
|
|
isA<ChatState>().having(
|
|
(s) => s.items.first is ToolResultItem,
|
|
'first item is ToolResultItem',
|
|
true,
|
|
),
|
|
],
|
|
);
|
|
|
|
blocTest<ChatBloc, ChatState>(
|
|
'state snapshot user message keeps attachments',
|
|
build: () => chatBloc,
|
|
act: (bloc) {
|
|
service.onEvent(
|
|
StateSnapshotEvent(
|
|
snapshot: {
|
|
'scope': 'history_day',
|
|
'messages': [
|
|
{
|
|
'id': 'u1',
|
|
'role': 'user',
|
|
'content': '请分析这张图',
|
|
'attachments': [
|
|
{'bucket': 'b', 'path': 'p', 'mimeType': 'image/png'},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
),
|
|
);
|
|
},
|
|
expect: () => [
|
|
isA<ChatState>().having(
|
|
(s) {
|
|
final item = s.items.first;
|
|
return item is TextMessageItem && item.attachments.length == 1;
|
|
},
|
|
'user attachment count',
|
|
true,
|
|
),
|
|
],
|
|
);
|
|
|
|
test(
|
|
'loadAttachmentPreview deduplicates in-flight and caches result',
|
|
() async {
|
|
final mock = service as MockAgUiService;
|
|
final results = await Future.wait<Uint8List?>([
|
|
chatBloc.loadAttachmentPreview('/api/preview/1'),
|
|
chatBloc.loadAttachmentPreview('/api/preview/1'),
|
|
]);
|
|
final secondRound = await chatBloc.loadAttachmentPreview(
|
|
'/api/preview/1',
|
|
);
|
|
|
|
expect(results.first, isNotNull);
|
|
expect(results.last, isNotNull);
|
|
expect(secondRound, isNotNull);
|
|
expect(mock.previewCalls, 1);
|
|
},
|
|
);
|
|
});
|
|
}
|