main
dart 450 lines 14.6 KB
Raw
1 import 'package:flutter/material.dart';
2 import 'package:flutter_localizations/flutter_localizations.dart';
3 import 'package:flutter_riverpod/flutter_riverpod.dart';
4 import 'package:flutter_test/flutter_test.dart';
5 import 'package:hugeicons/hugeicons.dart';
6 import 'package:localmind/core/services/app_haptics.dart';
7 import 'package:localmind/core/theme/app_theme.dart';
8 import 'package:localmind/features/chat/providers/chat_providers.dart';
9 import 'package:localmind/features/chat/views/components/background_generation_notice.dart';
10 import 'package:localmind/features/chat/views/components/heartbeat_dot.dart';
11
12 import 'package:localmind/features/conversations/data/models/conversation.dart';
13 import 'package:localmind/features/conversations/providers/conversation_providers.dart'
14 as conv;
15 import 'package:localmind/l10n/app_localizations.dart';
16 import 'package:shadcn_ui/shadcn_ui.dart';
17
18 class _FakeChatNotifier extends ChatNotifier {
19 Conversation? loadedConversation;
20
21 @override
22 ChatState build() => const ChatState();
23
24 @override
25 Future<void> loadConversation(Conversation conversation) async {
26 loadedConversation = conversation;
27 }
28 }
29
30 Widget _buildTestApp({
31 required Widget child,
32 required List<Conversation> conversations,
33 _FakeChatNotifier? chatNotifier,
34 ThemeData? theme,
35 }) {
36 return ProviderScope(
37 overrides: [
38 appHapticsProvider.overrideWithValue(AppHaptics(() => false)),
39 conv.conversationsProvider.overrideWith(
40 () => _StaticConversationsNotifier(conversations),
41 ),
42 if (chatNotifier != null) chatProvider.overrideWith(() => chatNotifier),
43 ],
44 child: ShadTheme(
45 data: AppTheme.lightShadTheme,
46 child: MaterialApp(
47 theme: theme ?? AppTheme.lightTheme,
48 locale: const Locale('en'),
49 localizationsDelegates: const [
50 AppLocalizations.delegate,
51 GlobalMaterialLocalizations.delegate,
52 GlobalWidgetsLocalizations.delegate,
53 GlobalCupertinoLocalizations.delegate,
54 ],
55 supportedLocales: AppLocalizations.supportedLocales,
56 home: Scaffold(body: Center(child: child)),
57 ),
58 ),
59 );
60 }
61
62 class _StaticConversationsNotifier extends conv.ConversationsNotifier {
63 _StaticConversationsNotifier(this._conversations);
64 final List<Conversation> _conversations;
65
66 @override
67 Future<List<Conversation>> build() async => _conversations;
68 }
69
70 void main() {
71 final sampleConversation = Conversation(
72 id: 'conv-123',
73 title: 'Function Creation Guide',
74 createdAt: DateTime.now(),
75 updatedAt: DateTime.now(),
76 );
77
78 testWidgets(
79 'shows non-blocking info notice with title for a single remote generation',
80 (tester) async {
81 await tester.pumpWidget(
82 _buildTestApp(
83 conversations: [sampleConversation],
84 child: BackgroundGenerationNotice(
85 generations: const [
86 ActiveGeneration(conversationId: 'conv-123', isOnDevice: false),
87 ],
88 blocksSending: false,
89 ),
90 ),
91 );
92 await tester.pump();
93 await tester.pump(const Duration(milliseconds: 300));
94
95 // Verify icon is present
96 final iconFinder = find.byWidgetPredicate(
97 (widget) =>
98 widget is HugeIcon &&
99 widget.icon == HugeIcons.strokeRoundedBackground,
100 );
101 expect(iconFinder, findsOneWidget);
102
103 // Verify circular progress indicator is present
104 final spinnerFinder = find.byType(CircularProgressIndicator);
105 expect(spinnerFinder, findsOneWidget);
106
107 // Non-blocking text should mention "in the background"
108 expect(find.textContaining('background'), findsOneWidget);
109 expect(find.textContaining('Function Creation Guide'), findsOneWidget);
110
111 // Verify horizontal ordering: Icon (left) < Text (middle) < Spinner (right)
112 final iconOffset = tester.getCenter(iconFinder);
113 final textOffset = tester.getCenter(
114 find.textContaining('Function Creation Guide'),
115 );
116 final spinnerOffset = tester.getCenter(spinnerFinder);
117
118 expect(
119 iconOffset.dx,
120 lessThan(textOffset.dx),
121 reason: 'Icon should be to the left of the text',
122 );
123 expect(
124 textOffset.dx,
125 lessThan(spinnerOffset.dx),
126 reason:
127 'Circular progress indicator should be to the right of the text',
128 );
129 },
130 );
131
132 testWidgets('shows blocking notice with title for on-device conflict', (
133 tester,
134 ) async {
135 await tester.pumpWidget(
136 _buildTestApp(
137 conversations: [sampleConversation],
138 child: BackgroundGenerationNotice(
139 generations: const [
140 ActiveGeneration(conversationId: 'conv-123', isOnDevice: true),
141 ],
142 blocksSending: true,
143 ),
144 ),
145 );
146 await tester.pump();
147 await tester.pump(const Duration(milliseconds: 300));
148
149 // Blocking text should mention "once it finishes"
150 expect(find.textContaining('once it finishes'), findsOneWidget);
151 expect(find.textContaining('Function Creation Guide'), findsOneWidget);
152 });
153
154 testWidgets('shows plural notice when multiple chats are generating', (
155 tester,
156 ) async {
157 await tester.pumpWidget(
158 _buildTestApp(
159 conversations: [sampleConversation],
160 child: const BackgroundGenerationNotice(
161 generations: [
162 ActiveGeneration(conversationId: 'conv-123', isOnDevice: false),
163 ActiveGeneration(conversationId: 'conv-456', isOnDevice: false),
164 ],
165 blocksSending: false,
166 ),
167 ),
168 );
169 await tester.pump();
170 await tester.pump(const Duration(milliseconds: 300));
171
172 // Should show the plural form with count
173 expect(find.textContaining('2'), findsOneWidget);
174 expect(find.textContaining('background'), findsOneWidget);
175 });
176
177 testWidgets('triggers loadConversation on tap', (tester) async {
178 final fakeChat = _FakeChatNotifier();
179
180 await tester.pumpWidget(
181 _buildTestApp(
182 conversations: [sampleConversation],
183 chatNotifier: fakeChat,
184 child: BackgroundGenerationNotice(
185 generations: const [
186 ActiveGeneration(conversationId: 'conv-123', isOnDevice: false),
187 ],
188 blocksSending: false,
189 ),
190 ),
191 );
192 await tester.pump();
193 await tester.pump(const Duration(milliseconds: 300));
194
195 await tester.tap(find.byType(InkWell));
196 await tester.pump();
197
198 expect(fakeChat.loadedConversation?.id, equals('conv-123'));
199 expect(
200 fakeChat.loadedConversation?.title,
201 equals('Function Creation Guide'),
202 );
203 });
204
205 testWidgets('renders untitled fallback when conversation is missing', (
206 tester,
207 ) async {
208 await tester.pumpWidget(
209 _buildTestApp(
210 conversations: [],
211 child: const BackgroundGenerationNotice(
212 generations: [
213 ActiveGeneration(conversationId: 'non-existent', isOnDevice: false),
214 ],
215 blocksSending: false,
216 ),
217 ),
218 );
219 await tester.pump();
220 await tester.pump(const Duration(milliseconds: 300));
221
222 expect(find.byType(CircularProgressIndicator), findsOneWidget);
223 // The untitled non-blocking text
224 expect(find.textContaining('background'), findsOneWidget);
225 });
226
227 testWidgets(
228 'clicking notice with 2 chats expands to show the two listed chats and allows selecting one',
229 (tester) async {
230 final conv1 = sampleConversation;
231 final conv2 = Conversation(
232 id: 'conv-456',
233 title: 'Second Background Task',
234 createdAt: DateTime.now(),
235 updatedAt: DateTime.now(),
236 );
237 final fakeChat = _FakeChatNotifier();
238
239 await tester.pumpWidget(
240 _buildTestApp(
241 conversations: [conv1, conv2],
242 chatNotifier: fakeChat,
243 child: const BackgroundGenerationNotice(
244 generations: [
245 ActiveGeneration(conversationId: 'conv-123', isOnDevice: false),
246 ActiveGeneration(conversationId: 'conv-456', isOnDevice: true),
247 ],
248 blocksSending: false,
249 ),
250 ),
251 );
252 await tester.pump();
253 await tester.pump(const Duration(milliseconds: 300));
254
255 // Initially collapsed: listed chat items are not visible
256 expect(find.text('Second Background Task'), findsNothing);
257
258 // Tap the header to expand
259 await tester.tap(find.textContaining('2'));
260 await tester.pump();
261 await tester.pump(const Duration(milliseconds: 300));
262
263 // Now expanded: both chats are listed
264 expect(find.text('Function Creation Guide'), findsOneWidget);
265 expect(find.text('Second Background Task'), findsOneWidget);
266 expect(find.text('On-Device'), findsOneWidget);
267
268 // Only ONE CircularProgressIndicator exists (at the top header)
269 expect(find.byType(CircularProgressIndicator), findsOneWidget);
270
271 // Each chat item uses a HeartbeatDot instead of a spinner
272 expect(find.byType(HeartbeatDot), findsNWidgets(2));
273
274 // Tapping the second chat loads it
275 await tester.tap(find.text('Second Background Task'));
276 await tester.pump();
277 await tester.pump(const Duration(milliseconds: 300));
278
279 expect(fakeChat.loadedConversation?.id, equals('conv-456'));
280 expect(
281 fakeChat.loadedConversation?.title,
282 equals('Second Background Task'),
283 );
284 },
285 );
286
287 testWidgets(
288 'collapsing when one chat finishes animates without layout overflow',
289 (tester) async {
290 final conv1 = sampleConversation;
291 final conv2 = Conversation(
292 id: 'conv-456',
293 title: 'Second Background Task',
294 createdAt: DateTime.now(),
295 updatedAt: DateTime.now(),
296 );
297
298 await tester.pumpWidget(
299 _buildTestApp(
300 conversations: [conv1, conv2],
301 child: const BackgroundGenerationNotice(
302 generations: [
303 ActiveGeneration(conversationId: 'conv-123', isOnDevice: false),
304 ActiveGeneration(conversationId: 'conv-456', isOnDevice: false),
305 ],
306 blocksSending: false,
307 ),
308 ),
309 );
310 await tester.pump();
311 await tester.pump(const Duration(milliseconds: 300));
312
313 // Expand
314 await tester.tap(find.textContaining('2'));
315 await tester.pump();
316 await tester.pump(const Duration(milliseconds: 300));
317 expect(find.byType(HeartbeatDot), findsNWidgets(2));
318
319 // Simulate one chat completing (notice drops to 1 generation, triggers collapse)
320 await tester.pumpWidget(
321 _buildTestApp(
322 conversations: [conv1, conv2],
323 child: const BackgroundGenerationNotice(
324 generations: [
325 ActiveGeneration(conversationId: 'conv-123', isOnDevice: false),
326 ],
327 blocksSending: false,
328 ),
329 ),
330 );
331
332 // Pump intermediate frames of collapse animation (where subpixel constraints happen)
333 await tester.pump(const Duration(milliseconds: 30));
334 await tester.pump(const Duration(milliseconds: 50));
335 await tester.pump(const Duration(milliseconds: 100));
336 await tester.pump(const Duration(milliseconds: 200));
337
338 // Fully collapsed to single notice
339 expect(find.byType(HeartbeatDot), findsNothing);
340 expect(find.byType(CircularProgressIndicator), findsOneWidget);
341 expect(tester.takeException(), isNull);
342 },
343 );
344
345 testWidgets(
346 'tapping header again when expanded collapses the list of chats',
347 (tester) async {
348 final conv1 = sampleConversation;
349 final conv2 = Conversation(
350 id: 'conv-456',
351 title: 'Second Background Task',
352 createdAt: DateTime.now(),
353 updatedAt: DateTime.now(),
354 );
355
356 await tester.pumpWidget(
357 _buildTestApp(
358 conversations: [conv1, conv2],
359 child: const BackgroundGenerationNotice(
360 generations: [
361 ActiveGeneration(conversationId: 'conv-123', isOnDevice: false),
362 ActiveGeneration(conversationId: 'conv-456', isOnDevice: false),
363 ],
364 blocksSending: false,
365 ),
366 ),
367 );
368 await tester.pump();
369 await tester.pump(const Duration(milliseconds: 300));
370
371 // Expand
372 await tester.tap(find.textContaining('2'));
373 await tester.pump();
374 await tester.pump(const Duration(milliseconds: 300));
375 expect(find.text('Second Background Task'), findsOneWidget);
376
377 // Tap header again to collapse. The spring needs longer than the
378 // expand check to fall below the visibility threshold.
379 await tester.tap(find.textContaining('2'));
380 await tester.pump();
381 await tester.pump(const Duration(seconds: 2));
382 expect(find.text('Second Background Task'), findsNothing);
383 },
384 );
385
386 testWidgets(
387 'blocked send with several chats opens the on-device chat on tap',
388 (tester) async {
389 final onDeviceConversation = Conversation(
390 id: 'conv-456',
391 title: 'Local Gemma Chat',
392 createdAt: DateTime.now(),
393 updatedAt: DateTime.now(),
394 );
395 final fakeChat = _FakeChatNotifier();
396
397 await tester.pumpWidget(
398 _buildTestApp(
399 conversations: [sampleConversation, onDeviceConversation],
400 chatNotifier: fakeChat,
401 child: const BackgroundGenerationNotice(
402 generations: [
403 ActiveGeneration(conversationId: 'conv-123', isOnDevice: false),
404 ActiveGeneration(conversationId: 'conv-456', isOnDevice: true),
405 ],
406 blocksSending: true,
407 ),
408 ),
409 );
410 await tester.pump();
411 await tester.pump(const Duration(milliseconds: 300));
412
413 expect(find.textContaining('Local Gemma Chat'), findsOneWidget);
414 expect(find.textContaining('once it finishes'), findsOneWidget);
415
416 await tester.tap(find.byType(InkWell));
417 await tester.pump();
418 await tester.pump(const Duration(milliseconds: 300));
419
420 // Opens the blocking chat instead of expanding a list.
421 expect(fakeChat.loadedConversation?.id, equals('conv-456'));
422 expect(find.byType(HeartbeatDot), findsNothing);
423 },
424 );
425
426 testWidgets('listed chat without a title uses a short fallback name', (
427 tester,
428 ) async {
429 await tester.pumpWidget(
430 _buildTestApp(
431 conversations: [sampleConversation],
432 child: const BackgroundGenerationNotice(
433 generations: [
434 ActiveGeneration(conversationId: 'conv-123', isOnDevice: false),
435 ActiveGeneration(conversationId: 'missing', isOnDevice: false),
436 ],
437 blocksSending: false,
438 ),
439 ),
440 );
441 await tester.pump();
442 await tester.pump(const Duration(milliseconds: 300));
443
444 await tester.tap(find.textContaining('2'));
445 await tester.pump();
446 await tester.pump(const Duration(milliseconds: 300));
447
448 expect(find.text('Untitled chat'), findsOneWidget);
449 });
450 }