feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user