test(chat): add comprehensive unit tests
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user