refactor: 移除前端 Mock API,新增共享组件,优化认证流程
- 删除 mock_api_client、mock_calendar_service、mock_history_service - 新增 fixed_length_code_input、link_button、message_composer 共享组件 - 优化登录/注册/密码重置页面使用新组件 - 简化 injection.dart 移除 mock 分支 - 更新 env.dart 配置(BACKEND_URL 替换 API_URL) - 后端 agentscope 工具和测试更新 - 重构 AGENTS.md 文档结构 - 新增 deploy/ 目录和 protocol 文档
This commit is contained in:
@@ -1,140 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/core/api/mock_api_client.dart';
|
||||
import 'package:social_app/features/calendar/data/calendar_api.dart';
|
||||
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
|
||||
|
||||
void main() {
|
||||
group('CalendarApi', () {
|
||||
test('listByRange parses metadata and attachments', () async {
|
||||
final client = MockApiClient();
|
||||
client.registerPatternHandler(
|
||||
RegExp(r'^/api/v1/schedule-items\?.*$'),
|
||||
'GET',
|
||||
(_) => [
|
||||
{
|
||||
'id': 'evt_1',
|
||||
'title': '晨会',
|
||||
'description': '同步',
|
||||
'start_at': '2026-03-11T01:00:00Z',
|
||||
'end_at': '2026-03-11T02:00:00Z',
|
||||
'timezone': 'Asia/Shanghai',
|
||||
'metadata': {
|
||||
'color': '#4F46E5',
|
||||
'location': '会议室A',
|
||||
'notes': '带电脑',
|
||||
'reminder_minutes': 15,
|
||||
'attachments': [
|
||||
{
|
||||
'name': '议程文档',
|
||||
'visible_to': ['u1'],
|
||||
'url': 'https://example.com/a',
|
||||
'note': '会前阅读',
|
||||
'content': null,
|
||||
'type': 'document',
|
||||
},
|
||||
],
|
||||
'version': 1,
|
||||
'new_field': 'future',
|
||||
},
|
||||
'status': 'active',
|
||||
'source_type': 'manual',
|
||||
'created_at': '2026-03-10T01:00:00Z',
|
||||
'updated_at': '2026-03-10T01:30:00Z',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
final api = CalendarApi(client);
|
||||
final result = await api.listByRange(
|
||||
startAt: DateTime.utc(2026, 3, 1),
|
||||
endAt: DateTime.utc(2026, 3, 31, 23, 59, 59),
|
||||
);
|
||||
|
||||
expect(result, hasLength(1));
|
||||
expect(result.first.metadata?.attachments, hasLength(1));
|
||||
expect(result.first.metadata?.raw['new_field'], 'future');
|
||||
expect(result.first.metadata?.reminderMinutes, 15);
|
||||
expect(result.first.startAt.isUtc, isFalse);
|
||||
});
|
||||
|
||||
test('create serializes full metadata', () async {
|
||||
final client = MockApiClient();
|
||||
client.registerHandler('/api/v1/schedule-items', 'POST', (request) {
|
||||
final body = request.data as Map<String, dynamic>;
|
||||
expect(body['metadata']['version'], 1);
|
||||
expect(body['metadata']['reminder_minutes'], 15);
|
||||
expect(body['metadata']['attachments'], isA<List<dynamic>>());
|
||||
return {
|
||||
'id': 'evt_2',
|
||||
...body,
|
||||
'status': 'active',
|
||||
'source_type': 'manual',
|
||||
'created_at': '2026-03-10T01:00:00Z',
|
||||
'updated_at': '2026-03-10T01:00:00Z',
|
||||
};
|
||||
});
|
||||
|
||||
final api = CalendarApi(client);
|
||||
final created = await api.create(
|
||||
ScheduleItemModel(
|
||||
id: 'evt_local',
|
||||
ownerId: 'user-1',
|
||||
title: '评审',
|
||||
startAt: DateTime.utc(2026, 3, 11, 3),
|
||||
endAt: DateTime.utc(2026, 3, 11, 4),
|
||||
metadata: ScheduleMetadata(
|
||||
color: '#F59E0B',
|
||||
location: '线上',
|
||||
notes: '准备 demo',
|
||||
attachments: [Attachment(name: 'PRD', type: 'document')],
|
||||
reminderMinutes: 15,
|
||||
version: 1,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(created.id, 'evt_2');
|
||||
expect(created.metadata?.location, '线上');
|
||||
});
|
||||
|
||||
test('update does not send unknown metadata fields', () async {
|
||||
final client = MockApiClient();
|
||||
client.registerHandler('/api/v1/schedule-items/evt_3', 'PATCH', (
|
||||
request,
|
||||
) {
|
||||
final body = request.data as Map<String, dynamic>;
|
||||
final metadata = body['metadata'] as Map<String, dynamic>;
|
||||
expect(metadata.containsKey('new_field'), isFalse);
|
||||
expect(metadata['reminder_minutes'], 30);
|
||||
return {
|
||||
'id': 'evt_3',
|
||||
...body,
|
||||
'status': 'active',
|
||||
'source_type': 'manual',
|
||||
'created_at': '2026-03-10T01:00:00Z',
|
||||
'updated_at': '2026-03-11T01:00:00Z',
|
||||
};
|
||||
});
|
||||
|
||||
final api = CalendarApi(client);
|
||||
final event = ScheduleItemModel(
|
||||
id: 'evt_3',
|
||||
ownerId: 'user-1',
|
||||
title: '同步会',
|
||||
startAt: DateTime.utc(2026, 3, 11, 1),
|
||||
metadata: ScheduleMetadata.fromJson({
|
||||
'color': '#3B82F6',
|
||||
'location': '会议室B',
|
||||
'notes': '更新周报',
|
||||
'attachments': const [],
|
||||
'version': 1,
|
||||
'reminder_minutes': 30,
|
||||
'new_field': 'future',
|
||||
}),
|
||||
);
|
||||
|
||||
final updated = await api.update(event);
|
||||
expect(updated.id, 'evt_3');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:social_app/core/di/injection.dart';
|
||||
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
|
||||
import 'package:social_app/features/calendar/data/services/mock_calendar_service.dart';
|
||||
import 'package:social_app/features/calendar/ui/screens/calendar_event_detail_screen.dart';
|
||||
|
||||
class _FakeCalendarService extends CalendarService {
|
||||
final ScheduleItemModel? event;
|
||||
|
||||
_FakeCalendarService({required this.event}) : super(apiClient: null);
|
||||
|
||||
@override
|
||||
Future<ScheduleItemModel?> getEventById(String id) async {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
final getIt = GetIt.instance;
|
||||
|
||||
setUp(() async {
|
||||
await getIt.reset();
|
||||
});
|
||||
|
||||
testWidgets('详情页显示结构化提醒时间并不显示metadata原样区块', (tester) async {
|
||||
sl.registerSingleton<CalendarService>(
|
||||
_FakeCalendarService(
|
||||
event: ScheduleItemModel(
|
||||
id: 'evt_1',
|
||||
ownerId: 'user-1',
|
||||
title: '评审会',
|
||||
startAt: DateTime(2026, 3, 11, 15, 0),
|
||||
endAt: DateTime(2026, 3, 11, 16, 0),
|
||||
metadata: ScheduleMetadata(
|
||||
color: '#4F46E5',
|
||||
location: '会议室A',
|
||||
reminderMinutes: 15,
|
||||
version: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: CalendarEventDetailScreen(eventId: 'evt_1')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('提醒时间'), findsOneWidget);
|
||||
expect(find.text('开始前15分钟'), findsOneWidget);
|
||||
expect(find.text('metadata'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('提醒分钟为空时显示无', (tester) async {
|
||||
sl.registerSingleton<CalendarService>(
|
||||
_FakeCalendarService(
|
||||
event: ScheduleItemModel(
|
||||
id: 'evt_2',
|
||||
ownerId: 'user-1',
|
||||
title: '同步会',
|
||||
startAt: DateTime(2026, 3, 12, 10, 0),
|
||||
metadata: ScheduleMetadata(version: 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: CalendarEventDetailScreen(eventId: 'evt_2')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('提醒时间'), findsOneWidget);
|
||||
expect(find.text('无'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -1,603 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:social_app/core/api/mock_api_client.dart';
|
||||
import 'package:social_app/features/chat/data/ai/ai_decision_engine.dart';
|
||||
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
|
||||
import 'package:social_app/features/chat/data/tools/route_navigation_tool.dart';
|
||||
import 'package:social_app/features/chat/data/tools/tool_registry.dart';
|
||||
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
|
||||
|
||||
class TestableAgUiService extends AgUiService {
|
||||
TestableAgUiService({super.onEvent});
|
||||
|
||||
@override
|
||||
Future<void> sendMessage(String content, {List<XFile>? images}) async {
|
||||
await mockEventStream(content);
|
||||
}
|
||||
|
||||
Future<void> mockEventStream(String content) async {
|
||||
final threadId = 'thread_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final runId = 'run_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final engine = AiDecisionEngine();
|
||||
|
||||
onEvent(RunStartedEvent(threadId: threadId, runId: runId));
|
||||
|
||||
final forceTrigger = engine.tryForceTrigger(content);
|
||||
if (forceTrigger != null) {
|
||||
await mockToolCallFlowWithArgs(forceTrigger.toolName, forceTrigger.args);
|
||||
}
|
||||
|
||||
final replies = generateReplies(content, engine);
|
||||
if (replies.isNotEmpty) {
|
||||
await mockTextMessageStream(replies);
|
||||
}
|
||||
|
||||
onEvent(RunFinishedEvent(threadId: threadId, runId: runId));
|
||||
}
|
||||
|
||||
Future<void> mockToolCallFlowWithArgs(
|
||||
String toolName,
|
||||
Map<String, dynamic> args,
|
||||
) async {
|
||||
final toolCallId = 'tc_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
onEvent(ToolCallStartEvent(toolCallId: toolCallId, toolCallName: toolName));
|
||||
|
||||
onEvent(ToolCallArgsEvent(toolCallId: toolCallId, delta: '{}'));
|
||||
|
||||
onEvent(ToolCallEndEvent(toolCallId: toolCallId));
|
||||
|
||||
if (toolName == 'front.navigate_to_route') {
|
||||
return;
|
||||
}
|
||||
|
||||
final validation = ToolRegistry.validateArgs(toolName, args);
|
||||
if (!validation.ok) {
|
||||
onEvent(
|
||||
ToolCallErrorEvent(
|
||||
toolCallId: toolCallId,
|
||||
error: validation.error ?? 'Validation failed',
|
||||
code: 'VALIDATION_ERROR',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ToolRegistry.initialize();
|
||||
await ToolRegistry.execute(toolName, args);
|
||||
final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
onEvent(
|
||||
ToolCallResultEvent(
|
||||
messageId: messageId,
|
||||
toolCallId: toolCallId,
|
||||
content: '{"result":{"ok":true}}',
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
onEvent(
|
||||
ToolCallErrorEvent(
|
||||
toolCallId: toolCallId,
|
||||
error: e.toString(),
|
||||
code: 'EXECUTION_ERROR',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> generateReplies(String content, AiDecisionEngine engine) {
|
||||
final intent = engine.matchIntent(content);
|
||||
|
||||
switch (intent) {
|
||||
case Intent.createEvent:
|
||||
return ['好的,我已经为您创建了日程安排。'];
|
||||
case Intent.searchEvent:
|
||||
return ['您今天有以下日程:\n- 10:00 团队会议\n- 14:00 产品评审'];
|
||||
case Intent.unknown:
|
||||
return ['我理解了您的问题,让我来帮您处理。'];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> mockTextMessageStream(List<String> replies) async {
|
||||
for (final reply in replies) {
|
||||
final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
onEvent(TextMessageStartEvent(messageId: messageId, role: 'assistant'));
|
||||
|
||||
onEvent(TextMessageContentEvent(messageId: messageId, delta: reply));
|
||||
|
||||
onEvent(TextMessageEndEvent(messageId: messageId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late TestableAgUiService service;
|
||||
late List<AgUiEvent> capturedEvents;
|
||||
|
||||
setUp(() {
|
||||
capturedEvents = [];
|
||||
ToolRegistry.initialize();
|
||||
RouteNavigationTool.instance.clearNavigator();
|
||||
service = TestableAgUiService(
|
||||
onEvent: (event) {
|
||||
capturedEvents.add(event);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('AgUiService', () {
|
||||
test('sendMessage first emits RunStartedEvent', () async {
|
||||
await service.sendMessage('你好');
|
||||
|
||||
expect(capturedEvents.first, isA<RunStartedEvent>());
|
||||
});
|
||||
|
||||
test('sendMessage last emits RunFinishedEvent', () async {
|
||||
await service.sendMessage('你好');
|
||||
|
||||
expect(capturedEvents.last, isA<RunFinishedEvent>());
|
||||
});
|
||||
|
||||
test('sendMessage emits events in correct order', () async {
|
||||
await service.sendMessage('你好');
|
||||
|
||||
expect(capturedEvents.first, isA<RunStartedEvent>());
|
||||
expect(capturedEvents.last, isA<RunFinishedEvent>());
|
||||
|
||||
final types = capturedEvents.map((e) => e.type).toList();
|
||||
expect(types.first, AgUiEventType.runStarted);
|
||||
expect(types.last, AgUiEventType.runFinished);
|
||||
});
|
||||
|
||||
test(
|
||||
'creating schedule text does not trigger frontend tool call events',
|
||||
() async {
|
||||
await service.sendMessage('提醒我明天10点开会');
|
||||
|
||||
final toolCallStarts = capturedEvents
|
||||
.whereType<ToolCallStartEvent>()
|
||||
.toList();
|
||||
final toolCallEnds = capturedEvents
|
||||
.whereType<ToolCallEndEvent>()
|
||||
.toList();
|
||||
final toolCallResults = capturedEvents
|
||||
.whereType<ToolCallResultEvent>()
|
||||
.toList();
|
||||
|
||||
expect(toolCallStarts.isEmpty, true);
|
||||
expect(toolCallEnds.isEmpty, true);
|
||||
expect(toolCallResults.isEmpty, true);
|
||||
},
|
||||
);
|
||||
|
||||
test('force trigger with #tool syntax', () async {
|
||||
await service.sendMessage(
|
||||
'#tool:front.navigate_to_route {"target": "/calendar/dayweek"}',
|
||||
);
|
||||
|
||||
final toolCallStarts = capturedEvents
|
||||
.whereType<ToolCallStartEvent>()
|
||||
.toList();
|
||||
|
||||
expect(toolCallStarts.isNotEmpty, true);
|
||||
expect(toolCallStarts.first.toolCallName, 'front.navigate_to_route');
|
||||
});
|
||||
|
||||
test('text message events are emitted for unknown intent', () async {
|
||||
await service.sendMessage('你好');
|
||||
|
||||
final textStarts = capturedEvents
|
||||
.whereType<TextMessageStartEvent>()
|
||||
.toList();
|
||||
final textContents = capturedEvents
|
||||
.whereType<TextMessageContentEvent>()
|
||||
.toList();
|
||||
final textEnds = capturedEvents.whereType<TextMessageEndEvent>().toList();
|
||||
|
||||
expect(textStarts.isNotEmpty, true);
|
||||
expect(textContents.isNotEmpty, true);
|
||||
expect(textEnds.isNotEmpty, true);
|
||||
});
|
||||
|
||||
test('search intent does not trigger tool calls', () async {
|
||||
await service.sendMessage('今天有什么日程');
|
||||
|
||||
final toolCallStarts = capturedEvents
|
||||
.whereType<ToolCallStartEvent>()
|
||||
.toList();
|
||||
|
||||
expect(toolCallStarts.isEmpty, true);
|
||||
});
|
||||
|
||||
test('frontend tool call keeps pending state before approval', () async {
|
||||
await service.sendMessage('#tool:front.navigate_to_route {}');
|
||||
|
||||
final toolCallErrors = capturedEvents
|
||||
.whereType<ToolCallErrorEvent>()
|
||||
.toList();
|
||||
final toolCallStarts = capturedEvents
|
||||
.whereType<ToolCallStartEvent>()
|
||||
.toList();
|
||||
|
||||
expect(toolCallStarts.isNotEmpty, true);
|
||||
expect(toolCallErrors.isEmpty, true);
|
||||
});
|
||||
});
|
||||
|
||||
group('AgUiService real api-path mock', () {
|
||||
test('sendMessage posts only current user message to run API', () async {
|
||||
final client = MockApiClient();
|
||||
final service = AgUiService(onEvent: (_) {}, apiClient: client);
|
||||
client.clearMocks();
|
||||
|
||||
Map<String, dynamic>? postedRunInput;
|
||||
client.registerHandler('/api/v1/agent/runs', 'POST', (request) {
|
||||
postedRunInput = request.data as Map<String, dynamic>;
|
||||
return {
|
||||
'taskId': 'task-1',
|
||||
'threadId': 'thread-1',
|
||||
'runId': 'run-1',
|
||||
'created': false,
|
||||
};
|
||||
});
|
||||
client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) {
|
||||
return <String>[
|
||||
'event: RUN_STARTED',
|
||||
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
'event: RUN_FINISHED',
|
||||
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
];
|
||||
});
|
||||
|
||||
await service.sendMessage('只发送当前输入');
|
||||
|
||||
expect(postedRunInput, isNotNull);
|
||||
final messages = postedRunInput!['messages'] as List<dynamic>;
|
||||
expect(messages.length, 1);
|
||||
final first = messages.first as Map<String, dynamic>;
|
||||
expect(first['role'], 'user');
|
||||
expect(first['content'], '只发送当前输入');
|
||||
});
|
||||
|
||||
test('sendMessage uploads images then posts binary url blocks', () async {
|
||||
final client = MockApiClient();
|
||||
final service = AgUiService(onEvent: (_) {}, apiClient: client);
|
||||
client.clearMocks();
|
||||
|
||||
var uploadCalls = 0;
|
||||
final uploadedPath = 'agent-inputs/user/thread-1/upload-1.png';
|
||||
client.registerHandler('/api/v1/agent/attachments', 'POST', (request) {
|
||||
uploadCalls += 1;
|
||||
return {
|
||||
'attachment': {
|
||||
'bucket': 'bucket-test',
|
||||
'path': uploadedPath,
|
||||
'mimeType': 'image/png',
|
||||
'url': 'https://signed.example/$uploadedPath',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Map<String, dynamic>? postedRunInput;
|
||||
client.registerHandler('/api/v1/agent/runs', 'POST', (request) {
|
||||
postedRunInput = request.data as Map<String, dynamic>;
|
||||
return {
|
||||
'taskId': 'task-1',
|
||||
'threadId': 'thread-1',
|
||||
'runId': 'run-1',
|
||||
'created': false,
|
||||
};
|
||||
});
|
||||
client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) {
|
||||
return <String>[
|
||||
'event: RUN_STARTED',
|
||||
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
'event: RUN_FINISHED',
|
||||
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
];
|
||||
});
|
||||
|
||||
final image = XFile.fromData(
|
||||
Uint8List.fromList(<int>[1, 2, 3]),
|
||||
mimeType: 'image/png',
|
||||
name: 'demo.png',
|
||||
);
|
||||
|
||||
await service.sendMessage('图文消息', images: [image]);
|
||||
|
||||
expect(uploadCalls, 1);
|
||||
expect(postedRunInput, isNotNull);
|
||||
final messages = postedRunInput!['messages'] as List<dynamic>;
|
||||
final first = messages.first as Map<String, dynamic>;
|
||||
final content = first['content'] as List<dynamic>;
|
||||
expect((content.first as Map<String, dynamic>)['type'], 'text');
|
||||
expect((content[1] as Map<String, dynamic>)['type'], 'binary');
|
||||
expect(
|
||||
(content[1] as Map<String, dynamic>)['url'],
|
||||
'https://signed.example/$uploadedPath',
|
||||
);
|
||||
final forwardedProps =
|
||||
postedRunInput!['forwardedProps'] as Map<String, dynamic>;
|
||||
final attachments = forwardedProps['attachments'] as List<dynamic>;
|
||||
expect((attachments.first as Map<String, dynamic>)['path'], uploadedPath);
|
||||
});
|
||||
|
||||
test('approveToolCall posts only tool message to resume API', () async {
|
||||
final client = MockApiClient();
|
||||
final service = AgUiService(onEvent: (_) {}, apiClient: client);
|
||||
RouteNavigationTool.instance.bindNavigator((_, {replace = false}) {
|
||||
final _ = replace;
|
||||
});
|
||||
client.clearMocks();
|
||||
|
||||
client.registerHandler('/api/v1/agent/runs', 'POST', (_) {
|
||||
return {
|
||||
'taskId': 'task-1',
|
||||
'threadId': 'thread-1',
|
||||
'runId': 'run-1',
|
||||
'created': false,
|
||||
};
|
||||
});
|
||||
var eventCallCount = 0;
|
||||
client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) {
|
||||
eventCallCount += 1;
|
||||
if (eventCallCount == 1) {
|
||||
return <String>[
|
||||
'event: RUN_STARTED',
|
||||
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
'event: RUN_FINISHED',
|
||||
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
];
|
||||
}
|
||||
return <String>[
|
||||
'event: RUN_STARTED',
|
||||
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-2"}',
|
||||
'',
|
||||
'event: RUN_FINISHED',
|
||||
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-2"}',
|
||||
'',
|
||||
];
|
||||
});
|
||||
|
||||
Map<String, dynamic>? postedResumeInput;
|
||||
client.registerHandler('/api/v1/agent/runs/thread-1/resume', 'POST', (
|
||||
request,
|
||||
) {
|
||||
postedResumeInput = request.data as Map<String, dynamic>;
|
||||
return {
|
||||
'taskId': 'task-2',
|
||||
'threadId': 'thread-1',
|
||||
'runId': 'run-2',
|
||||
'created': false,
|
||||
};
|
||||
});
|
||||
|
||||
await service.sendMessage('初始化会话');
|
||||
await service.approveToolCall(
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'front.navigate_to_route',
|
||||
args: {
|
||||
'target': '/calendar/dayweek',
|
||||
'replace': false,
|
||||
'__nonce': 'nonce-1',
|
||||
},
|
||||
);
|
||||
|
||||
expect(postedResumeInput, isNotNull);
|
||||
final messages = postedResumeInput!['messages'] as List<dynamic>;
|
||||
expect(messages.length, 1);
|
||||
final first = messages.first as Map<String, dynamic>;
|
||||
expect(first['role'], 'tool');
|
||||
expect(first.containsKey('toolCallId'), true);
|
||||
});
|
||||
|
||||
test('approveToolCall resumes and emits TOOL_CALL_RESULT', () async {
|
||||
final events = <AgUiEvent>[];
|
||||
final realService = AgUiService(onEvent: events.add);
|
||||
RouteNavigationTool.instance.bindNavigator((_, {replace = false}) {
|
||||
final _ = replace;
|
||||
});
|
||||
|
||||
await realService.sendMessage('打开日历页面');
|
||||
|
||||
final toolStart = events.whereType<ToolCallStartEvent>().first;
|
||||
final toolArgsEvent = events.whereType<ToolCallArgsEvent>().firstWhere(
|
||||
(e) => e.toolCallId == toolStart.toolCallId,
|
||||
);
|
||||
final toolArgs = jsonDecode(toolArgsEvent.delta) as Map<String, dynamic>;
|
||||
expect(toolStart.toolCallName, 'front.navigate_to_route');
|
||||
expect(
|
||||
events
|
||||
.whereType<ToolCallResultEvent>()
|
||||
.where((e) => e.toolCallId == toolStart.toolCallId)
|
||||
.isEmpty,
|
||||
true,
|
||||
);
|
||||
|
||||
await realService.approveToolCall(
|
||||
toolCallId: toolStart.toolCallId,
|
||||
toolName: 'front.navigate_to_route',
|
||||
args: toolArgs,
|
||||
);
|
||||
|
||||
final results = events
|
||||
.whereType<ToolCallResultEvent>()
|
||||
.where((e) => e.toolCallId == toolStart.toolCallId)
|
||||
.toList();
|
||||
expect(results.isNotEmpty, true);
|
||||
});
|
||||
|
||||
test('approveToolCall aborts when local tool execution fails', () async {
|
||||
final events = <AgUiEvent>[];
|
||||
final realService = AgUiService(onEvent: events.add);
|
||||
|
||||
await realService.sendMessage('打开日历页面');
|
||||
final toolStart = events.whereType<ToolCallStartEvent>().first;
|
||||
final toolArgsEvent = events.whereType<ToolCallArgsEvent>().firstWhere(
|
||||
(e) => e.toolCallId == toolStart.toolCallId,
|
||||
);
|
||||
final toolArgs = jsonDecode(toolArgsEvent.delta) as Map<String, dynamic>;
|
||||
|
||||
// replace navigator -> true 会失败,因为未绑定 navigator。
|
||||
toolArgs['target'] = '/settings';
|
||||
expect(
|
||||
() => realService.approveToolCall(
|
||||
toolCallId: toolStart.toolCallId,
|
||||
toolName: 'front.navigate_to_route',
|
||||
args: toolArgs,
|
||||
),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('stream ignores malformed SSE payload and continues', () async {
|
||||
final events = <AgUiEvent>[];
|
||||
final client = MockApiClient();
|
||||
final service = AgUiService(onEvent: events.add, apiClient: client);
|
||||
client.clearMocks();
|
||||
client.registerHandler('/api/v1/agent/runs', 'POST', (_) {
|
||||
return {
|
||||
'taskId': 'task-1',
|
||||
'threadId': 'thread-1',
|
||||
'runId': 'run-1',
|
||||
'created': false,
|
||||
};
|
||||
});
|
||||
client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) {
|
||||
return <String>[
|
||||
'event: RUN_STARTED',
|
||||
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
'event: TEXT_MESSAGE_CONTENT',
|
||||
'data: {bad-json',
|
||||
'',
|
||||
'event: TEXT_MESSAGE_CONTENT',
|
||||
'data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"m1","delta":"ok"}',
|
||||
'',
|
||||
'event: RUN_FINISHED',
|
||||
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
];
|
||||
});
|
||||
|
||||
await service.sendMessage('hi');
|
||||
|
||||
expect(events.whereType<RunStartedEvent>().length, 1);
|
||||
expect(events.whereType<TextMessageContentEvent>().length, 1);
|
||||
expect(events.whereType<RunFinishedEvent>().length, 1);
|
||||
});
|
||||
|
||||
test('subsequent SSE requests carry Last-Event-ID header', () async {
|
||||
final client = MockApiClient();
|
||||
final service = AgUiService(onEvent: (_) {}, apiClient: client);
|
||||
client.clearMocks();
|
||||
var runCount = 0;
|
||||
final seenLastEventIds = <String?>[];
|
||||
client.registerHandler('/api/v1/agent/runs', 'POST', (_) {
|
||||
runCount += 1;
|
||||
return {
|
||||
'taskId': 'task-$runCount',
|
||||
'threadId': 'thread-1',
|
||||
'runId': 'run-$runCount',
|
||||
'created': false,
|
||||
};
|
||||
});
|
||||
client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (
|
||||
request,
|
||||
) {
|
||||
seenLastEventIds.add(request.headers?['Last-Event-ID']);
|
||||
if (runCount == 1) {
|
||||
return <String>[
|
||||
'id: 1-0',
|
||||
'event: RUN_STARTED',
|
||||
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
'id: 2-0',
|
||||
'event: RUN_FINISHED',
|
||||
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
];
|
||||
}
|
||||
return <String>[
|
||||
'id: 3-0',
|
||||
'event: RUN_STARTED',
|
||||
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-2"}',
|
||||
'',
|
||||
'id: 4-0',
|
||||
'event: RUN_FINISHED',
|
||||
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-2"}',
|
||||
'',
|
||||
];
|
||||
});
|
||||
|
||||
await service.sendMessage('first');
|
||||
await service.sendMessage('second');
|
||||
|
||||
expect(seenLastEventIds.length, 2);
|
||||
expect(seenLastEventIds[0], isNull);
|
||||
expect(seenLastEventIds[1], '2-0');
|
||||
});
|
||||
|
||||
test('stream parses backend TOOL_CALL_RESULT payload with ui field', () async {
|
||||
final events = <AgUiEvent>[];
|
||||
final client = MockApiClient();
|
||||
final service = AgUiService(onEvent: events.add, apiClient: client);
|
||||
client.clearMocks();
|
||||
client.registerHandler('/api/v1/agent/runs', 'POST', (_) {
|
||||
return {
|
||||
'taskId': 'task-1',
|
||||
'threadId': 'thread-1',
|
||||
'runId': 'run-1',
|
||||
'created': false,
|
||||
};
|
||||
});
|
||||
client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) {
|
||||
return <String>[
|
||||
'event: RUN_STARTED',
|
||||
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
'event: TOOL_CALL_RESULT',
|
||||
'data: {"type":"TOOL_CALL_RESULT","messageId":"tool-result-1","toolCallId":"call-1","callId":"call-1","toolName":"calendar_write","result":{"type":"calendar_operation.v1","version":"v1","data":{"ok":true,"operation":"create"},"actions":[]},"ui":{"type":"calendar_operation.v1","version":"v1","data":{"ok":true,"operation":"create"},"actions":[]},"content":"已创建日程:项目评审(明天 10:00)"}',
|
||||
'',
|
||||
'event: RUN_FINISHED',
|
||||
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
];
|
||||
});
|
||||
|
||||
await service.sendMessage('创建日程');
|
||||
|
||||
final result = events.whereType<ToolCallResultEvent>().toList();
|
||||
expect(result.length, 1);
|
||||
expect(result.first.ui?.cardType, 'calendar_operation.v1');
|
||||
});
|
||||
|
||||
test('fetchAttachmentPreview returns binary bytes', () async {
|
||||
final client = MockApiClient();
|
||||
final service = AgUiService(onEvent: (_) {}, apiClient: client);
|
||||
client.clearMocks();
|
||||
client.registerHandler(
|
||||
'/api/v1/agent/runs/t1/attachments/m1/0',
|
||||
'GET',
|
||||
(_) => <int>[1, 2, 3, 4],
|
||||
);
|
||||
|
||||
final data = await service.fetchAttachmentPreview(
|
||||
'/api/v1/agent/runs/t1/attachments/m1/0',
|
||||
);
|
||||
|
||||
expect(data, [1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image_picker/image_picker.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 MockAgUiService extends AgUiService {
|
||||
MockAgUiService() : super(onEvent: (_) {});
|
||||
|
||||
int previewCalls = 0;
|
||||
|
||||
@override
|
||||
Future<void> sendMessage(String content, {List<XFile>? images}) async {}
|
||||
|
||||
@override
|
||||
Future<Uint8List> fetchAttachmentPreview(String previewPath) async {
|
||||
previewCalls += 1;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
return Uint8List.fromList(<int>[1, 2, 3]);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThrowingAgUiService extends AgUiService {
|
||||
_ThrowingAgUiService() : super(onEvent: (_) {});
|
||||
|
||||
@override
|
||||
Future<void> sendMessage(String content, {List<XFile>? images}) async {
|
||||
throw StateError('network down');
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late ChatBloc chatBloc;
|
||||
late AgUiService service;
|
||||
|
||||
setUp(() {
|
||||
service = MockAgUiService();
|
||||
chatBloc = ChatBloc(service: service);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
chatBloc.close();
|
||||
});
|
||||
|
||||
group('ChatBloc', () {
|
||||
test('initial state is empty', () {
|
||||
expect(chatBloc.state.items, isEmpty);
|
||||
expect(chatBloc.state.isLoading, false);
|
||||
expect(chatBloc.state.isSending, false);
|
||||
expect(chatBloc.state.isWaitingFirstToken, false);
|
||||
expect(chatBloc.state.isStreaming, false);
|
||||
expect(chatBloc.state.currentMessageId, isNull);
|
||||
expect(chatBloc.state.error, isNull);
|
||||
});
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'sendMessage adds user message to items',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) => bloc.sendMessage('Hello'),
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((state) => state.items.length, 'items length', 1)
|
||||
.having((state) => state.isSending, 'isSending', true)
|
||||
.having(
|
||||
(state) => state.isWaitingFirstToken,
|
||||
'isWaitingFirstToken',
|
||||
true,
|
||||
)
|
||||
.having(
|
||||
(state) => state.items.first,
|
||||
'first item',
|
||||
isA<TextMessageItem>().having(
|
||||
(item) => item.content,
|
||||
'content',
|
||||
'Hello',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'textMessageStart event adds AI message with streaming',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) {
|
||||
bloc.emit(chatBloc.state.copyWith(isStreaming: true));
|
||||
service.onEvent(
|
||||
TextMessageStartEvent(messageId: 'msg_1', role: 'assistant'),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>().having((s) => s.isStreaming, 'isStreaming', true),
|
||||
isA<ChatState>()
|
||||
.having((s) => s.items.length, 'items length', 1)
|
||||
.having((s) => s.currentMessageId, 'currentMessageId', 'msg_1')
|
||||
.having(
|
||||
(s) => s.items.first,
|
||||
'first item',
|
||||
isA<TextMessageItem>()
|
||||
.having((item) => item.isStreaming, 'isStreaming', true)
|
||||
.having((item) => item.sender, 'sender', MessageSender.ai),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'textMessageContent event appends content',
|
||||
build: () => chatBloc,
|
||||
seed: () => ChatState(
|
||||
items: [
|
||||
TextMessageItem(
|
||||
id: 'msg_1',
|
||||
content: '',
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
isStreaming: true,
|
||||
),
|
||||
],
|
||||
currentMessageId: 'msg_1',
|
||||
),
|
||||
act: (bloc) {
|
||||
service.onEvent(
|
||||
TextMessageContentEvent(messageId: 'msg_1', delta: 'Hello'),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>().having(
|
||||
(s) => (s.items.first as TextMessageItem).content,
|
||||
'content',
|
||||
'Hello',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'textMessageEnd event sets isStreaming to false',
|
||||
build: () => chatBloc,
|
||||
seed: () => ChatState(
|
||||
items: [
|
||||
TextMessageItem(
|
||||
id: 'msg_1',
|
||||
content: 'Hello World',
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
isStreaming: true,
|
||||
),
|
||||
],
|
||||
currentMessageId: 'msg_1',
|
||||
),
|
||||
act: (bloc) {
|
||||
service.onEvent(TextMessageEndEvent(messageId: 'msg_1'));
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.currentMessageId, 'currentMessageId', isNull)
|
||||
.having((s) => s.isStreaming, 'isStreaming', false)
|
||||
.having(
|
||||
(s) => (s.items.first as TextMessageItem).isStreaming,
|
||||
'isStreaming',
|
||||
false,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'runStarted sets isLoading to true',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) {
|
||||
service.onEvent(RunStartedEvent(threadId: 't1', runId: 'r1'));
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isLoading, 'isLoading', true)
|
||||
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', true)
|
||||
.having((s) => s.error, 'error', isNull),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'runFinished sets isLoading to false',
|
||||
build: () => chatBloc,
|
||||
seed: () => const ChatState(isWaitingFirstToken: true),
|
||||
act: (bloc) {
|
||||
service.onEvent(RunFinishedEvent(threadId: 't1', runId: 'r1'));
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isLoading, 'isLoading', false)
|
||||
.having((s) => s.currentMessageId, 'currentMessageId', isNull),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'step events update currentStage',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) {
|
||||
service.onEvent(StepStartedEvent(stepName: 'execution'));
|
||||
service.onEvent(StepFinishedEvent(stepName: 'execution'));
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>().having(
|
||||
(s) => s.currentStage,
|
||||
'currentStage',
|
||||
AgentStage.execution,
|
||||
),
|
||||
isA<ChatState>().having((s) => s.currentStage, 'currentStage', isNull),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'runError sets error message',
|
||||
build: () => chatBloc,
|
||||
seed: () => const ChatState(isWaitingFirstToken: true),
|
||||
act: (bloc) {
|
||||
service.onEvent(
|
||||
RunErrorEvent(message: 'Something went wrong', code: 'ERR'),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isLoading, 'isLoading', false)
|
||||
.having((s) => s.currentMessageId, 'currentMessageId', isNull)
|
||||
.having((s) => s.error, 'error', 'Something went wrong'),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'cancelCurrentRun exits waiting states',
|
||||
build: () => chatBloc,
|
||||
seed: () => const ChatState(isWaitingFirstToken: true),
|
||||
act: (bloc) => bloc.cancelCurrentRun(),
|
||||
expect: () => [
|
||||
isA<ChatState>().having((s) => s.isCancelling, 'isCancelling', true),
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', false)
|
||||
.having((s) => s.isStreaming, 'isStreaming', false)
|
||||
.having((s) => s.isCancelling, 'isCancelling', false),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'sendMessage failure emits error and exits waiting state',
|
||||
build: () => ChatBloc(service: _ThrowingAgUiService()),
|
||||
act: (bloc) => bloc.sendMessage('hello'),
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isSending, 'isSending', true)
|
||||
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', true),
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isSending, 'isSending', false)
|
||||
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', false)
|
||||
.having((s) => s.error, 'error', contains('network down')),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'clearError removes error',
|
||||
build: () => chatBloc,
|
||||
seed: () => const ChatState(error: 'Some error'),
|
||||
act: (bloc) => bloc.clearError(),
|
||||
expect: () => [isA<ChatState>().having((s) => s.error, 'error', isNull)],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'toolCallStart adds ToolCallItem',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) {
|
||||
service.onEvent(
|
||||
ToolCallStartEvent(
|
||||
toolCallId: 'tc_1',
|
||||
toolCallName: 'back.mutate_calendar_event',
|
||||
),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>().having(
|
||||
(s) {
|
||||
final item = s.items.first;
|
||||
return item is ToolCallItem &&
|
||||
item.toolName == 'back.mutate_calendar_event' &&
|
||||
item.status == ToolCallStatus.pending;
|
||||
},
|
||||
'has pending tool call',
|
||||
true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'toolCallResult without ui removes pending tool call and does not add empty card',
|
||||
build: () => chatBloc,
|
||||
seed: () => ChatState(
|
||||
items: [
|
||||
ToolCallItem(
|
||||
id: 'tc_1',
|
||||
callId: 'tc_1',
|
||||
toolName: 'front.navigate_to_route',
|
||||
args: {'target': '/calendar/dayweek', '__nonce': 'nonce_1'},
|
||||
status: ToolCallStatus.executing,
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
),
|
||||
],
|
||||
),
|
||||
act: (bloc) {
|
||||
service.onEvent(
|
||||
ToolCallResultEvent(
|
||||
messageId: 'msg_tool_1',
|
||||
toolCallId: 'tc_1',
|
||||
content: '{"result":{"ok":true}}',
|
||||
),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>().having((s) => s.items.isEmpty, 'items empty', true),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'toolCallResult with ui in payload.result adds ToolResultItem',
|
||||
build: () => chatBloc,
|
||||
seed: () => ChatState(
|
||||
items: [
|
||||
ToolCallItem(
|
||||
id: 'tc_2',
|
||||
callId: 'tc_2',
|
||||
toolName: 'back.mutate_calendar_event',
|
||||
args: {'operation': 'create'},
|
||||
status: ToolCallStatus.executing,
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
),
|
||||
],
|
||||
),
|
||||
act: (bloc) {
|
||||
service.onEvent(
|
||||
ToolCallResultEvent(
|
||||
messageId: 'msg_tool_2',
|
||||
toolCallId: 'tc_2',
|
||||
content:
|
||||
'{"result":{"type":"calendar_operation.v1","version":"v1","data":{"operation":"delete","ok":true,"message":"done"},"actions":[]}}',
|
||||
),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>().having(
|
||||
(s) => s.items.first is ToolResultItem,
|
||||
'first item is ToolResultItem',
|
||||
true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'state snapshot user message keeps attachments',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) {
|
||||
service.onEvent(
|
||||
StateSnapshotEvent(
|
||||
snapshot: {
|
||||
'scope': 'history_day',
|
||||
'messages': [
|
||||
{
|
||||
'id': 'u1',
|
||||
'role': 'user',
|
||||
'content': '请分析这张图',
|
||||
'attachments': [
|
||||
{'bucket': 'b', 'path': 'p', 'mimeType': 'image/png'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>().having(
|
||||
(s) {
|
||||
final item = s.items.first;
|
||||
return item is TextMessageItem && item.attachments.length == 1;
|
||||
},
|
||||
'user attachment count',
|
||||
true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
test(
|
||||
'loadAttachmentPreview deduplicates in-flight and caches result',
|
||||
() async {
|
||||
final mock = service as MockAgUiService;
|
||||
final results = await Future.wait<Uint8List?>([
|
||||
chatBloc.loadAttachmentPreview('/api/preview/1'),
|
||||
chatBloc.loadAttachmentPreview('/api/preview/1'),
|
||||
]);
|
||||
final secondRound = await chatBloc.loadAttachmentPreview(
|
||||
'/api/preview/1',
|
||||
);
|
||||
|
||||
expect(results.first, isNotNull);
|
||||
expect(results.last, isNotNull);
|
||||
expect(secondRound, isNotNull);
|
||||
expect(mock.previewCalls, 1);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:social_app/core/api/api_exception.dart';
|
||||
import 'package:social_app/core/api/mock_api_client.dart';
|
||||
import 'package:social_app/core/di/injection.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';
|
||||
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
|
||||
import 'package:social_app/features/home/data/voice_recorder.dart';
|
||||
import 'package:social_app/features/home/ui/screens/home_screen.dart';
|
||||
import 'package:social_app/features/messages/data/inbox_api.dart';
|
||||
|
||||
class _FakeVoiceRecorder implements VoiceRecorder {
|
||||
bool started = false;
|
||||
String? stoppedPath;
|
||||
|
||||
@override
|
||||
Future<void> start() async {
|
||||
started = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> stop() async {
|
||||
started = false;
|
||||
stoppedPath ??=
|
||||
'${Directory.systemTemp.path}/test-audio-${DateTime.now().microsecondsSinceEpoch}.wav';
|
||||
return stoppedPath;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
class _WaitingAgUiService extends AgUiService {
|
||||
_WaitingAgUiService() : super(onEvent: (_) {});
|
||||
|
||||
final Completer<void> _pending = Completer<void>();
|
||||
|
||||
@override
|
||||
Future<void> sendMessage(String content, {List<XFile>? images}) async {
|
||||
onEvent(RunStartedEvent(threadId: 't1', runId: 'r1'));
|
||||
return _pending.future;
|
||||
}
|
||||
|
||||
void emitStepStarted(String stepName) {
|
||||
onEvent(StepStartedEvent(stepName: stepName));
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
if (!sl.isRegistered<InboxApi>()) {
|
||||
sl.registerSingleton<InboxApi>(InboxApi(MockApiClient()));
|
||||
}
|
||||
});
|
||||
|
||||
IconData _inputActionIcon(WidgetTester tester) {
|
||||
final icon = tester.widget<Icon>(
|
||||
find.byKey(const ValueKey('home_input_action_icon')),
|
||||
);
|
||||
return icon.icon!;
|
||||
}
|
||||
|
||||
group('HomeScreen Widget Tests', () {
|
||||
testWidgets('displays input field', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('输入消息...'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays header icons', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(LucideIcons.settings), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.calendar), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.messageSquare), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays send or mic icon based on input', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(LucideIcons.mic), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tap mic starts recording and shows listening state', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final fakeRecorder = _FakeVoiceRecorder();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomeScreen(voiceRecorder: fakeRecorder, autoLoadHistory: false),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.mic));
|
||||
await tester.pump();
|
||||
|
||||
expect(fakeRecorder.started, true);
|
||||
expect(find.text('正在聆听...'), findsOneWidget);
|
||||
expect(_inputActionIcon(tester), LucideIcons.send);
|
||||
});
|
||||
|
||||
testWidgets('tap send while recording transcribes and auto sends message', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final fakeRecorder = _FakeVoiceRecorder();
|
||||
String? sentTranscript;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomeScreen(
|
||||
voiceRecorder: fakeRecorder,
|
||||
autoLoadHistory: false,
|
||||
onTranscribeAudio: (filePath) async {
|
||||
expect(filePath.endsWith('.wav'), true);
|
||||
return '语音自动发送';
|
||||
},
|
||||
onAutoSendTranscript: (transcript) async {
|
||||
sentTranscript = transcript;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(sentTranscript, '语音自动发送');
|
||||
expect(find.byIcon(LucideIcons.plus), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tap stop enters transcribing state', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final fakeRecorder = _FakeVoiceRecorder();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomeScreen(
|
||||
voiceRecorder: fakeRecorder,
|
||||
autoLoadHistory: false,
|
||||
onTranscribeAudio: (filePath) async {
|
||||
expect(filePath.endsWith('.wav'), true);
|
||||
return '语音转文字结果';
|
||||
},
|
||||
onAutoSendTranscript: (_) async {},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('语音识别中...'), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsAtLeastNWidgets(1));
|
||||
});
|
||||
|
||||
testWidgets('tap stop shows readable unauthorized message', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final fakeRecorder = _FakeVoiceRecorder();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomeScreen(
|
||||
voiceRecorder: fakeRecorder,
|
||||
autoLoadHistory: false,
|
||||
onTranscribeAudio: (_) async {
|
||||
throw const UnauthorizedException();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text('请重新登录'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
});
|
||||
|
||||
testWidgets('tap stop shows message when transcript is empty', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final fakeRecorder = _FakeVoiceRecorder();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomeScreen(
|
||||
voiceRecorder: fakeRecorder,
|
||||
autoLoadHistory: false,
|
||||
onTranscribeAudio: (_) async => '',
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text('未识别到有效语音,请靠近麦克风并连续说话后重试'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
});
|
||||
|
||||
testWidgets('shows transcribing indicator while waiting ASR result', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final fakeRecorder = _FakeVoiceRecorder();
|
||||
final completer = Completer<String>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomeScreen(
|
||||
voiceRecorder: fakeRecorder,
|
||||
autoLoadHistory: false,
|
||||
onTranscribeAudio: (_) => completer.future,
|
||||
onAutoSendTranscript: (_) async {},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('语音识别中...'), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
completer.complete('识别完成');
|
||||
});
|
||||
|
||||
testWidgets('tap send unfocuses text input after sending', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(TextField));
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(TextField), 'hello');
|
||||
await tester.pump();
|
||||
|
||||
final editableBefore = tester.state<EditableTextState>(
|
||||
find.byType(EditableText),
|
||||
);
|
||||
expect(editableBefore.widget.focusNode.hasFocus, isTrue);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
|
||||
final editableAfter = tester.state<EditableTextState>(
|
||||
find.byType(EditableText),
|
||||
);
|
||||
expect(editableAfter.widget.focusNode.hasFocus, isFalse);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
});
|
||||
|
||||
testWidgets('shows stop icon and waiting indicator while waiting agent', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final waitingService = _WaitingAgUiService();
|
||||
final chatBloc = ChatBloc(service: waitingService);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomeScreen(autoLoadHistory: false, chatBloc: chatBloc),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'hello');
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
|
||||
expect(_inputActionIcon(tester), LucideIcons.square);
|
||||
expect(find.text('正在思考...'), findsOneWidget);
|
||||
|
||||
waitingService.emitStepStarted('intent');
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('意图识别中'), findsOneWidget);
|
||||
expect(find.text('正在思考...'), findsNothing);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('已停止等待回复'), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
|
||||
await chatBloc.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:social_app/shared/widgets/message_composer.dart';
|
||||
|
||||
Widget _buildTestApp({
|
||||
required MessageComposerMode mode,
|
||||
required MessageComposerProcess process,
|
||||
required bool hasMessage,
|
||||
required bool isWaitingAgent,
|
||||
VoidCallback? onHoldStart,
|
||||
VoidCallback? onHoldEnd,
|
||||
VoidCallback? onHoldCancel,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MessageComposer(
|
||||
mode: mode,
|
||||
process: process,
|
||||
hasMessage: hasMessage,
|
||||
isWaitingAgent: isWaitingAgent,
|
||||
iconSize: 24,
|
||||
composerMinHeight: 48,
|
||||
onTapPlus: () {},
|
||||
onTapRightAction: () {},
|
||||
onHoldToSpeakStart: onHoldStart ?? () {},
|
||||
onHoldToSpeakEnd: onHoldEnd ?? () {},
|
||||
onHoldToSpeakMoveUpdate: (_) {},
|
||||
onHoldToSpeakCancel: onHoldCancel ?? () {},
|
||||
textInputChild: const SizedBox.shrink(),
|
||||
recordingAnimation: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('MessageComposer', () {
|
||||
testWidgets('renders one unified rounded composer container', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
mode: MessageComposerMode.text,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: false,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byKey(messageComposerContainerKey), findsOneWidget);
|
||||
|
||||
final containerFinder = find.byKey(messageComposerContainerKey);
|
||||
final plusFinder = find.byKey(messageComposerPlusButtonKey);
|
||||
final rightFinder = find.byKey(messageComposerRightButtonKey);
|
||||
|
||||
expect(
|
||||
find.descendant(of: containerFinder, matching: plusFinder),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: containerFinder, matching: rightFinder),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('right action icon follows state priority', (tester) async {
|
||||
Future<IconData> rightIconFor({
|
||||
required MessageComposerMode mode,
|
||||
required MessageComposerProcess process,
|
||||
required bool hasMessage,
|
||||
required bool isWaitingAgent,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
mode: mode,
|
||||
process: process,
|
||||
hasMessage: hasMessage,
|
||||
isWaitingAgent: isWaitingAgent,
|
||||
),
|
||||
);
|
||||
|
||||
final iconFinder = find.descendant(
|
||||
of: find.byKey(messageComposerRightButtonKey),
|
||||
matching: find.byType(Icon),
|
||||
);
|
||||
final iconWidget = tester.widget<Icon>(iconFinder.first);
|
||||
return iconWidget.icon!;
|
||||
}
|
||||
|
||||
expect(
|
||||
await rightIconFor(
|
||||
mode: MessageComposerMode.text,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: true,
|
||||
),
|
||||
LucideIcons.square,
|
||||
);
|
||||
|
||||
expect(
|
||||
await rightIconFor(
|
||||
mode: MessageComposerMode.holdToSpeak,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: true,
|
||||
isWaitingAgent: false,
|
||||
),
|
||||
LucideIcons.send,
|
||||
);
|
||||
|
||||
expect(
|
||||
await rightIconFor(
|
||||
mode: MessageComposerMode.holdToSpeak,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: false,
|
||||
),
|
||||
LucideIcons.keyboard,
|
||||
);
|
||||
|
||||
expect(
|
||||
await rightIconFor(
|
||||
mode: MessageComposerMode.text,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: false,
|
||||
),
|
||||
LucideIcons.mic,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('recording hint appears only while recording', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
mode: MessageComposerMode.holdToSpeak,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: false,
|
||||
),
|
||||
);
|
||||
expect(find.byKey(messageComposerRecordingHintKey), findsNothing);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
mode: MessageComposerMode.holdToSpeak,
|
||||
process: MessageComposerProcess.recording,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: false,
|
||||
),
|
||||
);
|
||||
expect(find.byKey(messageComposerRecordingHintKey), findsOneWidget);
|
||||
expect(find.text('松开发送,上滑取消'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('composer height remains stable across mode switches', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
mode: MessageComposerMode.text,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: false,
|
||||
),
|
||||
);
|
||||
final textHeight = tester.getSize(
|
||||
find.byKey(messageComposerContainerKey),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
mode: MessageComposerMode.holdToSpeak,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: false,
|
||||
),
|
||||
);
|
||||
final holdHeight = tester.getSize(
|
||||
find.byKey(messageComposerContainerKey),
|
||||
);
|
||||
|
||||
expect(textHeight.height, holdHeight.height);
|
||||
});
|
||||
|
||||
testWidgets('invokes long press start/end callbacks in hold mode', (
|
||||
tester,
|
||||
) async {
|
||||
var started = false;
|
||||
var ended = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
mode: MessageComposerMode.holdToSpeak,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: false,
|
||||
onHoldStart: () => started = true,
|
||||
onHoldEnd: () => ended = true,
|
||||
),
|
||||
);
|
||||
|
||||
final center = tester.getCenter(find.byKey(messageComposerHoldAreaKey));
|
||||
final gesture = await tester.startGesture(center);
|
||||
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 10));
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
|
||||
expect(started, isTrue);
|
||||
expect(ended, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('invokes long press cancel callback when gesture canceled', (
|
||||
tester,
|
||||
) async {
|
||||
var canceled = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
mode: MessageComposerMode.holdToSpeak,
|
||||
process: MessageComposerProcess.idle,
|
||||
hasMessage: false,
|
||||
isWaitingAgent: false,
|
||||
onHoldCancel: () => canceled = true,
|
||||
),
|
||||
);
|
||||
|
||||
final center = tester.getCenter(find.byKey(messageComposerHoldAreaKey));
|
||||
final gesture = await tester.startGesture(center);
|
||||
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 10));
|
||||
await gesture.cancel();
|
||||
await tester.pump();
|
||||
|
||||
expect(canceled, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:social_app/main.dart';
|
||||
import 'package:social_app/features/auth/presentation/bloc/auth_bloc.dart';
|
||||
import 'package:social_app/features/auth/presentation/bloc/auth_state.dart';
|
||||
import 'package:social_app/features/auth/data/auth_repository.dart';
|
||||
import 'package:social_app/core/di/injection.dart';
|
||||
|
||||
class MockAuthBloc extends Mock implements AuthBloc {}
|
||||
|
||||
class MockAuthRepository extends Mock implements AuthRepository {}
|
||||
|
||||
class FakeAuthState extends Fake implements AuthState {}
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
registerFallbackValue(FakeAuthState());
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
if (sl.isRegistered<AuthRepository>()) {
|
||||
await sl.reset();
|
||||
}
|
||||
sl.registerSingleton<AuthRepository>(MockAuthRepository());
|
||||
});
|
||||
|
||||
testWidgets('Login screen loads correctly', (WidgetTester tester) async {
|
||||
final mockAuthBloc = MockAuthBloc();
|
||||
when(() => mockAuthBloc.state).thenReturn(AuthInitial());
|
||||
when(
|
||||
() => mockAuthBloc.stream,
|
||||
).thenAnswer((_) => Stream.value(AuthInitial()));
|
||||
|
||||
await tester.pumpWidget(LinksyApp(authBloc: mockAuthBloc));
|
||||
expect(find.text('linksy'), findsOneWidget);
|
||||
expect(find.text('登录'), findsOneWidget);
|
||||
expect(find.text('还没有账号?去注册'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Main content is vertically centered above footer', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final mockAuthBloc = MockAuthBloc();
|
||||
when(() => mockAuthBloc.state).thenReturn(AuthInitial());
|
||||
when(
|
||||
() => mockAuthBloc.stream,
|
||||
).thenAnswer((_) => Stream.value(AuthInitial()));
|
||||
|
||||
await tester.pumpWidget(LinksyApp(authBloc: mockAuthBloc));
|
||||
|
||||
final safeAreaRect = tester.getRect(find.byType(SafeArea));
|
||||
final mainRect = tester.getRect(
|
||||
find.byKey(const Key('login_main_content')),
|
||||
);
|
||||
final footerRect = tester.getRect(find.byKey(const Key('login_footer')));
|
||||
|
||||
final topSpace = mainRect.top - safeAreaRect.top;
|
||||
final bottomSpace = footerRect.top - mainRect.bottom;
|
||||
|
||||
expect((topSpace - bottomSpace).abs(), lessThanOrEqualTo(2));
|
||||
});
|
||||
|
||||
testWidgets('Login screen does not overflow when keyboard is visible', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final mockAuthBloc = MockAuthBloc();
|
||||
when(() => mockAuthBloc.state).thenReturn(AuthInitial());
|
||||
when(
|
||||
() => mockAuthBloc.stream,
|
||||
).thenAnswer((_) => Stream.value(AuthInitial()));
|
||||
|
||||
await tester.pumpWidget(
|
||||
MediaQuery(
|
||||
data: const MediaQueryData(
|
||||
size: Size(390, 844),
|
||||
viewInsets: EdgeInsets.only(bottom: 320),
|
||||
),
|
||||
child: LinksyApp(authBloc: mockAuthBloc),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('登录'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user