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,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);
});
});
}