diff --git a/apps/test/features/chat/ag_ui_event_test.dart b/apps/test/features/chat/ag_ui_event_test.dart new file mode 100644 index 0000000..988956c --- /dev/null +++ b/apps/test/features/chat/ag_ui_event_test.dart @@ -0,0 +1,307 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/features/chat/data/models/ag_ui_event.dart'; + +void main() { + group('agUiEventTypeFromWire', () { + test('maps RUN_STARTED correctly', () { + expect(agUiEventTypeFromWire('RUN_STARTED'), AgUiEventType.runStarted); + }); + + test('maps RUN_FINISHED correctly', () { + expect(agUiEventTypeFromWire('RUN_FINISHED'), AgUiEventType.runFinished); + }); + + test('maps RUN_ERROR correctly', () { + expect(agUiEventTypeFromWire('RUN_ERROR'), AgUiEventType.runError); + }); + + test('maps TEXT_MESSAGE_START correctly', () { + expect( + agUiEventTypeFromWire('TEXT_MESSAGE_START'), + AgUiEventType.textMessageStart, + ); + }); + + test('maps TEXT_MESSAGE_CONTENT correctly', () { + expect( + agUiEventTypeFromWire('TEXT_MESSAGE_CONTENT'), + AgUiEventType.textMessageContent, + ); + }); + + test('maps TEXT_MESSAGE_END correctly', () { + expect( + agUiEventTypeFromWire('TEXT_MESSAGE_END'), + AgUiEventType.textMessageEnd, + ); + }); + + test('maps TOOL_CALL_START correctly', () { + expect( + agUiEventTypeFromWire('TOOL_CALL_START'), + AgUiEventType.toolCallStart, + ); + }); + + test('maps TOOL_CALL_ARGS correctly', () { + expect( + agUiEventTypeFromWire('TOOL_CALL_ARGS'), + AgUiEventType.toolCallArgs, + ); + }); + + test('maps TOOL_CALL_END correctly', () { + expect(agUiEventTypeFromWire('TOOL_CALL_END'), AgUiEventType.toolCallEnd); + }); + + test('maps TOOL_CALL_RESULT correctly', () { + expect( + agUiEventTypeFromWire('TOOL_CALL_RESULT'), + AgUiEventType.toolCallResult, + ); + }); + + test('maps TOOL_CALL_ERROR correctly', () { + expect( + agUiEventTypeFromWire('TOOL_CALL_ERROR'), + AgUiEventType.toolCallError, + ); + }); + + test('returns unknown for unknown type', () { + expect(agUiEventTypeFromWire('UNKNOWN_TYPE'), AgUiEventType.unknown); + }); + + test('returns unknown for empty string', () { + expect(agUiEventTypeFromWire(''), AgUiEventType.unknown); + }); + }); + + group('agUiEventTypeToWire', () { + test('maps runStarted to RUN_STARTED', () { + expect(agUiEventTypeToWire(AgUiEventType.runStarted), 'RUN_STARTED'); + }); + + test('maps runFinished to RUN_FINISHED', () { + expect(agUiEventTypeToWire(AgUiEventType.runFinished), 'RUN_FINISHED'); + }); + + test('maps textMessageStart to TEXT_MESSAGE_START', () { + expect( + agUiEventTypeToWire(AgUiEventType.textMessageStart), + 'TEXT_MESSAGE_START', + ); + }); + + test('maps unknown to empty string', () { + expect(agUiEventTypeToWire(AgUiEventType.unknown), ''); + }); + }); + + group('AgUiEvent.fromJson', () { + test('parses RunStartedEvent', () { + final json = { + 'type': 'RUN_STARTED', + 'threadId': 'thread_123', + 'runId': 'run_456', + }; + + final event = AgUiEvent.fromJson(json); + + expect(event, isA()); + final runStarted = event as RunStartedEvent; + expect(runStarted.threadId, 'thread_123'); + expect(runStarted.runId, 'run_456'); + }); + + test('parses RunFinishedEvent', () { + final json = { + 'type': 'RUN_FINISHED', + 'threadId': 'thread_123', + 'runId': 'run_456', + }; + + final event = AgUiEvent.fromJson(json); + + expect(event, isA()); + final runFinished = event as RunFinishedEvent; + expect(runFinished.threadId, 'thread_123'); + }); + + test('parses RunErrorEvent', () { + final json = { + 'type': 'RUN_ERROR', + 'message': 'Something went wrong', + 'code': 'ERR_001', + }; + + final event = AgUiEvent.fromJson(json); + + expect(event, isA()); + final runError = event as RunErrorEvent; + expect(runError.message, 'Something went wrong'); + expect(runError.code, 'ERR_001'); + }); + + test('parses TextMessageStartEvent', () { + final json = { + 'type': 'TEXT_MESSAGE_START', + 'messageId': 'msg_123', + 'role': 'assistant', + }; + + final event = AgUiEvent.fromJson(json); + + expect(event, isA()); + final textStart = event as TextMessageStartEvent; + expect(textStart.messageId, 'msg_123'); + expect(textStart.role, 'assistant'); + }); + + test('parses TextMessageContentEvent', () { + final json = { + 'type': 'TEXT_MESSAGE_CONTENT', + 'messageId': 'msg_123', + 'delta': 'Hello', + }; + + final event = AgUiEvent.fromJson(json); + + expect(event, isA()); + final textContent = event as TextMessageContentEvent; + expect(textContent.messageId, 'msg_123'); + expect(textContent.delta, 'Hello'); + }); + + test('parses TextMessageEndEvent', () { + final json = {'type': 'TEXT_MESSAGE_END', 'messageId': 'msg_123'}; + + final event = AgUiEvent.fromJson(json); + + expect(event, isA()); + final textEnd = event as TextMessageEndEvent; + expect(textEnd.messageId, 'msg_123'); + }); + + test('parses ToolCallStartEvent', () { + final json = { + 'type': 'TOOL_CALL_START', + 'toolCallId': 'tc_123', + 'toolCallName': 'create_calendar_event', + 'parentMessageId': 'msg_001', + }; + + final event = AgUiEvent.fromJson(json); + + expect(event, isA()); + final toolStart = event as ToolCallStartEvent; + expect(toolStart.toolCallId, 'tc_123'); + expect(toolStart.toolCallName, 'create_calendar_event'); + expect(toolStart.parentMessageId, 'msg_001'); + }); + + test('parses ToolCallArgsEvent', () { + final json = { + 'type': 'TOOL_CALL_ARGS', + 'toolCallId': 'tc_123', + 'delta': '{"title": "test"}', + }; + + final event = AgUiEvent.fromJson(json); + + expect(event, isA()); + final toolArgs = event as ToolCallArgsEvent; + expect(toolArgs.toolCallId, 'tc_123'); + expect(toolArgs.delta, '{"title": "test"}'); + }); + + test('parses ToolCallEndEvent', () { + final json = {'type': 'TOOL_CALL_END', 'toolCallId': 'tc_123'}; + + final event = AgUiEvent.fromJson(json); + + expect(event, isA()); + }); + + test('parses ToolCallResultEvent', () { + final json = { + 'type': 'TOOL_CALL_RESULT', + 'messageId': 'msg_123', + 'toolCallId': 'tc_123', + 'result': {'ok': true, 'eventId': 'evt_001'}, + }; + + final event = AgUiEvent.fromJson(json); + + expect(event, isA()); + final toolResult = event as ToolCallResultEvent; + expect(toolResult.messageId, 'msg_123'); + expect(toolResult.toolCallId, 'tc_123'); + expect(toolResult.result['ok'], true); + }); + + test('parses ToolCallErrorEvent', () { + final json = { + 'type': 'TOOL_CALL_ERROR', + 'toolCallId': 'tc_123', + 'error': 'Execution failed', + 'code': 'EXEC_ERROR', + }; + + final event = AgUiEvent.fromJson(json); + + expect(event, isA()); + final toolError = event as ToolCallErrorEvent; + expect(toolError.toolCallId, 'tc_123'); + expect(toolError.error, 'Execution failed'); + expect(toolError.code, 'EXEC_ERROR'); + }); + + test('returns UnknownAgUiEvent for unknown type', () { + final json = {'type': 'UNKNOWN_TYPE', 'someField': 'someValue'}; + + final event = AgUiEvent.fromJson(json); + + expect(event, isA()); + final unknown = event as UnknownAgUiEvent; + expect(unknown.rawJson['someField'], 'someValue'); + }); + + test('returns UnknownAgUiEvent for missing type', () { + final json = {'someField': 'someValue'}; + + final event = AgUiEvent.fromJson(json); + + expect(event, isA()); + }); + }); + + group('toJson', () { + test('RunStartedEvent serializes with correct fields', () { + final event = RunStartedEvent(threadId: 't1', runId: 'r1'); + final json = event.toJson(); + + expect(json['threadId'], 't1'); + expect(json['runId'], 'r1'); + }); + + test('TextMessageContentEvent serializes with correct fields', () { + final event = TextMessageContentEvent(messageId: 'm1', delta: 'hello'); + final json = event.toJson(); + + expect(json['messageId'], 'm1'); + expect(json['delta'], 'hello'); + }); + + test('ToolCallStartEvent serializes with correct fields', () { + final event = ToolCallStartEvent( + toolCallId: 'tc1', + toolCallName: 'test_tool', + ); + final json = event.toJson(); + + expect(json['toolCallId'], 'tc1'); + expect(json['toolCallName'], 'test_tool'); + }); + }); +} diff --git a/apps/test/features/chat/ag_ui_service_test.dart b/apps/test/features/chat/ag_ui_service_test.dart new file mode 100644 index 0000000..61e1bce --- /dev/null +++ b/apps/test/features/chat/ag_ui_service_test.dart @@ -0,0 +1,224 @@ +import 'package:flutter_test/flutter_test.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/tool_registry.dart'; +import 'package:social_app/features/chat/data/services/ag_ui_service.dart'; + +class TestableAgUiService extends AgUiService { + TestableAgUiService({super.onEvent}); + + @override + Future sendMessage(String content) async { + await mockEventStream(content); + } + + Future 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); + } else if (engine.shouldTriggerToolCall(content)) { + await mockToolCallFlow(content, engine); + } + + final replies = generateReplies(content, engine); + if (replies.isNotEmpty) { + await mockTextMessageStream(replies); + } + + onEvent(RunFinishedEvent(threadId: threadId, runId: runId)); + } + + Future mockToolCallFlow(String content, AiDecisionEngine engine) async { + final args = engine.getToolCallArgs(content); + if (args == null) return; + + await mockToolCallFlowWithArgs('create_calendar_event', args); + } + + Future mockToolCallFlowWithArgs( + String toolName, + Map args, + ) async { + final toolCallId = 'tc_${DateTime.now().millisecondsSinceEpoch}'; + + onEvent(ToolCallStartEvent(toolCallId: toolCallId, toolCallName: toolName)); + + onEvent(ToolCallArgsEvent(toolCallId: toolCallId, delta: '{}')); + + onEvent(ToolCallEndEvent(toolCallId: toolCallId)); + + 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(); + final result = await ToolRegistry.execute(toolName, args); + final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}'; + + onEvent( + ToolCallResultEvent( + messageId: messageId, + toolCallId: toolCallId, + result: result, + ), + ); + } catch (e) { + onEvent( + ToolCallErrorEvent( + toolCallId: toolCallId, + error: e.toString(), + code: 'EXECUTION_ERROR', + ), + ); + } + } + + List 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 mockTextMessageStream(List 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 capturedEvents; + + setUp(() { + capturedEvents = []; + ToolRegistry.initialize(); + service = TestableAgUiService( + onEvent: (event) { + capturedEvents.add(event); + }, + ); + }); + + group('AgUiService', () { + test('sendMessage first emits RunStartedEvent', () async { + await service.sendMessage('你好'); + + expect(capturedEvents.first, isA()); + }); + + test('sendMessage last emits RunFinishedEvent', () async { + await service.sendMessage('你好'); + + expect(capturedEvents.last, isA()); + }); + + test('sendMessage emits events in correct order', () async { + await service.sendMessage('你好'); + + expect(capturedEvents.first, isA()); + expect(capturedEvents.last, isA()); + + final types = capturedEvents.map((e) => e.type).toList(); + expect(types.first, AgUiEventType.runStarted); + expect(types.last, AgUiEventType.runFinished); + }); + + test('creating schedule text triggers tool call events', () async { + await service.sendMessage('提醒我明天10点开会'); + + final toolCallStarts = capturedEvents + .whereType() + .toList(); + final toolCallEnds = capturedEvents + .whereType() + .toList(); + final toolCallResults = capturedEvents + .whereType() + .toList(); + + expect(toolCallStarts.isNotEmpty, true); + expect(toolCallEnds.isNotEmpty, true); + expect(toolCallResults.isNotEmpty, true); + expect(toolCallStarts.first.toolCallName, 'create_calendar_event'); + }); + + test('force trigger with #tool syntax', () async { + await service.sendMessage( + '#tool:create_calendar_event {"title": "Test", "startAt": "2026-03-01T10:00:00Z"}', + ); + + final toolCallStarts = capturedEvents + .whereType() + .toList(); + + expect(toolCallStarts.isNotEmpty, true); + expect(toolCallStarts.first.toolCallName, 'create_calendar_event'); + }); + + test('text message events are emitted for unknown intent', () async { + await service.sendMessage('你好'); + + final textStarts = capturedEvents + .whereType() + .toList(); + final textContents = capturedEvents + .whereType() + .toList(); + final textEnds = capturedEvents.whereType().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() + .toList(); + + expect(toolCallStarts.isEmpty, true); + }); + + test('tool call with invalid args emits error', () async { + await service.sendMessage('#tool:create_calendar_event {}'); + + final toolCallErrors = capturedEvents + .whereType() + .toList(); + + expect(toolCallErrors.isNotEmpty, true); + expect(toolCallErrors.first.error, contains('Missing required fields')); + }); + }); +} diff --git a/apps/test/features/chat/ai_decision_engine_test.dart b/apps/test/features/chat/ai_decision_engine_test.dart new file mode 100644 index 0000000..79b25b9 --- /dev/null +++ b/apps/test/features/chat/ai_decision_engine_test.dart @@ -0,0 +1,168 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/features/chat/data/ai/ai_decision_engine.dart'; + +void main() { + late AiDecisionEngine engine; + + setUp(() { + engine = AiDecisionEngine(); + }); + + group('matchIntent', () { + test('returns searchEvent for "今天有什么日程"', () { + expect(engine.matchIntent('今天有什么日程'), Intent.searchEvent); + }); + + test('returns searchEvent for "查看日程"', () { + expect(engine.matchIntent('查看日程'), Intent.searchEvent); + }); + + test('returns searchEvent for "查询安排"', () { + expect(engine.matchIntent('查询安排'), Intent.searchEvent); + }); + + test('returns createEvent for "提醒我明天开会"', () { + expect(engine.matchIntent('提醒我明天开会'), Intent.createEvent); + }); + + test('returns createEvent for "安排时间"', () { + expect(engine.matchIntent('安排时间'), Intent.createEvent); + }); + + test('returns createEvent for time pattern "明天10点"', () { + expect(engine.matchIntent('明天10点'), Intent.createEvent); + }); + + test('returns unknown for "你好"', () { + expect(engine.matchIntent('你好'), Intent.unknown); + }); + + test('returns unknown for random text', () { + expect(engine.matchIntent('随便说点什么'), Intent.unknown); + }); + }); + + group('shouldTriggerToolCall', () { + test('returns false for "你好"', () { + expect(engine.shouldTriggerToolCall('你好'), false); + }); + + test('returns false for search intent', () { + expect(engine.shouldTriggerToolCall('今天有什么日程'), false); + }); + + test('returns true for create event intent', () { + expect(engine.shouldTriggerToolCall('提醒我明天开会'), true); + }); + + test('returns true for time pattern', () { + expect(engine.shouldTriggerToolCall('明天10点开会'), true); + }); + }); + + group('tryExtractEventArgs', () { + test('returns map with title and startAt for "提醒我明天10点开会"', () { + final result = engine.tryExtractEventArgs('提醒我明天10点开会'); + + expect(result, isNotNull); + expect(result!['title'], isNotNull); + expect(result['startAt'], isNotNull); + expect(result['timezone'], 'Asia/Shanghai'); + }); + + test('returns null for "你好"', () { + expect(engine.tryExtractEventArgs('你好'), isNull); + }); + + test('returns null for search intent', () { + expect(engine.tryExtractEventArgs('今天有什么日程'), isNull); + }); + + test('extracts title correctly', () { + final result = engine.tryExtractEventArgs('提醒我开会明天10点'); + + expect(result, isNotNull); + expect(result!['title'], contains('开会')); + }); + + test('parses today time correctly', () { + final result = engine.tryExtractEventArgs('开会今天14:30'); + final now = DateTime.now(); + + expect(result, isNotNull); + final startAt = DateTime.parse(result!['startAt'] as String); + expect(startAt.year, now.year); + expect(startAt.month, now.month); + expect(startAt.day, now.day); + expect(startAt.hour, 14); + expect(startAt.minute, 30); + }); + + test('parses tomorrow time correctly', () { + final result = engine.tryExtractEventArgs('开会明天9点'); + final now = DateTime.now(); + final expectedTomorrow = DateTime(now.year, now.month, now.day + 1); + + expect(result, isNotNull); + final startAt = DateTime.parse(result!['startAt'] as String); + expect(startAt.day, equals(expectedTomorrow.day)); + expect(startAt.hour, 9); + expect(startAt.minute, 0); + }); + }); + + group('tryForceTrigger', () { + test('returns ForceTriggerResult for "#tool:create_calendar_event {}"', () { + final result = engine.tryForceTrigger('#tool:create_calendar_event {}'); + + expect(result, isNotNull); + expect(result!.toolName, 'create_calendar_event'); + expect(result.args, isEmpty); + }); + + test( + 'returns ForceTriggerResult with args for "#tool:custom {"key": "value"}"', + () { + final result = engine.tryForceTrigger('#tool:custom {"key": "value"}'); + + expect(result, isNotNull); + expect(result!.toolName, 'custom'); + expect(result.args['key'], 'value'); + }, + ); + + test('returns null for normal text', () { + expect(engine.tryForceTrigger('普通文本'), isNull); + }); + + test('returns null for empty string', () { + expect(engine.tryForceTrigger(''), isNull); + }); + + test('handles invalid JSON gracefully', () { + final result = engine.tryForceTrigger('#tool:test {invalid json}'); + + expect(result, isNotNull); + expect(result!.toolName, 'test'); + expect(result.args, isEmpty); + }); + }); + + group('getToolCallArgs', () { + test('returns args for create event intent', () { + final result = engine.getToolCallArgs('提醒我明天10点开会'); + + expect(result, isNotNull); + expect(result!['title'], isNotNull); + expect(result['startAt'], isNotNull); + }); + + test('returns null for non-create intent', () { + expect(engine.getToolCallArgs('你好'), isNull); + }); + + test('returns null for search intent', () { + expect(engine.getToolCallArgs('今天有什么日程'), isNull); + }); + }); +} diff --git a/apps/test/features/chat/chat_bloc_test.dart b/apps/test/features/chat/chat_bloc_test.dart new file mode 100644 index 0000000..8b2ec7b --- /dev/null +++ b/apps/test/features/chat/chat_bloc_test.dart @@ -0,0 +1,207 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.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/presentation/bloc/chat_bloc.dart'; + +void main() { + late ChatBloc chatBloc; + late AgUiService service; + + setUp(() { + service = AgUiService(); + 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.currentMessageId, isNull); + expect(chatBloc.state.error, isNull); + }); + + blocTest( + 'sendMessage adds user message to items', + build: () => chatBloc, + act: (bloc) => bloc.sendMessage('Hello'), + expect: () => [ + isA() + .having((state) => state.items.length, 'items length', 1) + .having( + (state) => state.items.first, + 'first item', + isA().having( + (item) => item.content, + 'content', + 'Hello', + ), + ), + ], + ); + + blocTest( + 'textMessageStart event adds AI message with streaming', + build: () => chatBloc, + act: (bloc) { + bloc.emit(chatBloc.state.copyWith(isLoading: true)); + service.onEvent!( + TextMessageStartEvent(messageId: 'msg_1', role: 'assistant'), + ); + }, + expect: () => [ + isA() + .having((s) => s.isLoading, 'isLoading', true) + .having((s) => s.isLoading, 'isLoading', true), + isA() + .having((s) => s.items.length, 'items length', 1) + .having((s) => s.currentMessageId, 'currentMessageId', 'msg_1') + .having( + (s) => s.items.first, + 'first item', + isA() + .having((item) => item.isStreaming, 'isStreaming', true) + .having((item) => item.sender, 'sender', MessageSender.ai), + ), + ], + ); + + blocTest( + '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().having( + (s) => (s.items.first as TextMessageItem).content, + 'content', + 'Hello', + ), + ], + ); + + blocTest( + '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() + .having((s) => s.currentMessageId, 'currentMessageId', isNull) + .having( + (s) => (s.items.first as TextMessageItem).isStreaming, + 'isStreaming', + false, + ), + ], + ); + + blocTest( + 'runStarted sets isLoading to true', + build: () => chatBloc, + act: (bloc) { + service.onEvent!(RunStartedEvent(threadId: 't1', runId: 'r1')); + }, + expect: () => [ + isA() + .having((s) => s.isLoading, 'isLoading', true) + .having((s) => s.error, 'error', isNull), + ], + ); + + blocTest( + 'runFinished sets isLoading to false', + build: () => chatBloc, + seed: () => const ChatState(isLoading: true), + act: (bloc) { + service.onEvent!(RunFinishedEvent(threadId: 't1', runId: 'r1')); + }, + expect: () => [ + isA() + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.currentMessageId, 'currentMessageId', isNull), + ], + ); + + blocTest( + 'runError sets error message', + build: () => chatBloc, + seed: () => const ChatState(isLoading: true), + act: (bloc) { + service.onEvent!( + RunErrorEvent(message: 'Something went wrong', code: 'ERR'), + ); + }, + expect: () => [ + isA() + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.error, 'error', 'Something went wrong'), + ], + ); + + blocTest( + 'clearError removes error', + build: () => chatBloc, + seed: () => const ChatState(error: 'Some error'), + act: (bloc) => bloc.clearError(), + expect: () => [isA().having((s) => s.error, 'error', isNull)], + ); + + blocTest( + 'toolCallStart adds ToolCallItem', + build: () => chatBloc, + act: (bloc) { + service.onEvent!( + ToolCallStartEvent( + toolCallId: 'tc_1', + toolCallName: 'create_calendar_event', + ), + ); + }, + expect: () => [ + isA().having( + (s) { + final item = s.items.first; + return item is ToolCallItem && + item.toolName == 'create_calendar_event' && + item.status == ToolCallStatus.pending; + }, + 'has pending tool call', + true, + ), + ], + ); + }); +} diff --git a/apps/test/features/chat/tool_registry_test.dart b/apps/test/features/chat/tool_registry_test.dart new file mode 100644 index 0000000..5369e5e --- /dev/null +++ b/apps/test/features/chat/tool_registry_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/features/chat/data/tools/tool_registry.dart'; + +void main() { + setUp(() { + ToolRegistry.initialize(); + }); + + group('getTool', () { + test('returns tool definition for create_calendar_event', () { + final tool = ToolRegistry.getTool('create_calendar_event'); + + expect(tool, isNotNull); + expect(tool!.name, 'create_calendar_event'); + expect(tool.description, isNotEmpty); + }); + + test('returns null for unknown tool', () { + expect(ToolRegistry.getTool('unknown_tool'), isNull); + }); + }); + + group('validateArgs', () { + test('returns error for empty args (missing title)', () { + final result = ToolRegistry.validateArgs('create_calendar_event', {}); + + expect(result.ok, false); + expect(result.error, contains('title')); + }); + + test('returns error when missing startAt', () { + final result = ToolRegistry.validateArgs('create_calendar_event', { + 'title': 'Test Event', + }); + + expect(result.ok, false); + expect(result.error, contains('startAt')); + }); + + test('returns ok: true for valid args with title and startAt', () { + final result = ToolRegistry.validateArgs('create_calendar_event', { + 'title': 'x', + 'startAt': 'x', + }); + + expect(result.ok, true); + expect(result.error, isNull); + }); + + test('returns error for unknown tool', () { + final result = ToolRegistry.validateArgs('unknown_tool', {}); + + expect(result.ok, false); + expect(result.error, contains('Tool not found')); + }); + }); + + group('execute', () { + test('returns eventId on success', () async { + final result = await ToolRegistry.execute('create_calendar_event', { + 'title': 'Test Meeting', + 'startAt': '2026-03-01T10:00:00Z', + }); + + expect(result['eventId'], isNotNull); + expect(result['ok'], true); + expect(result['title'], 'Test Meeting'); + }); + + test('throws ToolNotFoundException for unknown tool', () async { + expect( + () => ToolRegistry.execute('unknown_tool', {}), + throwsA(isA()), + ); + }); + + test('includes optional fields in result', () async { + final result = await ToolRegistry.execute('create_calendar_event', { + 'title': 'Test', + 'startAt': '2026-03-01T10:00:00Z', + 'description': 'Description', + 'location': 'Room A', + 'endAt': '2026-03-01T11:00:00Z', + }); + + expect(result['description'], 'Description'); + expect(result['location'], 'Room A'); + expect(result['endAt'], '2026-03-01T11:00:00Z'); + }); + }); + + group('getAllTools', () { + test('returns list of tool definitions', () { + final tools = ToolRegistry.getAllTools(); + + expect(tools, isNotEmpty); + expect(tools.any((t) => t.name == 'create_calendar_event'), true); + }); + }); +} diff --git a/apps/test/features/chat/ui_schema_renderer_test.dart b/apps/test/features/chat/ui_schema_renderer_test.dart new file mode 100644 index 0000000..bb01e0e --- /dev/null +++ b/apps/test/features/chat/ui_schema_renderer_test.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/features/chat/data/models/tool_result.dart'; +import 'package:social_app/features/chat/ui/widgets/ui_schema_renderer.dart'; + +void main() { + group('UiSchemaRenderer', () { + testWidgets('calendar_card.v1 renders title', (tester) async { + final card = UiCard( + cardType: 'calendar_card.v1', + data: CalendarCardData( + id: 'evt_001', + title: 'Team Meeting', + startAt: '2026-03-01T10:00:00Z', + ).toJson(), + ); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))), + ); + + expect(find.text('Team Meeting'), findsOneWidget); + }); + + testWidgets('calendar_card.v1 renders time', (tester) async { + final card = UiCard( + cardType: 'calendar_card.v1', + data: CalendarCardData( + id: 'evt_001', + title: 'Meeting', + startAt: '2026-03-01T10:00:00Z', + endAt: '2026-03-01T11:30:00Z', + ).toJson(), + ); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))), + ); + + expect(find.textContaining('3月1日'), findsOneWidget); + }); + + testWidgets('calendar_card.v1 renders location', (tester) async { + final card = UiCard( + cardType: 'calendar_card.v1', + data: CalendarCardData( + id: 'evt_001', + title: 'Meeting', + startAt: '2026-03-01T10:00:00Z', + location: 'Room 101', + ).toJson(), + ); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))), + ); + + expect(find.text('Room 101'), findsOneWidget); + }); + + testWidgets('calendar_card.v1 renders description', (tester) async { + final card = UiCard( + cardType: 'calendar_card.v1', + data: CalendarCardData( + id: 'evt_001', + title: 'Meeting', + startAt: '2026-03-01T10:00:00Z', + description: 'Quarterly review', + ).toJson(), + ); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))), + ); + + expect(find.text('Quarterly review'), findsOneWidget); + }); + + testWidgets('calendar_card.v1 renders AI generated tag', (tester) async { + final card = UiCard( + cardType: 'calendar_card.v1', + data: CalendarCardData( + id: 'evt_001', + title: 'Meeting', + startAt: '2026-03-01T10:00:00Z', + sourceType: 'ai_generated', + ).toJson(), + ); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))), + ); + + expect(find.text('AI生成'), findsOneWidget); + }); + + testWidgets('error_card.v1 renders error message', (tester) async { + final card = UiCard( + cardType: 'error_card.v1', + data: {'message': 'Something went wrong'}, + ); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))), + ); + + expect(find.text('Something went wrong'), findsOneWidget); + }); + + testWidgets('error_card.v1 renders default message when missing', ( + tester, + ) async { + final card = UiCard(cardType: 'error_card.v1', data: {}); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))), + ); + + expect(find.text('发生错误'), findsOneWidget); + }); + + testWidgets('unknown card type renders fallback', (tester) async { + final card = UiCard(cardType: 'unknown_type', data: {'foo': 'bar'}); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))), + ); + + expect(find.textContaining('未知卡片类型'), findsOneWidget); + expect(find.textContaining('unknown_type'), findsOneWidget); + }); + + testWidgets('calendar_card.v1 renders actions', (tester) async { + final card = UiCard( + cardType: 'calendar_card.v1', + data: CalendarCardData( + id: 'evt_001', + title: 'Meeting', + startAt: '2026-03-01T10:00:00Z', + ).toJson(), + actions: [ + CardAction(type: 'link', label: '查看详情', target: '/calendar/evt_001'), + ], + ); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))), + ); + + expect(find.text('查看详情'), findsOneWidget); + }); + + testWidgets('calendar_card.v1 renders custom color', (tester) async { + final card = UiCard( + cardType: 'calendar_card.v1', + data: CalendarCardData( + id: 'evt_001', + title: 'Meeting', + startAt: '2026-03-01T10:00:00Z', + color: '#FF0000', + ).toJson(), + ); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))), + ); + + expect(find.text('Meeting'), findsOneWidget); + }); + }); +}