Files
social-app/apps/test/features/chat/chat_bloc_test.dart
T

330 lines
10 KiB
Dart
Raw Normal View History

2026-02-28 13:49:51 +08:00
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image_picker/image_picker.dart';
2026-02-28 13:49:51 +08:00
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';
2026-02-28 13:49:51 +08:00
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
class MockAgUiService extends AgUiService {
MockAgUiService() : super(onEvent: (_) {});
@override
Future<void> sendMessage(String content, {List<XFile>? images}) async {}
}
class _ThrowingAgUiService extends AgUiService {
_ThrowingAgUiService() : super(onEvent: (_) {});
@override
Future<void> sendMessage(String content, {List<XFile>? images}) async {
throw StateError('network down');
}
}
2026-02-28 13:49:51 +08:00
void main() {
late ChatBloc chatBloc;
late AgUiService service;
setUp(() {
service = MockAgUiService();
2026-02-28 13:49:51 +08:00
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);
2026-02-28 13:49:51 +08:00
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,
)
2026-02-28 13:49:51 +08:00
.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(
2026-02-28 13:49:51 +08:00
TextMessageStartEvent(messageId: 'msg_1', role: 'assistant'),
);
},
expect: () => [
isA<ChatState>().having((s) => s.isStreaming, 'isStreaming', true),
2026-02-28 13:49:51 +08:00
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(
2026-02-28 13:49:51 +08:00
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'));
2026-02-28 13:49:51 +08:00
},
expect: () => [
isA<ChatState>()
.having((s) => s.currentMessageId, 'currentMessageId', isNull)
.having((s) => s.isStreaming, 'isStreaming', false)
2026-02-28 13:49:51 +08:00
.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'));
2026-02-28 13:49:51 +08:00
},
expect: () => [
isA<ChatState>()
.having((s) => s.isLoading, 'isLoading', true)
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', true)
2026-02-28 13:49:51 +08:00
.having((s) => s.error, 'error', isNull),
],
);
blocTest<ChatBloc, ChatState>(
'runFinished sets isLoading to false',
build: () => chatBloc,
seed: () => const ChatState(isWaitingFirstToken: true),
2026-02-28 13:49:51 +08:00
act: (bloc) {
service.onEvent(RunFinishedEvent(threadId: 't1', runId: 'r1'));
2026-02-28 13:49:51 +08:00
},
expect: () => [
isA<ChatState>()
.having((s) => s.isLoading, 'isLoading', false)
.having((s) => s.currentMessageId, 'currentMessageId', isNull),
],
);
blocTest<ChatBloc, ChatState>(
'runError sets error message',
build: () => chatBloc,
seed: () => const ChatState(isWaitingFirstToken: true),
2026-02-28 13:49:51 +08:00
act: (bloc) {
service.onEvent(
2026-02-28 13:49:51 +08:00
RunErrorEvent(message: 'Something went wrong', code: 'ERR'),
);
},
expect: () => [
isA<ChatState>()
.having((s) => s.isLoading, 'isLoading', false)
.having((s) => s.currentMessageId, 'currentMessageId', isNull)
2026-02-28 13:49:51 +08:00
.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')),
],
);
2026-02-28 13:49:51 +08:00
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(
2026-02-28 13:49:51 +08:00
ToolCallStartEvent(
toolCallId: 'tc_1',
toolCallName: 'back.mutate_calendar_event',
2026-02-28 13:49:51 +08:00
),
);
},
expect: () => [
isA<ChatState>().having(
(s) {
final item = s.items.first;
return item is ToolCallItem &&
item.toolName == 'back.mutate_calendar_event' &&
2026-02-28 13:49:51 +08:00
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,
),
],
);
2026-02-28 13:49:51 +08:00
});
}