import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.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: (_) {}); @override Future sendMessage(String content) async {} } class _ThrowingAgUiService extends AgUiService { _ThrowingAgUiService() : super(onEvent: (_) {}); @override Future sendMessage(String content) 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( 'sendMessage adds user message to items', build: () => chatBloc, act: (bloc) => bloc.sendMessage('Hello'), expect: () => [ isA() .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().having( (item) => item.content, 'content', 'Hello', ), ), ], ); blocTest( '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().having((s) => s.isStreaming, 'isStreaming', true), isA() .having((s) => s.items.length, 'items length', 1) .having((s) => s.currentMessageId, 'currentMessageId', 'msg_1') .having( (s) => s.items.first, 'first item', isA() .having((item) => item.isStreaming, 'isStreaming', true) .having((item) => item.sender, 'sender', MessageSender.ai), ), ], ); blocTest( '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().having( (s) => (s.items.first as TextMessageItem).content, 'content', 'Hello', ), ], ); blocTest( '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() .having((s) => s.currentMessageId, 'currentMessageId', isNull) .having((s) => s.isStreaming, 'isStreaming', false) .having( (s) => (s.items.first as TextMessageItem).isStreaming, 'isStreaming', false, ), ], ); blocTest( 'runStarted sets isLoading to true', build: () => chatBloc, act: (bloc) { service.onEvent(RunStartedEvent(threadId: 't1', runId: 'r1')); }, expect: () => [ isA() .having((s) => s.isLoading, 'isLoading', true) .having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', true) .having((s) => s.error, 'error', isNull), ], ); blocTest( 'runFinished sets isLoading to false', build: () => chatBloc, seed: () => const ChatState(isWaitingFirstToken: true), act: (bloc) { service.onEvent(RunFinishedEvent(threadId: 't1', runId: 'r1')); }, expect: () => [ isA() .having((s) => s.isLoading, 'isLoading', false) .having((s) => s.currentMessageId, 'currentMessageId', isNull), ], ); blocTest( 'runError sets error message', build: () => chatBloc, seed: () => const ChatState(isWaitingFirstToken: true), act: (bloc) { service.onEvent( RunErrorEvent(message: 'Something went wrong', code: 'ERR'), ); }, expect: () => [ isA() .having((s) => s.isLoading, 'isLoading', false) .having((s) => s.currentMessageId, 'currentMessageId', isNull) .having((s) => s.error, 'error', 'Something went wrong'), ], ); blocTest( 'cancelCurrentRun exits waiting states', build: () => chatBloc, seed: () => const ChatState(isWaitingFirstToken: true), act: (bloc) => bloc.cancelCurrentRun(), expect: () => [ isA().having((s) => s.isCancelling, 'isCancelling', true), isA() .having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', false) .having((s) => s.isStreaming, 'isStreaming', false) .having((s) => s.isCancelling, 'isCancelling', false), ], ); blocTest( 'sendMessage failure emits error and exits waiting state', build: () => ChatBloc(service: _ThrowingAgUiService()), act: (bloc) => bloc.sendMessage('hello'), expect: () => [ isA() .having((s) => s.isSending, 'isSending', true) .having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', true), isA() .having((s) => s.isSending, 'isSending', false) .having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', false) .having((s) => s.error, 'error', contains('network down')), ], ); blocTest( 'clearError removes error', build: () => chatBloc, seed: () => const ChatState(error: 'Some error'), act: (bloc) => bloc.clearError(), expect: () => [isA().having((s) => s.error, 'error', isNull)], ); blocTest( 'toolCallStart adds ToolCallItem', build: () => chatBloc, act: (bloc) { service.onEvent( ToolCallStartEvent( toolCallId: 'tc_1', toolCallName: 'back.mutate_calendar_event', ), ); }, expect: () => [ isA().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( '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().having((s) => s.items.isEmpty, 'items empty', true), ], ); blocTest( '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().having( (s) => s.items.first is ToolResultItem, 'first item is ToolResultItem', true, ), ], ); }); }