feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持
This commit is contained in:
@@ -1,109 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
|
||||
|
||||
void main() {
|
||||
group('AgUiEvent parsing', () {
|
||||
test('parses TEXT_MESSAGE_END with ui_schema payload', () {
|
||||
final event = AgUiEvent.fromJson({
|
||||
'type': 'TEXT_MESSAGE_END',
|
||||
'messageId': 'msg_1',
|
||||
'answer': '你好',
|
||||
'role': 'assistant',
|
||||
'status': 'success',
|
||||
'ui_schema': {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'card',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'title', 'content': '创建成功'},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(event, isA<TextMessageEndEvent>());
|
||||
final textEnd = event as TextMessageEndEvent;
|
||||
expect(textEnd.messageId, 'msg_1');
|
||||
expect(textEnd.answer, '你好');
|
||||
expect(textEnd.uiSchema?['version'], '2.0');
|
||||
});
|
||||
|
||||
test('parses TOOL_CALL_RESULT snake_case fields', () {
|
||||
final event = AgUiEvent.fromJson({
|
||||
'type': 'TOOL_CALL_RESULT',
|
||||
'messageId': 'tool_1',
|
||||
'tool_call_id': 'call_1',
|
||||
'tool_name': 'calendar_read',
|
||||
'status': 'success',
|
||||
'result': '找到 2 条结果',
|
||||
});
|
||||
|
||||
expect(event, isA<ToolCallResultEvent>());
|
||||
final result = event as ToolCallResultEvent;
|
||||
expect(result.toolCallId, 'call_1');
|
||||
expect(result.toolName, 'calendar_read');
|
||||
expect(result.resultSummary, '找到 2 条结果');
|
||||
expect(result.status, 'success');
|
||||
});
|
||||
|
||||
test('parses history snapshot with ui_schema', () {
|
||||
final snapshot = HistorySnapshot.fromJson({
|
||||
'scope': 'history_day',
|
||||
'threadId': 'thread_1',
|
||||
'day': '2026-03-16',
|
||||
'hasMore': false,
|
||||
'messages': [
|
||||
{
|
||||
'id': 'm1',
|
||||
'seq': 1,
|
||||
'role': 'assistant',
|
||||
'content': '已处理',
|
||||
'ui_schema': {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'card',
|
||||
'children': [],
|
||||
},
|
||||
},
|
||||
'timestamp': '2026-03-16T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(snapshot.scope, 'history_day');
|
||||
expect(snapshot.messages, hasLength(1));
|
||||
expect(snapshot.messages.first.uiSchema, isNotNull);
|
||||
});
|
||||
|
||||
test('parses history user attachments list', () {
|
||||
final snapshot = HistorySnapshot.fromJson({
|
||||
'scope': 'history_day',
|
||||
'threadId': 'thread_1',
|
||||
'day': '2026-03-16',
|
||||
'hasMore': false,
|
||||
'messages': [
|
||||
{
|
||||
'id': 'm1',
|
||||
'seq': 1,
|
||||
'role': 'user',
|
||||
'content': '请看图',
|
||||
'attachments': [
|
||||
{'url': 'https://signed.example/a.png', 'mimeType': 'image/png'},
|
||||
{'url': 'https://signed.example/b.jpg', 'mimeType': 'image/jpeg'},
|
||||
],
|
||||
'timestamp': '2026-03-16T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
final userMessage = snapshot.messages.first;
|
||||
expect(userMessage.attachments, hasLength(2));
|
||||
expect(userMessage.attachments.first.url, 'https://signed.example/a.png');
|
||||
expect(userMessage.attachments.last.mimeType, 'image/jpeg');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.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/services/ag_ui_service.dart';
|
||||
|
||||
class _FakeApiClient implements IApiClient {
|
||||
_FakeApiClient({
|
||||
required this.sseLines,
|
||||
this.sseLineStreamFactory,
|
||||
this.runIdFactory,
|
||||
});
|
||||
|
||||
final List<String> sseLines;
|
||||
final Stream<String> Function()? sseLineStreamFactory;
|
||||
final String Function()? runIdFactory;
|
||||
final List<String> postPaths = <String>[];
|
||||
|
||||
@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,
|
||||
}) async {
|
||||
final streamFactory = sseLineStreamFactory;
|
||||
if (streamFactory != null) {
|
||||
return streamFactory();
|
||||
}
|
||||
return Stream<String>.fromIterable(sseLines);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> patch<T>(String path, {data, Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> put<T>(String path, {data, Options? options}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> post<T>(String path, {data, Options? options}) async {
|
||||
postPaths.add(path);
|
||||
if (path.contains('/cancel?runId=')) {
|
||||
final payload = <String, dynamic>{
|
||||
'threadId': 'thread-1',
|
||||
'runId': 'run-new',
|
||||
'accepted': true,
|
||||
};
|
||||
return Response<T>(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
data: payload as T,
|
||||
statusCode: 202,
|
||||
);
|
||||
}
|
||||
final runIdFactory = this.runIdFactory;
|
||||
final payload = <String, dynamic>{
|
||||
'taskId': 'task-1',
|
||||
'threadId': 'thread-1',
|
||||
'runId': runIdFactory != null ? runIdFactory() : 'run-new',
|
||||
'created': true,
|
||||
};
|
||||
return Response<T>(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
data: payload as T,
|
||||
statusCode: 202,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _buildSseEvent({
|
||||
required String id,
|
||||
required String type,
|
||||
required String payload,
|
||||
}) {
|
||||
return <String>['id: $id', 'event: $type', 'data: $payload', ''];
|
||||
}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'sendMessage ignores stale run events and waits for expected run',
|
||||
() async {
|
||||
final oldRunLines = _buildSseEvent(
|
||||
id: '1',
|
||||
type: AgUiEventTypeWire.runStarted,
|
||||
payload:
|
||||
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-old"}',
|
||||
);
|
||||
final oldFinishedLines = _buildSseEvent(
|
||||
id: '2',
|
||||
type: AgUiEventTypeWire.runFinished,
|
||||
payload:
|
||||
'{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-old"}',
|
||||
);
|
||||
final newRunLines = _buildSseEvent(
|
||||
id: '3',
|
||||
type: AgUiEventTypeWire.runStarted,
|
||||
payload:
|
||||
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
|
||||
);
|
||||
final newFinishedLines = _buildSseEvent(
|
||||
id: '4',
|
||||
type: AgUiEventTypeWire.runFinished,
|
||||
payload:
|
||||
'{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-new"}',
|
||||
);
|
||||
|
||||
final service = AgUiService(
|
||||
apiClient: _FakeApiClient(
|
||||
sseLines: <String>[
|
||||
...oldRunLines,
|
||||
...oldFinishedLines,
|
||||
...newRunLines,
|
||||
...newFinishedLines,
|
||||
],
|
||||
),
|
||||
);
|
||||
final events = <AgUiEvent>[];
|
||||
service.onEvent = events.add;
|
||||
|
||||
await service.sendMessage('hello');
|
||||
|
||||
expect(events, hasLength(2));
|
||||
expect(events.first, isA<RunStartedEvent>());
|
||||
expect((events.first as RunStartedEvent).runId, 'run-new');
|
||||
expect(events.last, isA<RunFinishedEvent>());
|
||||
expect((events.last as RunFinishedEvent).runId, 'run-new');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'sendMessage accepts in-run terminal event without runId after binding',
|
||||
() async {
|
||||
final newRunLines = _buildSseEvent(
|
||||
id: '11',
|
||||
type: AgUiEventTypeWire.runStarted,
|
||||
payload:
|
||||
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
|
||||
);
|
||||
final noRunIdTextLines = _buildSseEvent(
|
||||
id: '12',
|
||||
type: AgUiEventTypeWire.textMessageEnd,
|
||||
payload:
|
||||
'{"type":"TEXT_MESSAGE_END","threadId":"thread-1","messageId":"m1","answer":"ok","role":"assistant","status":"success"}',
|
||||
);
|
||||
final noRunIdFinishedLines = _buildSseEvent(
|
||||
id: '13',
|
||||
type: AgUiEventTypeWire.runFinished,
|
||||
payload: '{"type":"RUN_FINISHED","threadId":"thread-1"}',
|
||||
);
|
||||
|
||||
final service = AgUiService(
|
||||
apiClient: _FakeApiClient(
|
||||
sseLines: <String>[
|
||||
...newRunLines,
|
||||
...noRunIdTextLines,
|
||||
...noRunIdFinishedLines,
|
||||
],
|
||||
),
|
||||
);
|
||||
final events = <AgUiEvent>[];
|
||||
service.onEvent = events.add;
|
||||
|
||||
await service.sendMessage('hello');
|
||||
|
||||
expect(events, hasLength(3));
|
||||
expect(events[0], isA<RunStartedEvent>());
|
||||
expect(events[1], isA<TextMessageEndEvent>());
|
||||
expect(events[2], isA<RunFinishedEvent>());
|
||||
},
|
||||
);
|
||||
|
||||
test('cancelCurrentRun actively closes current SSE subscription', () async {
|
||||
var streamCancelled = false;
|
||||
final streamController = StreamController<String>(
|
||||
onCancel: () {
|
||||
streamCancelled = true;
|
||||
},
|
||||
);
|
||||
|
||||
final service = AgUiService(
|
||||
apiClient: _FakeApiClient(
|
||||
sseLines: const <String>[],
|
||||
sseLineStreamFactory: () => streamController.stream,
|
||||
),
|
||||
);
|
||||
|
||||
final sendFuture = service.sendMessage('hello');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await service.cancelCurrentRun();
|
||||
|
||||
await sendFuture;
|
||||
expect(streamCancelled, isTrue);
|
||||
await streamController.close();
|
||||
});
|
||||
|
||||
test(
|
||||
'cancelCurrentRun calls backend cancel endpoint for active run',
|
||||
() async {
|
||||
final streamController = StreamController<String>();
|
||||
final fakeApi = _FakeApiClient(
|
||||
sseLines: const <String>[],
|
||||
sseLineStreamFactory: () => streamController.stream,
|
||||
);
|
||||
final service = AgUiService(apiClient: fakeApi);
|
||||
|
||||
final sendFuture = service.sendMessage('hello');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
for (final line in _buildSseEvent(
|
||||
id: '51',
|
||||
type: AgUiEventTypeWire.runStarted,
|
||||
payload:
|
||||
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
|
||||
)) {
|
||||
streamController.add(line);
|
||||
}
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
await service.cancelCurrentRun();
|
||||
await sendFuture;
|
||||
|
||||
expect(
|
||||
fakeApi.postPaths,
|
||||
contains('/api/v1/agent/runs/thread-1/cancel?runId=run-new'),
|
||||
);
|
||||
await streamController.close();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'new sendMessage cancels previous SSE subscription explicitly',
|
||||
() async {
|
||||
var firstStreamCancelled = false;
|
||||
final firstController = StreamController<String>(
|
||||
onCancel: () {
|
||||
firstStreamCancelled = true;
|
||||
},
|
||||
);
|
||||
final secondController = StreamController<String>();
|
||||
final streamQueue = <StreamController<String>>[
|
||||
firstController,
|
||||
secondController,
|
||||
];
|
||||
var streamIndex = 0;
|
||||
var runIndex = 0;
|
||||
|
||||
final service = AgUiService(
|
||||
apiClient: _FakeApiClient(
|
||||
sseLines: const <String>[],
|
||||
sseLineStreamFactory: () => streamQueue[streamIndex++].stream,
|
||||
runIdFactory: () {
|
||||
runIndex += 1;
|
||||
return 'run-$runIndex';
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final firstSendFuture = service.sendMessage('first');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final secondSendFuture = service.sendMessage('second');
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
for (final line in _buildSseEvent(
|
||||
id: '21',
|
||||
type: AgUiEventTypeWire.runStarted,
|
||||
payload: '{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-2"}',
|
||||
)) {
|
||||
secondController.add(line);
|
||||
}
|
||||
for (final line in _buildSseEvent(
|
||||
id: '22',
|
||||
type: AgUiEventTypeWire.runFinished,
|
||||
payload:
|
||||
'{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-2"}',
|
||||
)) {
|
||||
secondController.add(line);
|
||||
}
|
||||
await secondController.close();
|
||||
|
||||
await firstSendFuture;
|
||||
await secondSendFuture;
|
||||
|
||||
expect(firstStreamCancelled, isTrue);
|
||||
await firstController.close();
|
||||
},
|
||||
);
|
||||
|
||||
test('sendMessage surfaces event callback exceptions', () async {
|
||||
final service = AgUiService(
|
||||
apiClient: _FakeApiClient(
|
||||
sseLines: <String>[
|
||||
..._buildSseEvent(
|
||||
id: '31',
|
||||
type: AgUiEventTypeWire.runStarted,
|
||||
payload:
|
||||
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
|
||||
),
|
||||
..._buildSseEvent(
|
||||
id: '32',
|
||||
type: AgUiEventTypeWire.runFinished,
|
||||
payload:
|
||||
'{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-new"}',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
service.onEvent = (_) => throw StateError('event callback failed');
|
||||
|
||||
await expectLater(service.sendMessage('hello'), throwsA(isA<StateError>()));
|
||||
});
|
||||
|
||||
test('sendMessage fails when SSE closes before terminal event', () async {
|
||||
final startedLines = _buildSseEvent(
|
||||
id: '41',
|
||||
type: AgUiEventTypeWire.runStarted,
|
||||
payload: '{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
|
||||
);
|
||||
|
||||
final service = AgUiService(
|
||||
apiClient: _FakeApiClient(sseLines: <String>[...startedLines]),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
service.sendMessage('hello'),
|
||||
throwsA(
|
||||
isA<StateError>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('SSE closed before terminal event'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/chat/presentation/bloc/agent_stage.dart';
|
||||
|
||||
void main() {
|
||||
group('agent stage mapping', () {
|
||||
test('maps protocol step router to routing stage label', () {
|
||||
final stage = stageFromStepName('router');
|
||||
|
||||
expect(stage, AgentStage.routing);
|
||||
expect(stageLabel(stage), '意图识别中');
|
||||
});
|
||||
|
||||
test('maps protocol step worker to execution stage label', () {
|
||||
final stage = stageFromStepName('worker');
|
||||
|
||||
expect(stage, AgentStage.execution);
|
||||
expect(stageLabel(stage), '任务执行中');
|
||||
});
|
||||
|
||||
test('maps protocol step memory to memory stage label', () {
|
||||
final stage = stageFromStepName('memory');
|
||||
|
||||
expect(stage, AgentStage.memory);
|
||||
expect(stageLabel(stage), '记忆提取中');
|
||||
});
|
||||
|
||||
test('uses processing label when step is unknown', () {
|
||||
final stage = stageFromStepName('unexpected');
|
||||
|
||||
expect(stage, isNull);
|
||||
expect(stageLabel(stage), '任务处理中');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
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>> put<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;
|
||||
Completer<HistorySnapshot>? pendingHistory;
|
||||
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: []);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HistorySnapshot> loadHistory({DateTime? beforeDate}) async {
|
||||
final pending = pendingHistory;
|
||||
if (pending != null) {
|
||||
return pending.future;
|
||||
}
|
||||
return const HistorySnapshot(
|
||||
scope: 'history_day',
|
||||
threadId: null,
|
||||
day: null,
|
||||
hasMore: false,
|
||||
messages: <HistoryMessage>[],
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
),
|
||||
);
|
||||
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');
|
||||
});
|
||||
|
||||
test('run canceled error clears error and marks tool as canceled', () {
|
||||
service.emitEvent(
|
||||
ToolCallStartEvent(
|
||||
toolCallId: 'tool-cancel',
|
||||
toolCallName: 'ocr_image',
|
||||
),
|
||||
);
|
||||
service.emitEvent(ToolCallEndEvent(toolCallId: 'tool-cancel'));
|
||||
|
||||
service.emitEvent(
|
||||
RunErrorEvent(message: 'run canceled by user', code: 'RUN_CANCELED'),
|
||||
);
|
||||
|
||||
final toolItem = bloc.state.items.whereType<ToolCallItem>().single;
|
||||
expect(toolItem.status, ToolCallStatus.error);
|
||||
expect(toolItem.errorMessage, '本次运行已取消');
|
||||
expect(bloc.state.error, isNull);
|
||||
expect(bloc.state.isWaitingFirstToken, isFalse);
|
||||
expect(bloc.state.isStreaming, isFalse);
|
||||
expect(bloc.state.isCancelling, isFalse);
|
||||
});
|
||||
|
||||
test('text event with ui schema is rendered into chat items', () {
|
||||
service.emitEvent(RunStartedEvent(threadId: 'thread-1', runId: 'run-1'));
|
||||
|
||||
service.emitEvent(
|
||||
TextMessageEndEvent(
|
||||
messageId: 'assistant-1',
|
||||
answer: '这是测试回复',
|
||||
role: 'assistant',
|
||||
status: 'success',
|
||||
uiSchema: {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'body', 'content': '测试 UI 卡片'},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
service.emitEvent(RunFinishedEvent(threadId: 'thread-1', runId: 'run-1'));
|
||||
|
||||
final messages = bloc.state.items.whereType<TextMessageItem>().toList();
|
||||
final uiCards = bloc.state.items.whereType<ToolResultItem>().toList();
|
||||
|
||||
expect(messages, hasLength(1));
|
||||
expect(messages.single.content, '这是测试回复');
|
||||
expect(uiCards, hasLength(1));
|
||||
expect(uiCards.single.uiSchema['root'], isA<Map<String, dynamic>>());
|
||||
expect(bloc.state.isWaitingFirstToken, isFalse);
|
||||
expect(bloc.state.isStreaming, isFalse);
|
||||
expect(bloc.state.currentStage, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'history loading does not overwrite real-time text and ui events',
|
||||
() async {
|
||||
final historyCompleter = Completer<HistorySnapshot>();
|
||||
service.pendingHistory = historyCompleter;
|
||||
|
||||
final loadFuture = bloc.loadHistory();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
service.emitEvent(
|
||||
RunStartedEvent(threadId: 'thread-1', runId: 'run-1'),
|
||||
);
|
||||
service.emitEvent(
|
||||
TextMessageEndEvent(
|
||||
messageId: 'assistant-live',
|
||||
answer: '实时回复',
|
||||
role: 'assistant',
|
||||
status: 'success',
|
||||
uiSchema: {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'body', 'content': '实时 UI 卡片'},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
historyCompleter.complete(
|
||||
const HistorySnapshot(
|
||||
scope: 'history_day',
|
||||
threadId: 'thread-1',
|
||||
day: '2026-03-24',
|
||||
hasMore: false,
|
||||
messages: <HistoryMessage>[],
|
||||
),
|
||||
);
|
||||
|
||||
await loadFuture;
|
||||
|
||||
final texts = bloc.state.items.whereType<TextMessageItem>().toList();
|
||||
final uiCards = bloc.state.items.whereType<ToolResultItem>().toList();
|
||||
expect(texts.map((item) => item.id), contains('assistant-live'));
|
||||
expect(uiCards.map((item) => item.id), contains('assistant-live-ui'));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'abnormal SSE close recovers from history without raw bad-state error',
|
||||
() async {
|
||||
service.nextError = StateError(
|
||||
'SSE closed before terminal event for run',
|
||||
);
|
||||
service.pendingHistory = Completer<HistorySnapshot>()
|
||||
..complete(
|
||||
HistorySnapshot(
|
||||
scope: 'history_day',
|
||||
threadId: 'thread-1',
|
||||
day: '2026-03-24',
|
||||
hasMore: false,
|
||||
messages: <HistoryMessage>[
|
||||
HistoryMessage(
|
||||
id: 'assistant-history-1',
|
||||
seq: 2,
|
||||
role: 'assistant',
|
||||
content: '历史补偿回复',
|
||||
timestamp: DateTime(2026, 3, 24, 17, 0, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await bloc.sendMessage('你是谁?');
|
||||
|
||||
expect(bloc.state.error, isNull);
|
||||
expect(bloc.state.isWaitingFirstToken, isFalse);
|
||||
expect(bloc.state.isStreaming, isFalse);
|
||||
expect(bloc.state.currentStage, isNull);
|
||||
expect(
|
||||
bloc.state.items
|
||||
.whereType<TextMessageItem>()
|
||||
.map((item) => item.content)
|
||||
.toList(),
|
||||
contains('历史补偿回复'),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/chat/ui/navigation/ui_schema_navigation.dart';
|
||||
|
||||
void main() {
|
||||
test('buildUiSchemaNavigationTarget merges scalar params only', () {
|
||||
final target = buildUiSchemaNavigationTarget(
|
||||
path: '/calendar/dayweek',
|
||||
params: {
|
||||
'date': '2026-03-18',
|
||||
'from': 'home',
|
||||
'count': 2,
|
||||
'enabled': true,
|
||||
'ignored': {'nested': true},
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
target,
|
||||
'/calendar/dayweek?date=2026-03-18&from=home&count=2&enabled=true',
|
||||
);
|
||||
});
|
||||
|
||||
test('isValidInternalNavigationPath follows protocol constraints', () {
|
||||
expect(isValidInternalNavigationPath('/todo/123/edit'), true);
|
||||
expect(isValidInternalNavigationPath('https://evil.com'), false);
|
||||
expect(isValidInternalNavigationPath('/todo/123?x=1'), false);
|
||||
expect(isValidInternalNavigationPath('/todo/:id'), false);
|
||||
});
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:social_app/features/chat/ui/widgets/ui_schema_renderer.dart';
|
||||
|
||||
void main() {
|
||||
group('UiSchemaRenderer', () {
|
||||
testWidgets('renders stack title and badge', (tester) async {
|
||||
final schema = {
|
||||
'version': '2.0',
|
||||
'locale': 'zh-CN',
|
||||
'status': 'success',
|
||||
'theme': 'default',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'card',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'title', 'content': '日程已创建'},
|
||||
{'type': 'badge', 'label': 'SUCCESS', 'status': 'success'},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(body: UiSchemaRenderer.renderSchema(schema)),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('日程已创建'), findsOneWidget);
|
||||
expect(find.text('SUCCESS'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders kv node values', (tester) async {
|
||||
final schema = {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'card',
|
||||
'children': [
|
||||
{
|
||||
'type': 'kv',
|
||||
'items': [
|
||||
{'key': 'title', 'label': '标题', 'value': '评审会'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(body: UiSchemaRenderer.renderSchema(schema)),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('标题'), findsOneWidget);
|
||||
expect(find.text('评审会'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders batch result list items in one card', (tester) async {
|
||||
final schema = {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'card',
|
||||
'status': 'warning',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'title', 'content': '日历操作完成'},
|
||||
{
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'gap': 8,
|
||||
'children': [
|
||||
{
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'card',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'body', 'content': '#1 create'},
|
||||
{'type': 'text', 'role': 'caption', 'content': '成功'},
|
||||
{'type': 'text', 'role': 'caption', 'content': '日程「晨会」已创建'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'card',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'body', 'content': '#2 delete'},
|
||||
{'type': 'text', 'role': 'caption', 'content': '失败'},
|
||||
{
|
||||
'type': 'text',
|
||||
'role': 'caption',
|
||||
'content': 'Schedule item not found',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(body: UiSchemaRenderer.renderSchema(schema)),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('日历操作完成'), findsOneWidget);
|
||||
expect(find.text('#1 create'), findsOneWidget);
|
||||
expect(find.text('#2 delete'), findsOneWidget);
|
||||
expect(find.text('成功'), findsOneWidget);
|
||||
expect(find.text('失败'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders fallback for invalid schema', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: UiSchemaRenderer.renderSchema({'version': '2.0'}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.textContaining('无效 UI Schema'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('handles navigation action by pushing target page', (
|
||||
tester,
|
||||
) async {
|
||||
final schema = {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'plain',
|
||||
'children': [
|
||||
{
|
||||
'type': 'button',
|
||||
'label': '查看待办',
|
||||
'style': 'primary',
|
||||
'action': {
|
||||
'type': 'navigation',
|
||||
'path': '/todo/123',
|
||||
'params': {'from': 'assistant'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) =>
|
||||
Scaffold(body: UiSchemaRenderer.renderSchema(schema)),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/todo/:id',
|
||||
builder: (context, state) => Text(
|
||||
'todo detail ${state.pathParameters['id']} from ${state.uri.queryParameters['from']}',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
|
||||
await tester.tap(find.text('查看待办'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('todo detail 123 from assistant'), findsOneWidget);
|
||||
expect(router.canPop(), isTrue);
|
||||
|
||||
router.pop();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('查看待办'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('uses replace navigation when replace is true', (tester) async {
|
||||
final schema = {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'plain',
|
||||
'children': [
|
||||
{
|
||||
'type': 'button',
|
||||
'label': '替换跳转',
|
||||
'style': 'primary',
|
||||
'action': {
|
||||
'type': 'navigation',
|
||||
'path': '/todo/456',
|
||||
'replace': true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) =>
|
||||
Scaffold(body: UiSchemaRenderer.renderSchema(schema)),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/todo/:id',
|
||||
builder: (context, state) =>
|
||||
Text('todo detail ${state.pathParameters['id']}'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
|
||||
await tester.tap(find.text('替换跳转'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('todo detail 456'), findsOneWidget);
|
||||
expect(router.canPop(), isFalse);
|
||||
|
||||
expect(find.text('todo detail 456'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('does not navigate for placeholder path', (tester) async {
|
||||
final schema = {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'appearance': 'plain',
|
||||
'children': [
|
||||
{
|
||||
'type': 'button',
|
||||
'label': '坏路径',
|
||||
'style': 'primary',
|
||||
'action': {'type': 'navigation', 'path': '/todo/:id'},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) =>
|
||||
Scaffold(body: UiSchemaRenderer.renderSchema(schema)),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/todo/:id',
|
||||
builder: (context, state) => const Text('detail'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
|
||||
await tester.tap(find.text('坏路径'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
|
||||
expect(find.text('坏路径'), findsOneWidget);
|
||||
expect(find.text('detail'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user