chore: 更新国际化翻译及 UI 组件优化
This commit is contained in:
@@ -108,6 +108,7 @@ class _FakeChatApi implements ChatApi {
|
||||
@override
|
||||
Future<Stream<String>> streamRunEvents(
|
||||
String threadId, {
|
||||
required String runId,
|
||||
String? lastEventId,
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/core/chat/ag_ui_event.dart';
|
||||
|
||||
void main() {
|
||||
test('history message timestamp is normalized to local time', () {
|
||||
final raw = <String, dynamic>{
|
||||
'id': 'm1',
|
||||
'seq': 1,
|
||||
'role': 'assistant',
|
||||
'content': 'hello',
|
||||
'timestamp': '2026-03-29T16:06:27.870001+00:00',
|
||||
'attachments': const [],
|
||||
};
|
||||
|
||||
final message = HistoryMessage.fromJson(raw);
|
||||
final expected = DateTime.parse(
|
||||
'2026-03-29T16:06:27.870001+00:00',
|
||||
).toLocal();
|
||||
|
||||
expect(message.timestamp.isUtc, isFalse);
|
||||
expect(message.timestamp, expected);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.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';
|
||||
|
||||
class _RetryableSseChatApi implements ChatApi {
|
||||
int streamCalls = 0;
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> createRun(Map<String, dynamic> runInput) async {
|
||||
return <String, dynamic>{'threadId': 'thread-1', 'runId': 'run-1'};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<String>> streamRunEvents(
|
||||
String threadId, {
|
||||
required String runId,
|
||||
String? lastEventId,
|
||||
}) async {
|
||||
streamCalls += 1;
|
||||
|
||||
if (streamCalls == 1) {
|
||||
return Stream<String>.fromIterable(<String>[
|
||||
'id: e-1',
|
||||
'event: RUN_STARTED',
|
||||
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
return Stream<String>.fromIterable(<String>[
|
||||
'id: e-2',
|
||||
'event: TEXT_MESSAGE_END',
|
||||
'data: {"type":"TEXT_MESSAGE_END","threadId":"thread-1","runId":"run-1","messageId":"m-assistant-1","answer":"ok","role":"assistant","status":"success"}',
|
||||
'',
|
||||
'id: e-3',
|
||||
'event: RUN_FINISHED',
|
||||
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancelRun({required String threadId, required String runId}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> fetchHistory({
|
||||
String? threadId,
|
||||
DateTime? beforeDate,
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List> fetchAttachmentPreview(String previewPath) {
|
||||
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 _AlwaysPrematureCloseChatApi extends _RetryableSseChatApi {
|
||||
@override
|
||||
Future<Stream<String>> streamRunEvents(
|
||||
String threadId, {
|
||||
required String runId,
|
||||
String? lastEventId,
|
||||
}) async {
|
||||
streamCalls += 1;
|
||||
return Stream<String>.fromIterable(<String>[
|
||||
'id: e-1',
|
||||
'event: RUN_STARTED',
|
||||
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'reconnects SSE stream when first attempt closes before terminal',
|
||||
() async {
|
||||
final chatApi = _RetryableSseChatApi();
|
||||
final events = <AgUiEventType>[];
|
||||
final service = AgUiService(
|
||||
chatApi: chatApi,
|
||||
onEvent: (event) {
|
||||
events.add(event.type);
|
||||
},
|
||||
);
|
||||
|
||||
await service.sendMessage('hello');
|
||||
|
||||
expect(chatApi.streamCalls, 2);
|
||||
expect(events.contains(AgUiEventType.runStarted), isTrue);
|
||||
expect(events.contains(AgUiEventType.textMessageEnd), isTrue);
|
||||
expect(events.contains(AgUiEventType.runFinished), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('throws after SSE resume attempts are exhausted', () async {
|
||||
final chatApi = _AlwaysPrematureCloseChatApi();
|
||||
final service = AgUiService(chatApi: chatApi);
|
||||
|
||||
await expectLater(service.sendMessage('hello'), throwsA(isA<StateError>()));
|
||||
|
||||
expect(chatApi.streamCalls, 3);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/core/chat/agent_stage.dart';
|
||||
|
||||
void main() {
|
||||
test('maps router/worker/memory step names exactly', () {
|
||||
expect(stageFromStepName('router'), AgentStage.routing);
|
||||
expect(stageFromStepName('worker'), AgentStage.execution);
|
||||
expect(stageFromStepName('memory'), AgentStage.memory);
|
||||
});
|
||||
|
||||
test('normalizes step name with trim and case', () {
|
||||
expect(stageFromStepName(' ROUTER '), AgentStage.routing);
|
||||
expect(stageFromStepName('Worker'), AgentStage.execution);
|
||||
expect(stageFromStepName(' MEMORY'), AgentStage.memory);
|
||||
});
|
||||
|
||||
test('returns null for unknown step name', () {
|
||||
expect(stageFromStepName('tool'), isNull);
|
||||
expect(stageFromStepName(''), isNull);
|
||||
expect(stageFromStepName('unknown'), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/core/chat/chat_list_item.dart';
|
||||
import 'package:social_app/core/chat/chat_timeline_reconciler.dart';
|
||||
|
||||
void main() {
|
||||
test('replaces optimistic user echo with remote persisted user message', () {
|
||||
final now = DateTime(2026, 3, 30, 0, 6, 0);
|
||||
final local = <ChatListItem>[
|
||||
TextMessageItem(
|
||||
id: 'user-local-1',
|
||||
content: '随便推荐,比如说给我推荐B站的主页。',
|
||||
timestamp: now,
|
||||
sender: MessageSender.user,
|
||||
isLocalEcho: true,
|
||||
),
|
||||
];
|
||||
final remote = <ChatListItem>[
|
||||
TextMessageItem(
|
||||
id: 'db-user-27',
|
||||
content: '随便推荐,比如说给我推荐B站的主页。',
|
||||
timestamp: now.add(const Duration(seconds: 2)),
|
||||
sender: MessageSender.user,
|
||||
),
|
||||
TextMessageItem(
|
||||
id: 'db-assistant-30',
|
||||
content: 'B站(哔哩哔哩)的主页链接是:https://www.bilibili.com/',
|
||||
timestamp: now.add(const Duration(seconds: 6)),
|
||||
sender: MessageSender.ai,
|
||||
),
|
||||
];
|
||||
|
||||
final merged = ChatTimelineReconciler.merge(
|
||||
localItems: local,
|
||||
remoteItems: remote,
|
||||
);
|
||||
|
||||
expect(
|
||||
merged
|
||||
.whereType<TextMessageItem>()
|
||||
.where((m) => m.sender == MessageSender.user)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
expect(merged.any((item) => item.id == 'user-local-1'), isFalse);
|
||||
expect(merged.any((item) => item.id == 'db-user-27'), isTrue);
|
||||
expect(merged.any((item) => item.id == 'db-assistant-30'), isTrue);
|
||||
});
|
||||
|
||||
test('keeps optimistic user echo when remote does not match content', () {
|
||||
final now = DateTime(2026, 3, 30, 0, 6, 0);
|
||||
final local = <ChatListItem>[
|
||||
TextMessageItem(
|
||||
id: 'user-local-1',
|
||||
content: 'A',
|
||||
timestamp: now,
|
||||
sender: MessageSender.user,
|
||||
isLocalEcho: true,
|
||||
),
|
||||
];
|
||||
final remote = <ChatListItem>[
|
||||
TextMessageItem(
|
||||
id: 'db-user-2',
|
||||
content: 'B',
|
||||
timestamp: now.add(const Duration(seconds: 1)),
|
||||
sender: MessageSender.user,
|
||||
),
|
||||
];
|
||||
|
||||
final merged = ChatTimelineReconciler.merge(
|
||||
localItems: local,
|
||||
remoteItems: remote,
|
||||
);
|
||||
|
||||
expect(merged.any((item) => item.id == 'user-local-1'), isTrue);
|
||||
expect(merged.any((item) => item.id == 'db-user-2'), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'dedupes attachment message even when local uses path and remote uses url',
|
||||
() {
|
||||
final now = DateTime(2026, 3, 30, 0, 6, 0);
|
||||
final local = <ChatListItem>[
|
||||
TextMessageItem(
|
||||
id: 'user-local-attachment',
|
||||
content: '看这张图',
|
||||
timestamp: now,
|
||||
sender: MessageSender.user,
|
||||
isLocalEcho: true,
|
||||
attachments: const [
|
||||
{'path': '/tmp/a.jpg', 'mimeType': 'image/jpeg'},
|
||||
],
|
||||
),
|
||||
];
|
||||
final remote = <ChatListItem>[
|
||||
TextMessageItem(
|
||||
id: 'db-user-attachment',
|
||||
content: '看这张图',
|
||||
timestamp: now.add(const Duration(seconds: 3)),
|
||||
sender: MessageSender.user,
|
||||
attachments: const [
|
||||
{'url': 'https://cdn.example.com/a.jpg', 'mimeType': 'image/jpeg'},
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
final merged = ChatTimelineReconciler.merge(
|
||||
localItems: local,
|
||||
remoteItems: remote,
|
||||
);
|
||||
|
||||
expect(merged.any((item) => item.id == 'user-local-attachment'), isFalse);
|
||||
expect(merged.any((item) => item.id == 'db-user-attachment'), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'matches nearest optimistic echo when same text sent multiple times',
|
||||
() {
|
||||
final base = DateTime(2026, 3, 30, 0, 6, 0);
|
||||
final local = <ChatListItem>[
|
||||
TextMessageItem(
|
||||
id: 'echo-older',
|
||||
content: '你好',
|
||||
timestamp: base,
|
||||
sender: MessageSender.user,
|
||||
isLocalEcho: true,
|
||||
),
|
||||
TextMessageItem(
|
||||
id: 'echo-newer',
|
||||
content: '你好',
|
||||
timestamp: base.add(const Duration(seconds: 45)),
|
||||
sender: MessageSender.user,
|
||||
isLocalEcho: true,
|
||||
),
|
||||
];
|
||||
final remote = <ChatListItem>[
|
||||
TextMessageItem(
|
||||
id: 'db-user-newer',
|
||||
content: '你好',
|
||||
timestamp: base.add(const Duration(seconds: 47)),
|
||||
sender: MessageSender.user,
|
||||
),
|
||||
];
|
||||
|
||||
final merged = ChatTimelineReconciler.merge(
|
||||
localItems: local,
|
||||
remoteItems: remote,
|
||||
);
|
||||
|
||||
expect(merged.any((item) => item.id == 'echo-older'), isTrue);
|
||||
expect(merged.any((item) => item.id == 'echo-newer'), isFalse);
|
||||
expect(merged.any((item) => item.id == 'db-user-newer'), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('does not dedupe when attachment identity differs', () {
|
||||
final now = DateTime(2026, 3, 30, 0, 6, 0);
|
||||
final local = <ChatListItem>[
|
||||
TextMessageItem(
|
||||
id: 'echo-attachment-1',
|
||||
content: '看这张图',
|
||||
timestamp: now,
|
||||
sender: MessageSender.user,
|
||||
isLocalEcho: true,
|
||||
attachments: const [
|
||||
{'path': '/tmp/a.jpg', 'mimeType': 'image/jpeg'},
|
||||
],
|
||||
),
|
||||
];
|
||||
final remote = <ChatListItem>[
|
||||
TextMessageItem(
|
||||
id: 'db-attachment-2',
|
||||
content: '看这张图',
|
||||
timestamp: now.add(const Duration(seconds: 2)),
|
||||
sender: MessageSender.user,
|
||||
attachments: const [
|
||||
{'url': 'https://cdn.example.com/b.jpg', 'mimeType': 'image/jpeg'},
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
final merged = ChatTimelineReconciler.merge(
|
||||
localItems: local,
|
||||
remoteItems: remote,
|
||||
);
|
||||
|
||||
expect(merged.any((item) => item.id == 'echo-attachment-1'), isTrue);
|
||||
expect(merged.any((item) => item.id == 'db-attachment-2'), isTrue);
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/core/chat/chat_api.dart';
|
||||
import 'package:social_app/core/chat/chat_history_repository.dart';
|
||||
import 'package:social_app/data/cache/cache_scope.dart';
|
||||
import 'package:social_app/data/cache/cache_store.dart';
|
||||
|
||||
class _FakeChatApi implements ChatApi {
|
||||
@@ -42,6 +43,7 @@ class _FakeChatApi implements ChatApi {
|
||||
@override
|
||||
Future<Stream<String>> streamRunEvents(
|
||||
String threadId, {
|
||||
required String runId,
|
||||
String? lastEventId,
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
@@ -79,6 +81,14 @@ class _FakeChatApi implements ChatApi {
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
CacheScope.configureProvider(() => null);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
CacheScope.resetProvider();
|
||||
});
|
||||
|
||||
test('loads first-page history from cache on second read', () async {
|
||||
final chatApi = _FakeChatApi();
|
||||
chatApi.setHistory('first:default', {
|
||||
@@ -113,4 +123,32 @@ void main() {
|
||||
expect(second.messages.length, 1);
|
||||
expect(chatApi.historyCalls['first:default'], 1);
|
||||
});
|
||||
|
||||
test('separates history cache by global scope provider', () async {
|
||||
final chatApi = _FakeChatApi();
|
||||
chatApi.setHistory('first:default', {
|
||||
'scope': 'history_day',
|
||||
'threadId': 't1',
|
||||
'day': '2026-03-29',
|
||||
'hasMore': false,
|
||||
'messages': const [],
|
||||
});
|
||||
|
||||
final repository = ChatHistoryRepository(
|
||||
chatApi: chatApi,
|
||||
store: HybridCacheStore(
|
||||
memory: MemoryCacheStore(),
|
||||
persistent: PersistentCacheStore(),
|
||||
),
|
||||
);
|
||||
|
||||
var scope = 'user-a';
|
||||
CacheScope.configureProvider(() => scope);
|
||||
|
||||
await repository.loadHistory();
|
||||
scope = 'user-b';
|
||||
await repository.loadHistory();
|
||||
|
||||
expect(chatApi.historyCalls['first:default'], 2);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
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;
|
||||
|
||||
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 {}
|
||||
}
|
||||
|
||||
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>();
|
||||
service.loadHistoryHandler =
|
||||
({DateTime? beforeDate, bool forceRefresh = false}) {
|
||||
return completer.future;
|
||||
};
|
||||
|
||||
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(
|
||||
'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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:social_app/l10n/app_localizations.dart';
|
||||
import 'package:social_app/shared/widgets/ui_schema/ui_schema_renderer.dart';
|
||||
|
||||
Map<String, dynamic> _badgeSchema({
|
||||
required String label,
|
||||
required String status,
|
||||
}) {
|
||||
return {
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'children': [
|
||||
{'type': 'badge', 'label': label, 'status': status},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Widget _buildRendererHost(Map<String, dynamic> schema, Locale locale) {
|
||||
return MaterialApp(
|
||||
locale: locale,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return UiSchemaRenderer(context, colorScheme).renderSchema(schema);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buttonSchema(Map<String, dynamic> action) {
|
||||
return {
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'children': [
|
||||
{
|
||||
'type': 'button',
|
||||
'label': '查看详情',
|
||||
'style': 'primary',
|
||||
'action': action,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Widget _buildRouterHost(Map<String, dynamic> schema, Locale locale) {
|
||||
final router = GoRouter(
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
body: UiSchemaRenderer(context, colorScheme).renderSchema(schema),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/todo/123',
|
||||
builder: (context, state) => const Scaffold(body: Text('todo-detail')),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return MaterialApp.router(
|
||||
locale: locale,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('localizes stable status token labels', (tester) async {
|
||||
final schema = _badgeSchema(label: 'ui.status.success', status: 'success');
|
||||
await tester.pumpWidget(_buildRendererHost(schema, const Locale('zh')));
|
||||
|
||||
expect(find.text('已完成'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('localizes legacy uppercase status labels', (tester) async {
|
||||
final schema = _badgeSchema(label: 'SUCCESS', status: 'success');
|
||||
await tester.pumpWidget(_buildRendererHost(schema, const Locale('zh')));
|
||||
|
||||
expect(find.text('已完成'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('keeps unknown status token label unchanged', (tester) async {
|
||||
final schema = _badgeSchema(
|
||||
label: 'ui.status.processing',
|
||||
status: 'success',
|
||||
);
|
||||
await tester.pumpWidget(_buildRendererHost(schema, const Locale('en')));
|
||||
|
||||
expect(find.text('ui.status.processing'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('keeps custom badge label unchanged', (tester) async {
|
||||
final schema = _badgeSchema(label: '创建完成', status: 'success');
|
||||
await tester.pumpWidget(_buildRendererHost(schema, const Locale('zh')));
|
||||
|
||||
expect(find.text('创建完成'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('navigates when navigation action path is valid', (tester) async {
|
||||
final schema = _buttonSchema({
|
||||
'type': 'navigation',
|
||||
'path': '/todo/123',
|
||||
'params': {'from': 'assistant', 'focus': true},
|
||||
});
|
||||
|
||||
await tester.pumpWidget(_buildRouterHost(schema, const Locale('zh')));
|
||||
await tester.tap(find.text('查看详情'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('todo-detail'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows l10n error when navigation path is invalid', (
|
||||
tester,
|
||||
) async {
|
||||
final schema = _buttonSchema({
|
||||
'type': 'navigation',
|
||||
'path': 'https://example.com',
|
||||
});
|
||||
|
||||
await tester.pumpWidget(_buildRouterHost(schema, const Locale('zh')));
|
||||
await tester.tap(find.text('查看详情'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('导航路径无效'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('shows l10n error when url action is invalid', (tester) async {
|
||||
final schema = _buttonSchema({'type': 'url', 'url': 'javascript:alert(1)'});
|
||||
|
||||
await tester.pumpWidget(_buildRouterHost(schema, const Locale('zh')));
|
||||
await tester.tap(find.text('查看详情'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('链接无效'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('rejects encoded navigation path payload', (tester) async {
|
||||
final schema = _buttonSchema({
|
||||
'type': 'navigation',
|
||||
'path': '/calendar/events/%2F%2Fevil.example.com',
|
||||
});
|
||||
|
||||
await tester.pumpWidget(_buildRouterHost(schema, const Locale('zh')));
|
||||
await tester.tap(find.text('查看详情'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('导航路径无效'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('rejects dot-segment traversal navigation path', (tester) async {
|
||||
final schema = _buttonSchema({
|
||||
'type': 'navigation',
|
||||
'path': '/todo/../../settings',
|
||||
});
|
||||
|
||||
await tester.pumpWidget(_buildRouterHost(schema, const Locale('zh')));
|
||||
await tester.tap(find.text('查看详情'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('导航路径无效'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('rejects private-network URL action', (tester) async {
|
||||
final schema = _buttonSchema({
|
||||
'type': 'url',
|
||||
'url': 'http://127.0.0.1:8080/admin',
|
||||
});
|
||||
|
||||
await tester.pumpWidget(_buildRouterHost(schema, const Locale('zh')));
|
||||
await tester.tap(find.text('查看详情'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('链接无效'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('rejects ipv6 loopback URL action', (tester) async {
|
||||
final schema = _buttonSchema({
|
||||
'type': 'url',
|
||||
'url': 'http://[0:0:0:0:0:0:0:1]:8080/admin',
|
||||
});
|
||||
|
||||
await tester.pumpWidget(_buildRouterHost(schema, const Locale('zh')));
|
||||
await tester.tap(find.text('查看详情'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('链接无效'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user