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:
qzl
2026-03-12 16:41:45 +08:00
parent d7fbb74bf8
commit 01c36eb32e
70 changed files with 5138 additions and 5829 deletions
@@ -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]);
});
});
}
-410
View File
@@ -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);
});
});
}
-87
View File
@@ -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);
});
}