main
dart 308 lines 10.1 KB
Raw
1 import 'dart:async';
2
3 import 'package:flutter_riverpod/flutter_riverpod.dart';
4 import 'package:flutter_test/flutter_test.dart';
5 import 'package:localmind/core/models/enums.dart';
6 import 'package:localmind/core/providers/app_providers.dart';
7 import 'package:localmind/features/chat/data/chat_service.dart';
8 import 'package:localmind/features/chat/data/models/chat_parameters.dart';
9 import 'package:localmind/features/chat/data/models/mcp_integration.dart';
10 import 'package:localmind/features/chat/data/models/message.dart';
11 import 'package:localmind/features/chat/data/tools/tool_definition.dart';
12 import 'package:localmind/features/chat/providers/chat_notifier.dart';
13 import 'package:localmind/features/chat/providers/chat_service_providers.dart';
14 import 'package:localmind/features/chat/providers/model_selection_providers.dart';
15 import 'package:localmind/features/chat/providers/smart_reply_providers.dart';
16 import 'package:localmind/features/conversations/data/models/conversation.dart';
17 import 'package:localmind/features/conversations/providers/conversation_providers.dart';
18 import 'package:localmind/features/models/data/models/model_info.dart';
19 import 'package:localmind/features/on_device/providers/on_device_providers.dart';
20 import 'package:localmind/features/servers/data/models/server.dart';
21 import 'package:localmind/features/servers/providers/server_providers.dart';
22 import 'package:localmind/features/settings/data/models/app_settings.dart';
23 import 'package:localmind/features/voice_mode/providers/voice_mode_provider.dart';
24
25 void main() {
26 test(
27 'smartRepliesProvider waits for title generation on the conversation to complete before generating replies',
28 () async {
29 final titleCompleter = Completer<void>();
30 final executionOrder = <String>[];
31
32 final testChatNotifier = _MockChatNotifier(
33 initialState: ChatState(
34 isStreaming: false,
35 messages: [
36 Message(
37 id: 'user-1',
38 conversationId: 'conv-1',
39 role: MessageRole.user,
40 content: 'Tell me about stars',
41 createdAt: DateTime.utc(2026, 8, 15),
42 ),
43 Message(
44 id: 'assistant-1',
45 conversationId: 'conv-1',
46 role: MessageRole.assistant,
47 content: 'Stars are massive celestial bodies made of plasma.',
48 status: MessageStatus.complete,
49 createdAt: DateTime.utc(2026, 8, 15),
50 ),
51 ],
52 ),
53 onWaitForTitle: (convId) async {
54 executionOrder.add('waitForTitle:start');
55 await titleCompleter.future;
56 executionOrder.add('waitForTitle:done');
57 },
58 );
59
60 final fakeChatService = _SequentialTrackingChatService(
61 onSend: (prompt) {
62 executionOrder.add('llm:smartReply');
63 },
64 );
65
66 final conversation = Conversation(
67 id: 'conv-1',
68 title: 'New Chat',
69 serverId: 'server-1',
70 createdAt: DateTime.utc(2026, 8, 15),
71 updatedAt: DateTime.utc(2026, 8, 15),
72 );
73
74 final container = ProviderContainer(
75 overrides: [
76 conversationsProvider.overrideWith(_TestConversationsNotifier.new),
77 activeConversationProvider.overrideWith(
78 () => _TestActiveConversationNotifier(conversation),
79 ),
80 activeConversationIdProvider.overrideWith(
81 () => _TestActiveConversationIdNotifier('conv-1'),
82 ),
83 chatProvider.overrideWith(() => testChatNotifier),
84 chatServiceProvider.overrideWithValue(fakeChatService),
85 activeServerProvider.overrideWith(_TestServerNotifier.new),
86 selectedModelProvider.overrideWith(_TestModelNotifier.new),
87 onDeviceEngineProvider.overrideWith(_TestOnDeviceEngineNotifier.new),
88 voiceModeProvider.overrideWith(_TestVoiceModeNotifier.new),
89 settingsProvider.overrideWith(_TestSettingsNotifier.new),
90 ],
91 );
92 addTearDown(container.dispose);
93
94 // Read smartRepliesProvider
95 final repliesFuture = container.read(smartRepliesProvider.future);
96
97 // Allow microtasks to execute
98 await Future<void>.delayed(const Duration(milliseconds: 10));
99
100 // waitForTitle should have been called, but LLM smart reply should NOT have run yet
101 expect(executionOrder, contains('waitForTitle:start'));
102 expect(executionOrder, isNot(contains('llm:smartReply')));
103
104 // Now complete the title generation
105 titleCompleter.complete();
106
107 final replies = await repliesFuture;
108 expect(executionOrder, [
109 'waitForTitle:start',
110 'waitForTitle:done',
111 'llm:smartReply',
112 ]);
113 expect(replies, ['Tell me more', 'How do they form?']);
114 },
115 );
116
117 test(
118 'smartRepliesProvider does not delay if no title generation is pending',
119 () async {
120 final executionOrder = <String>[];
121
122 final testChatNotifier = _MockChatNotifier(
123 initialState: ChatState(
124 isStreaming: false,
125 messages: [
126 Message(
127 id: 'user-2',
128 conversationId: 'conv-1',
129 role: MessageRole.user,
130 content: 'How big are they?',
131 createdAt: DateTime.utc(2026, 8, 15),
132 ),
133 Message(
134 id: 'assistant-2',
135 conversationId: 'conv-1',
136 role: MessageRole.assistant,
137 content: 'They range from small dwarfs to giant supergiants.',
138 status: MessageStatus.complete,
139 createdAt: DateTime.utc(2026, 8, 15),
140 ),
141 ],
142 ),
143 onWaitForTitle: (convId) async {
144 executionOrder.add('waitForTitle:instant');
145 },
146 );
147
148 final fakeChatService = _SequentialTrackingChatService(
149 onSend: (prompt) {
150 executionOrder.add('llm:smartReply');
151 },
152 );
153
154 final conversation = Conversation(
155 id: 'conv-1',
156 title: 'Stars and Galaxies',
157 serverId: 'server-1',
158 createdAt: DateTime.utc(2026, 8, 15),
159 updatedAt: DateTime.utc(2026, 8, 15),
160 );
161
162 final container = ProviderContainer(
163 overrides: [
164 conversationsProvider.overrideWith(_TestConversationsNotifier.new),
165 activeConversationProvider.overrideWith(
166 () => _TestActiveConversationNotifier(conversation),
167 ),
168 activeConversationIdProvider.overrideWith(
169 () => _TestActiveConversationIdNotifier('conv-1'),
170 ),
171 chatProvider.overrideWith(() => testChatNotifier),
172 chatServiceProvider.overrideWithValue(fakeChatService),
173 activeServerProvider.overrideWith(_TestServerNotifier.new),
174 selectedModelProvider.overrideWith(_TestModelNotifier.new),
175 onDeviceEngineProvider.overrideWith(_TestOnDeviceEngineNotifier.new),
176 voiceModeProvider.overrideWith(_TestVoiceModeNotifier.new),
177 settingsProvider.overrideWith(_TestSettingsNotifier.new),
178 ],
179 );
180 addTearDown(container.dispose);
181
182 final replies = await container.read(smartRepliesProvider.future);
183 expect(executionOrder, ['waitForTitle:instant', 'llm:smartReply']);
184 expect(replies, ['Tell me more', 'How do they form?']);
185 },
186 );
187 }
188
189 class _MockChatNotifier extends ChatNotifier {
190 _MockChatNotifier({
191 this.initialState = const ChatState(),
192 this.onWaitForTitle,
193 });
194
195 final ChatState initialState;
196 final Future<void> Function(String convId)? onWaitForTitle;
197
198 @override
199 ChatState build() => initialState;
200
201 @override
202 Future<void> waitForTitleGeneration(String conversationId) async {
203 if (onWaitForTitle != null) {
204 await onWaitForTitle!(conversationId);
205 }
206 }
207 }
208
209 class _SequentialTrackingChatService implements ChatService {
210 _SequentialTrackingChatService({required this.onSend});
211
212 final void Function(String prompt) onSend;
213
214 @override
215 Stream<ChatResponse> sendMessage({
216 required Server server,
217 required String modelId,
218 required List<Message> messages,
219 required ChatParameters params,
220 List<McpIntegration>? integrations,
221 List<ToolDefinition>? tools,
222 bool continueGeneration = false,
223 }) async* {
224 onSend(messages.last.content);
225 yield const ChatResponse(
226 type: ChatResponseType.message,
227 content: '["Tell me more", "How do they form?"]',
228 );
229 yield const ChatResponse(type: ChatResponseType.done);
230 }
231
232 @override
233 void cancelStream() {}
234 }
235
236 class _TestVoiceModeNotifier extends VoiceModeNotifier {
237 @override
238 VoiceModeState build() => const VoiceModeState();
239 }
240
241 class _TestConversationsNotifier extends ConversationsNotifier {
242 @override
243 Future<List<Conversation>> build() async => [];
244
245 @override
246 Future<void> updateSmartReplies(
247 String id,
248 List<String> replies,
249 String lastMessageId,
250 ) async {}
251 }
252
253 class _TestActiveConversationNotifier extends ActiveConversationNotifier {
254 _TestActiveConversationNotifier(this._initial);
255 final Conversation? _initial;
256
257 @override
258 Conversation? build() => _initial;
259 }
260
261 class _TestActiveConversationIdNotifier extends ActiveConversationIdNotifier {
262 _TestActiveConversationIdNotifier(this._initial);
263 final String? _initial;
264
265 @override
266 String? build() => _initial;
267 }
268
269 class _TestServerNotifier extends ActiveServerNotifier {
270 @override
271 Server? build() => Server(
272 id: 'server-1',
273 name: 'On-Device',
274 type: ServerType.onDevice,
275 host: '',
276 port: 0,
277 createdAt: DateTime.utc(2026, 8, 15),
278 lastConnectedAt: DateTime.utc(2026, 8, 15),
279 status: ConnectionStatus.connected,
280 );
281 }
282
283 class _TestModelNotifier extends SelectedModelNotifier {
284 @override
285 ModelInfo? build() => ModelInfo(
286 id: 'smollm2-135m-instruct',
287 name: 'SmolLM2 135M',
288 serverType: ServerType.onDevice,
289 serverId: 'server-1',
290 );
291 }
292
293 class _TestOnDeviceEngineNotifier extends OnDeviceEngineNotifier {
294 @override
295 OnDeviceEngineState build() => const OnDeviceEngineState(
296 status: OnDeviceEngineStatus.loaded,
297 loadedModelId: 'smollm2-135m-instruct',
298 );
299 }
300
301 class _TestSettingsNotifier extends SettingsNotifier {
302 @override
303 AppSettings build() => AppSettings(
304 smartReplyEnabled: true,
305 smartRepliesUsePersona: false,
306 autoGenerateTitle: true,
307 );
308 }