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

471 lines
13 KiB
Dart

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<void> cancelRun({required String threadId, required String runId}) {
throw UnimplementedError();
}
@override
Future<Map<String, dynamic>> createRun(Map<String, dynamic> runInput) {
throw UnimplementedError();
}
@override
Future<Map<String, dynamic>> fetchHistory({
String? threadId,
DateTime? beforeDate,
}) {
throw UnimplementedError();
}
@override
Future<Uint8List> fetchAttachmentPreview(String previewPath) {
throw UnimplementedError();
}
@override
Future<Stream<String>> streamRunEvents(
String threadId, {
required String runId,
String? lastEventId,
}) {
throw UnimplementedError();
}
@override
Future<String> transcribeAudio(String filePath) {
throw UnimplementedError();
}
@override
Future<Map<String, dynamic>> 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<SendMessageResult> Function(
String content,
List<AttachmentUploadInput>? attachments,
)?
sendMessageHandler;
Future<HistorySnapshot> Function({DateTime? beforeDate, bool forceRefresh})?
loadHistoryHandler;
int loadHistoryCalls = 0;
final List<String?> setUserContextCalls = <String?>[];
void emitEventForTest(AgUiEvent event) {
onEvent(event);
}
@override
Future<SendMessageResult> sendMessage(
String content, {
List<AttachmentUploadInput>? attachments,
}) async {
final handler = sendMessageHandler;
if (handler == null) {
throw UnimplementedError();
}
return handler(content, attachments);
}
@override
Future<HistorySnapshot> 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<void> setUserContext(String? userId) async {
setUserContextCalls.add(userId);
}
}
HistorySnapshot _snapshot(
List<HistoryMessage> 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<HistorySnapshot>();
var loadCall = 0;
service.loadHistoryHandler =
({DateTime? beforeDate, bool forceRefresh = false}) {
loadCall += 1;
if (loadCall == 1) {
return completer.future;
}
return Future<HistorySnapshot>.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<TextMessageItem>().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<void>.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<TextMessageItem>()
.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);
},
);
test('chat completed analytics triggers once only on RUN_FINISHED', () async {
final service = _FakeAgUiService();
var completedCalls = 0;
String? lastConversationId;
int? lastMessageCount;
int? lastResponseTimeMs;
final bloc = ChatBloc(
service: service,
chatApi: _NoopChatApi(),
onChatCompleted:
({
required String conversationId,
required int messageCount,
required int responseTimeMs,
}) {
completedCalls += 1;
lastConversationId = conversationId;
lastMessageCount = messageCount;
lastResponseTimeMs = responseTimeMs;
},
);
service.emitEventForTest(
RunStartedEvent(threadId: 'thread-1', runId: 'run-1'),
);
service.emitEventForTest(
TextMessageEndEvent(
messageId: 'msg-1',
answer: 'hello',
role: 'assistant',
status: 'success',
uiSchema: null,
),
);
expect(completedCalls, 0);
service.emitEventForTest(
RunFinishedEvent(threadId: 'thread-1', runId: 'run-1'),
);
service.emitEventForTest(
RunFinishedEvent(threadId: 'thread-1', runId: 'run-1'),
);
expect(completedCalls, 1);
expect(lastConversationId, 'thread-1');
expect(lastMessageCount, 1);
expect(lastResponseTimeMs, isNotNull);
expect(lastResponseTimeMs, greaterThanOrEqualTo(0));
bloc.close();
});
test('chat completed analytics does not trigger on RUN_ERROR', () async {
final service = _FakeAgUiService();
var completedCalls = 0;
final bloc = ChatBloc(
service: service,
chatApi: _NoopChatApi(),
onChatCompleted:
({
required String conversationId,
required int messageCount,
required int responseTimeMs,
}) {
completedCalls += 1;
},
);
service.emitEventForTest(
RunStartedEvent(threadId: 'thread-1', runId: 'run-1'),
);
service.emitEventForTest(
RunErrorEvent(message: 'run failed', code: 'RUN_FAILED'),
);
expect(completedCalls, 0);
bloc.close();
});
}