feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持

This commit is contained in:
qzl
2026-03-27 14:05:03 +08:00
parent b1f0eb8921
commit c592cc7854
178 changed files with 10748 additions and 5764 deletions
@@ -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('历史补偿回复'),
);
},
);
});
}