test(chat): add comprehensive unit tests

This commit is contained in:
qzl
2026-02-28 13:49:51 +08:00
parent dd90f48c6f
commit 92781ddbbe
6 changed files with 1177 additions and 0 deletions
@@ -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<RunStartedEvent>());
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<RunFinishedEvent>());
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<RunErrorEvent>());
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<TextMessageStartEvent>());
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<TextMessageContentEvent>());
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<TextMessageEndEvent>());
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<ToolCallStartEvent>());
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<ToolCallArgsEvent>());
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<ToolCallEndEvent>());
});
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<ToolCallResultEvent>());
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<ToolCallErrorEvent>());
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<UnknownAgUiEvent>());
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<UnknownAgUiEvent>());
});
});
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');
});
});
}
@@ -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<void> sendMessage(String content) 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);
} 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<void> mockToolCallFlow(String content, AiDecisionEngine engine) async {
final args = engine.getToolCallArgs(content);
if (args == null) return;
await mockToolCallFlowWithArgs('create_calendar_event', args);
}
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));
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<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();
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 triggers 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.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<ToolCallStartEvent>()
.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<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('tool call with invalid args emits error', () async {
await service.sendMessage('#tool:create_calendar_event {}');
final toolCallErrors = capturedEvents
.whereType<ToolCallErrorEvent>()
.toList();
expect(toolCallErrors.isNotEmpty, true);
expect(toolCallErrors.first.error, contains('Missing required fields'));
});
});
}
@@ -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);
});
});
}
+207
View File
@@ -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<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.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(isLoading: true));
service.onEvent!(
TextMessageStartEvent(messageId: 'msg_1', role: 'assistant'),
);
},
expect: () => [
isA<ChatState>()
.having((s) => s.isLoading, 'isLoading', true)
.having((s) => s.isLoading, 'isLoading', 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.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.error, 'error', isNull),
],
);
blocTest<ChatBloc, ChatState>(
'runFinished sets isLoading to false',
build: () => chatBloc,
seed: () => const ChatState(isLoading: 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>(
'runError sets error message',
build: () => chatBloc,
seed: () => const ChatState(isLoading: true),
act: (bloc) {
service.onEvent!(
RunErrorEvent(message: 'Something went wrong', code: 'ERR'),
);
},
expect: () => [
isA<ChatState>()
.having((s) => s.isLoading, 'isLoading', false)
.having((s) => s.error, 'error', 'Something went wrong'),
],
);
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: 'create_calendar_event',
),
);
},
expect: () => [
isA<ChatState>().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,
),
],
);
});
}
@@ -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<ToolNotFoundException>()),
);
});
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);
});
});
}
@@ -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);
});
});
}