fix: stabilize chat run lifecycle rendering
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:social_app/core/api/i_api_client.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 _NoopApiClient implements IApiClient {
|
||||
@override
|
||||
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> get<T>(String path, {Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<String>> getSseLines(
|
||||
String path, {
|
||||
Map<String, String>? headers,
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> patch<T>(String path, {data, Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> post<T>(String path, {data, Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeAgUiService extends AgUiService {
|
||||
_FakeAgUiService() : super(apiClient: _NoopApiClient());
|
||||
|
||||
Completer<SendMessageResult>? pendingResult;
|
||||
Object? nextError;
|
||||
|
||||
@override
|
||||
Future<SendMessageResult> sendMessage(
|
||||
String content, {
|
||||
List<XFile>? images,
|
||||
}) async {
|
||||
final error = nextError;
|
||||
if (error != null) {
|
||||
nextError = null;
|
||||
throw error;
|
||||
}
|
||||
final pending = pendingResult;
|
||||
if (pending != null) {
|
||||
return pending.future;
|
||||
}
|
||||
return const SendMessageResult(uploadedAttachments: []);
|
||||
}
|
||||
|
||||
void emitEvent(AgUiEvent event) {
|
||||
onEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('ChatBloc attachment sync', () {
|
||||
late _FakeAgUiService service;
|
||||
late ChatBloc bloc;
|
||||
|
||||
setUp(() {
|
||||
service = _FakeAgUiService();
|
||||
bloc = ChatBloc(service: service, apiClient: _NoopApiClient());
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await bloc.close();
|
||||
});
|
||||
|
||||
test('optimistic local image is replaced with uploaded url', () async {
|
||||
final completer = Completer<SendMessageResult>();
|
||||
service.pendingResult = completer;
|
||||
|
||||
final sendFuture = bloc.sendMessage(
|
||||
'hello',
|
||||
images: [
|
||||
XFile('/tmp/local.jpg', name: 'local.jpg', mimeType: 'image/jpeg'),
|
||||
],
|
||||
);
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final optimistic = bloc.state.items.last as TextMessageItem;
|
||||
expect(optimistic.attachments, hasLength(1));
|
||||
expect(optimistic.attachments.first['path'], '/tmp/local.jpg');
|
||||
expect(optimistic.attachments.first['uploading'], isTrue);
|
||||
|
||||
completer.complete(
|
||||
const SendMessageResult(
|
||||
uploadedAttachments: [
|
||||
UploadedAttachment(
|
||||
localPath: '/tmp/local.jpg',
|
||||
url: 'https://cdn.example.com/a.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await sendFuture;
|
||||
|
||||
final synced = bloc.state.items.last as TextMessageItem;
|
||||
expect(synced.attachments.first['url'], 'https://cdn.example.com/a.jpg');
|
||||
expect(synced.attachments.first['uploading'], isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'upload failure clears uploading state to avoid endless spinner',
|
||||
() async {
|
||||
service.nextError = StateError('upload failed');
|
||||
|
||||
await bloc.sendMessage(
|
||||
'hello',
|
||||
images: [
|
||||
XFile('/tmp/local.jpg', name: 'local.jpg', mimeType: 'image/jpeg'),
|
||||
],
|
||||
);
|
||||
|
||||
final failed = bloc.state.items.last as TextMessageItem;
|
||||
expect(failed.attachments.first['uploading'], isFalse);
|
||||
expect(bloc.state.error, contains('upload failed'));
|
||||
},
|
||||
);
|
||||
|
||||
test('tool call stays visible until assistant final output', () {
|
||||
service.emitEvent(
|
||||
ToolCallStartEvent(toolCallId: 'tool-1', toolCallName: 'ocr_image'),
|
||||
);
|
||||
var toolItem = bloc.state.items.last as ToolCallItem;
|
||||
expect(toolItem.status, ToolCallStatus.pending);
|
||||
|
||||
service.emitEvent(ToolCallEndEvent(toolCallId: 'tool-1'));
|
||||
toolItem = bloc.state.items.last as ToolCallItem;
|
||||
expect(toolItem.status, ToolCallStatus.executing);
|
||||
|
||||
service.emitEvent(
|
||||
ToolCallResultEvent(
|
||||
messageId: 'tool-msg-1',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'ocr_image',
|
||||
resultSummary: 'done',
|
||||
status: 'success',
|
||||
uiSchema: null,
|
||||
),
|
||||
);
|
||||
toolItem = bloc.state.items.last as ToolCallItem;
|
||||
expect(toolItem.status, ToolCallStatus.completed);
|
||||
|
||||
service.emitEvent(
|
||||
TextMessageEndEvent(
|
||||
messageId: 'assistant-1',
|
||||
answer: '识别完成',
|
||||
role: 'assistant',
|
||||
status: 'success',
|
||||
uiSchema: null,
|
||||
),
|
||||
);
|
||||
|
||||
expect(bloc.state.items.whereType<ToolCallItem>(), isEmpty);
|
||||
expect(bloc.state.items.whereType<TextMessageItem>().length, 1);
|
||||
});
|
||||
|
||||
test('run error keeps tool card and marks it failed', () {
|
||||
service.emitEvent(
|
||||
ToolCallStartEvent(toolCallId: 'tool-err', toolCallName: 'ocr_image'),
|
||||
);
|
||||
service.emitEvent(ToolCallEndEvent(toolCallId: 'tool-err'));
|
||||
|
||||
service.emitEvent(RunErrorEvent(message: 'runtime execution failed'));
|
||||
|
||||
final toolItem = bloc.state.items.whereType<ToolCallItem>().single;
|
||||
expect(toolItem.status, ToolCallStatus.error);
|
||||
expect(toolItem.errorMessage, '本次运行已失败');
|
||||
expect(bloc.state.error, 'runtime execution failed');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user