import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:social_app/core/chat/agent_stage.dart'; import 'package:social_app/core/chat/ag_ui_event.dart'; import 'package:social_app/core/chat/ag_ui_service.dart'; import 'package:social_app/core/chat/chat_api.dart'; import 'package:social_app/core/chat/chat_list_item.dart'; import 'package:social_app/core/l10n/l10n.dart'; import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart'; class _NoopChatApi implements ChatApi { @override Future cancelRun({required String threadId, required String runId}) { throw UnimplementedError(); } @override Future> createRun(Map runInput) { throw UnimplementedError(); } @override Future> fetchHistory({ String? threadId, DateTime? beforeDate, }) { throw UnimplementedError(); } @override Future fetchAttachmentPreview(String previewPath) { throw UnimplementedError(); } @override Future> streamRunEvents( String threadId, { required String runId, String? lastEventId, }) { throw UnimplementedError(); } @override Future transcribeAudio(String filePath) { throw UnimplementedError(); } @override Future> uploadAttachment({ required String threadId, required String filename, required String mimeType, required Uint8List bytes, }) { throw UnimplementedError(); } } class _FakeAgUiService extends AgUiService { _FakeAgUiService() : super(chatApi: _NoopChatApi(), onEvent: (_) {}); Future Function( String content, List? attachments, )? sendMessageHandler; Future Function({DateTime? beforeDate, bool forceRefresh})? loadHistoryHandler; int loadHistoryCalls = 0; final List setUserContextCalls = []; void emitEventForTest(AgUiEvent event) { onEvent(event); } @override Future sendMessage( String content, { List? attachments, }) async { final handler = sendMessageHandler; if (handler == null) { throw UnimplementedError(); } return handler(content, attachments); } @override Future loadHistory({ DateTime? beforeDate, bool forceRefresh = false, }) async { loadHistoryCalls += 1; final handler = loadHistoryHandler; if (handler == null) { throw UnimplementedError(); } return handler(beforeDate: beforeDate, forceRefresh: forceRefresh); } @override Future setUserContext(String? userId) async { setUserContextCalls.add(userId); } } HistorySnapshot _snapshot( List messages, { bool hasMore = false, }) { return HistorySnapshot( scope: 'history_day', threadId: 'thread-1', day: '2026-03-30', hasMore: hasMore, messages: messages, ); } HistoryMessage _historyMessage({ required String id, required int seq, required String role, required String content, required DateTime timestamp, }) { return HistoryMessage( id: id, seq: seq, role: role, content: content, timestamp: timestamp, ); } void main() { setUp(() { L10n.setLocale(const Locale('zh')); }); test( 'loadHistory ignores stale result after switchUser epoch change', () async { final service = _FakeAgUiService(); final completer = Completer(); var loadCall = 0; service.loadHistoryHandler = ({DateTime? beforeDate, bool forceRefresh = false}) { loadCall += 1; if (loadCall == 1) { return completer.future; } return Future.value(_snapshot(const [])); }; final bloc = ChatBloc( service: service, chatApi: _NoopChatApi(), recoveryPollInterval: const Duration(milliseconds: 1), recoveryTimeout: const Duration(milliseconds: 80), ); final pendingLoad = bloc.loadHistory(); await bloc.switchUser('user-b'); completer.complete( _snapshot([ _historyMessage( id: 'old-1', seq: 1, role: 'assistant', content: 'old session data', timestamp: DateTime.now(), ), ]), ); await pendingLoad; expect(bloc.state.items, isEmpty); expect(bloc.state.isLoadingHistory, isFalse); }, ); test('switchUser loads history after resetting state', () async { final service = _FakeAgUiService(); service.loadHistoryHandler = ({DateTime? beforeDate, bool forceRefresh = false}) async { final now = DateTime.now(); return _snapshot([ _historyMessage( id: 'history-1', seq: 1, role: 'assistant', content: 'welcome back', timestamp: now, ), ]); }; final bloc = ChatBloc(service: service, chatApi: _NoopChatApi()); await bloc.switchUser('user-a'); expect(service.setUserContextCalls, ['user-a']); expect(service.loadHistoryCalls, 1); expect(bloc.state.items, hasLength(1)); expect( bloc.state.items.single, isA().having( (item) => item.content, 'content', 'welcome back', ), ); }); test('switchUser keeps flow when history load fails', () async { final service = _FakeAgUiService(); service.loadHistoryHandler = ({DateTime? beforeDate, bool forceRefresh = false}) { throw StateError('history unavailable'); }; final bloc = ChatBloc(service: service, chatApi: _NoopChatApi()); await bloc.switchUser('user-a'); expect(service.setUserContextCalls, ['user-a']); expect(service.loadHistoryCalls, 1); expect(bloc.state.error, contains('history unavailable')); }); test( 'tool calendar_write success triggers calendar refresh callback', () async { final service = _FakeAgUiService(); var refreshCalls = 0; final bloc = ChatBloc( service: service, chatApi: _NoopChatApi(), onCalendarMutated: () async { refreshCalls += 1; }, ); service.emitEventForTest( ToolCallResultEvent( messageId: 'msg-1', toolCallId: 'call-1', toolName: 'calendar_write', resultSummary: 'ok', status: 'success', ), ); await Future.delayed(Duration.zero); expect(refreshCalls, 1); expect(bloc.state.isLoading, isFalse); }, ); test( 'sendMessage recovers from premature SSE close with polled history', () async { final service = _FakeAgUiService(); service.sendMessageHandler = (content, attachments) async { throw StateError('SSE closed before terminal event for run'); }; var loadAttempt = 0; service.loadHistoryHandler = ({DateTime? beforeDate, bool forceRefresh = false}) async { loadAttempt += 1; final now = DateTime.now(); if (loadAttempt == 1) { return _snapshot([ _historyMessage( id: 'db-user-1', seq: 1, role: 'user', content: 'hello', timestamp: now, ), ]); } return _snapshot([ _historyMessage( id: 'db-user-1', seq: 1, role: 'user', content: 'hello', timestamp: now, ), _historyMessage( id: 'db-assistant-1', seq: 2, role: 'assistant', content: 'world', timestamp: now.add(const Duration(seconds: 1)), ), ]); }; final bloc = ChatBloc( service: service, chatApi: _NoopChatApi(), recoveryPollInterval: const Duration(milliseconds: 1), recoveryTimeout: const Duration(milliseconds: 50), ); await bloc.sendMessage('hello'); final userMessages = bloc.state.items .whereType() .where((item) => item.sender == MessageSender.user) .toList(); expect(userMessages.length, 1); expect(userMessages.first.id, 'db-user-1'); expect( bloc.state.items.any( (item) => item is TextMessageItem && item.sender == MessageSender.ai && item.content == 'world', ), isTrue, ); expect(bloc.state.error, isNull); expect(service.loadHistoryCalls, 2); }, ); test('sendMessage reports error after recovery attempts exhausted', () async { final service = _FakeAgUiService(); service.sendMessageHandler = (content, attachments) async { throw StateError('SSE closed before terminal event for run'); }; service.loadHistoryHandler = ({DateTime? beforeDate, bool forceRefresh = false}) async { final now = DateTime.now(); return _snapshot([ _historyMessage( id: 'db-user-1', seq: 1, role: 'user', content: 'hello', timestamp: now, ), ]); }; final bloc = ChatBloc( service: service, chatApi: _NoopChatApi(), recoveryPollInterval: const Duration(milliseconds: 1), recoveryTimeout: const Duration(milliseconds: 15), ); await bloc.sendMessage('hello'); expect(bloc.state.error, L10n.current.chatSseInterruptedRetry); expect(service.loadHistoryCalls, greaterThanOrEqualTo(1)); }); test( 'tracks hasSeenStep to distinguish requesting vs processing stage', () async { final service = _FakeAgUiService(); final bloc = ChatBloc(service: service, chatApi: _NoopChatApi()); service.emitEventForTest( RunStartedEvent(threadId: 'thread-1', runId: 'run-1'), ); expect(bloc.state.isWaitingFirstToken, isTrue); expect(bloc.state.hasSeenStep, isFalse); expect(bloc.state.currentStage, isNull); service.emitEventForTest(StepStartedEvent(stepName: 'router')); expect(bloc.state.hasSeenStep, isTrue); expect(bloc.state.currentStage, AgentStage.routing); service.emitEventForTest(StepFinishedEvent(stepName: 'router')); expect(bloc.state.hasSeenStep, isTrue); expect(bloc.state.currentStage, isNull); }, ); }