main
dart 1,293 lines 43.4 KB
Raw
1 import 'dart:async';
2 import 'dart:io';
3
4 import 'package:dio/dio.dart';
5 import 'package:flutter/material.dart';
6 import 'package:flutter_riverpod/flutter_riverpod.dart';
7 import 'package:flutter_test/flutter_test.dart';
8 import 'package:just_audio/just_audio.dart';
9 import 'package:just_audio_background/just_audio_background.dart';
10 import 'package:localmind/features/tts/providers/tts_providers.dart';
11 import 'package:localmind/bootstrap/safe_riverpod_scope_host.dart';
12 import 'package:localmind/core/models/enums.dart';
13 import 'package:localmind/core/providers/app_providers.dart';
14 import 'package:localmind/core/providers/service_providers.dart';
15 import 'package:localmind/core/providers/storage_providers.dart';
16 import 'package:localmind/core/theme/app_theme.dart';
17 import 'package:localmind/features/chat/data/models/chat_parameters.dart';
18 import 'package:localmind/features/chat/providers/chat_params_providers.dart';
19 import 'package:localmind/features/chat/views/components/model_info_sheet.dart';
20 import 'package:localmind/features/models/components/model_list.dart';
21 import 'package:localmind/features/models/data/models/model_info.dart';
22 import 'package:localmind/features/servers/data/models/server.dart';
23 import 'package:localmind/features/servers/data/server_api_service.dart';
24 import 'package:localmind/features/servers/providers/server_providers.dart';
25 import 'package:localmind/features/onboarding/screens/onboarding_server_setup_screen.dart';
26 import 'package:localmind/features/personas/data/models/persona.dart';
27 import 'package:localmind/features/personas/providers/personas_providers.dart';
28 import 'package:localmind/features/personas/views/create_persona_screen.dart';
29 import 'package:localmind/features/servers/views/add_server_screen.dart';
30 import 'package:flutter_localizations/flutter_localizations.dart';
31 import 'package:localmind/features/settings/data/models/app_settings.dart';
32 import 'package:localmind/features/sidebar/components/github_repo_card.dart';
33 import 'package:localmind/l10n/app_localizations.dart';
34 import 'package:flutter/services.dart';
35 import 'package:shadcn_ui/shadcn_ui.dart';
36 import 'package:shared_preferences/shared_preferences.dart';
37
38 void main() {
39 TestWidgetsFlutterBinding.ensureInitialized();
40
41 final now = DateTime.now();
42
43 group('Issue #62: TTS AudioSource MediaItem Tag', () {
44 test('AudioSource tag is a valid MediaItem even during preview', () {
45 final tempDir = Directory.systemTemp;
46 final tempFile = File('${tempDir.path}/tts_test_preview.wav');
47 tempFile.writeAsBytesSync(Uint8List(100));
48 addTearDown(() {
49 if (tempFile.existsSync()) tempFile.deleteSync();
50 });
51
52 for (final isPreview in [true, false]) {
53 final source = AudioSource.file(
54 tempFile.path,
55 tag: MediaItem(
56 id: 'tts_chunk_test_$isPreview',
57 album: isPreview ? 'Voice Preview' : 'LocalMind TTS',
58 title: 'Hello world',
59 artist: 'Piper TTS',
60 ),
61 );
62
63 // Verify tag is non-null and is a MediaItem (avoiding "type 'Null' is not a subtype of type 'MediaItem'")
64 expect(source.tag, isNotNull);
65 expect(source.tag, isA<MediaItem>());
66 final mediaItem = source.tag as MediaItem;
67 expect(mediaItem.album, isPreview ? 'Voice Preview' : 'LocalMind TTS');
68 expect(mediaItem.title, 'Hello world');
69 }
70 });
71 });
72
73 group('Issue #60 & #63: Server API Error Handling', () {
74 test(
75 'fetchModels throws clean formatted error on 401 unauthorized with top-level message',
76 () async {
77 final dio = Dio();
78 dio.httpClientAdapter = _MockHttpClientAdapter((options) {
79 return ResponseBody.fromString(
80 '{"message":"Invalid API key or unauthorized.","type":"authentication_error"}',
81 401,
82 headers: {
83 Headers.contentTypeHeader: [Headers.jsonContentType],
84 },
85 );
86 });
87
88 final apiService = ServerApiService(dio);
89 final server = Server(
90 id: 'test-server',
91 name: 'OpenRouter',
92 host: 'openrouter.ai',
93 port: 443,
94 type: ServerType.openRouter,
95 apiKey: 'invalid-key',
96 createdAt: now,
97 lastConnectedAt: now,
98 );
99
100 expect(
101 () => apiService.fetchModels(server),
102 throwsA(
103 predicate(
104 (e) =>
105 e is Exception &&
106 e.toString().contains(
107 'Failed to fetch models: authentication_error: Invalid API key or unauthorized.',
108 ),
109 ),
110 ),
111 );
112 },
113 );
114
115 test(
116 'fetchModels throws clean formatted error on 500 error payload',
117 () async {
118 final dio = Dio();
119 dio.httpClientAdapter = _MockHttpClientAdapter((options) {
120 return ResponseBody.fromString(
121 '{"error":{"code":500,"message":"Internal engine crash"}}',
122 500,
123 headers: {
124 Headers.contentTypeHeader: [Headers.jsonContentType],
125 },
126 );
127 });
128
129 final apiService = ServerApiService(dio);
130 final server = Server(
131 id: 'test-server-500',
132 name: 'Local Server',
133 host: 'localhost',
134 port: 1234,
135 type: ServerType.openAICompatible,
136 createdAt: now,
137 lastConnectedAt: now,
138 );
139
140 expect(
141 () => apiService.fetchModels(server),
142 throwsA(
143 predicate(
144 (e) =>
145 e is Exception &&
146 e.toString().contains(
147 'Failed to fetch models: Error 500: Internal engine crash',
148 ),
149 ),
150 ),
151 );
152 },
153 );
154
155 test(
156 'loadModelWithInstanceId throws clean formatted error on top-level API error map',
157 () async {
158 final dio = Dio();
159 dio.httpClientAdapter = _MockHttpClientAdapter((options) {
160 return ResponseBody.fromString(
161 '{"message":"Model not found on disk","type":"not_found"}',
162 404,
163 headers: {
164 Headers.contentTypeHeader: [Headers.jsonContentType],
165 },
166 );
167 });
168
169 final apiService = ServerApiService(dio);
170 final server = Server(
171 id: 'test-server',
172 name: 'LM Studio',
173 host: 'localhost',
174 port: 1234,
175 type: ServerType.lmStudio,
176 createdAt: now,
177 lastConnectedAt: now,
178 );
179
180 expect(
181 () => apiService.loadModelWithInstanceId(server, 'missing-model'),
182 throwsA(
183 predicate(
184 (e) =>
185 e is Exception &&
186 e.toString().contains('not_found: Model not found on disk'),
187 ),
188 ),
189 );
190 },
191 );
192
193 testWidgets(
194 'showModelInfoSheet gracefully handles availableModelsProvider error',
195 (tester) async {
196 SharedPreferences.setMockInitialValues({});
197 final prefs = await SharedPreferences.getInstance();
198
199 final server = Server(
200 id: 'error-server',
201 name: 'Error Server',
202 host: 'localhost',
203 port: 1234,
204 type: ServerType.openAICompatible,
205 createdAt: now,
206 lastConnectedAt: now,
207 );
208
209 final container = ProviderContainer(
210 overrides: [
211 sharedPreferencesProvider.overrideWithValue(prefs),
212 chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
213 activeServerProvider.overrideWith(
214 () => _StubActiveServerNotifier(server),
215 ),
216 serversProvider.overrideWith(() => _StubServersNotifier(server)),
217 availableModelsProvider(server.id).overrideWith(
218 (ref) async => throw Exception('Server connection failed'),
219 ),
220 ],
221 );
222 addTearDown(container.dispose);
223
224 late BuildContext capturedContext;
225 late WidgetRef capturedRef;
226
227 await tester.pumpWidget(
228 UncontrolledProviderScope(
229 container: container,
230 child: MaterialApp(
231 localizationsDelegates: AppLocalizations.localizationsDelegates,
232 supportedLocales: AppLocalizations.supportedLocales,
233 home: ShadTheme(
234 data: AppTheme.lightShadTheme,
235 child: Scaffold(
236 body: Consumer(
237 builder: (context, ref, _) {
238 capturedContext = context;
239 capturedRef = ref;
240 return const SizedBox();
241 },
242 ),
243 ),
244 ),
245 ),
246 ),
247 );
248 await tester.pumpAndSettle();
249
250 // Trigger showModelInfoSheet and ensure no uncaught exception is thrown
251 showModelInfoSheet(capturedContext, capturedRef, 'test-model-id');
252 await tester.pump();
253 await tester.pump(const Duration(milliseconds: 300));
254
255 expect(tester.takeException(), isNull);
256 },
257 );
258 });
259
260 group('Issue #61: ModelList Unmounted Ref Access Guard', () {
261 testWidgets('does not throw when unmounted while model is loading', (
262 tester,
263 ) async {
264 SharedPreferences.setMockInitialValues({});
265 final prefs = await SharedPreferences.getInstance();
266
267 final server = Server(
268 id: 'test-server-unload',
269 name: 'Test Server',
270 host: 'localhost',
271 port: 1234,
272 type: ServerType.lmStudio,
273 createdAt: now,
274 lastConnectedAt: now,
275 );
276
277 final testModel = ModelInfo(
278 id: 'qwen-model',
279 name: 'Qwen Model',
280 serverId: server.id,
281 serverType: ServerType.lmStudio,
282 );
283
284 final mockDio = Dio();
285 mockDio.httpClientAdapter = _MockHttpClientAdapter((options) {
286 return ResponseBody.fromString('{"status":"ok"}', 200);
287 });
288 final mockApiService = ServerApiService(mockDio);
289
290 final container = ProviderContainer(
291 overrides: [
292 sharedPreferencesProvider.overrideWithValue(prefs),
293 settingsProvider.overrideWith(_TestSettingsNotifier.new),
294 chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
295 serverApiServiceProvider.overrideWithValue(mockApiService),
296 activeServerProvider.overrideWith(
297 () => _StubActiveServerNotifier(server),
298 ),
299 serversProvider.overrideWith(() => _StubServersNotifier(server)),
300 connectionStatusProvider.overrideWith(
301 _StubConnectedStatusNotifier.new,
302 ),
303 availableModelsProvider(
304 server.id,
305 ).overrideWith((ref) => Future.value([testModel])),
306 loadedModelsProvider(
307 server,
308 ).overrideWith((ref) => Future.value(<String>{})),
309 ],
310 );
311 addTearDown(container.dispose);
312
313 await tester.pumpWidget(
314 UncontrolledProviderScope(
315 container: container,
316 child: MaterialApp(
317 localizationsDelegates: AppLocalizations.localizationsDelegates,
318 supportedLocales: AppLocalizations.supportedLocales,
319 home: Scaffold(
320 body: ModelList(
321 serverId: server.id,
322 selectedModelId: null,
323 isDark: false,
324 ),
325 ),
326 ),
327 ),
328 );
329 await tester.pumpAndSettle();
330
331 expect(find.text('Qwen Model'), findsOneWidget);
332
333 // Tapping tile triggers onTap async load
334 await tester.tap(find.text('Qwen Model'));
335
336 // Immediately replace widget with empty container to unmount ModelList
337 await tester.pumpWidget(
338 UncontrolledProviderScope(
339 container: container,
340 child: const MaterialApp(home: Scaffold(body: SizedBox())),
341 ),
342 );
343 await tester.pumpAndSettle();
344
345 // No unhandled exceptions thrown
346 expect(tester.takeException(), isNull);
347 });
348 });
349
350 group('Issue #64: Server Setup Screens Unmounted testConnection Guard', () {
351 testWidgets(
352 'OnboardingServerSetupScreen does not crash when unmounted during testConnection',
353 (tester) async {
354 SharedPreferences.setMockInitialValues({});
355 final prefs = await SharedPreferences.getInstance();
356
357 final connectionCompleter = Completer<ResponseBody>();
358 final mockDio = Dio();
359 mockDio.httpClientAdapter = _MockHttpClientAdapter((options) {
360 return connectionCompleter.future;
361 });
362 final mockApiService = ServerApiService(mockDio);
363
364 final container = ProviderContainer(
365 overrides: [
366 sharedPreferencesProvider.overrideWithValue(prefs),
367 settingsProvider.overrideWith(_TestSettingsNotifier.new),
368 chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
369 serverApiServiceProvider.overrideWithValue(mockApiService),
370 ],
371 );
372 addTearDown(container.dispose);
373
374 await tester.pumpWidget(
375 UncontrolledProviderScope(
376 container: container,
377 child: ShadTheme(
378 data: AppTheme.lightShadTheme,
379 child: const MaterialApp(
380 locale: Locale('en'),
381 localizationsDelegates: AppLocalizations.localizationsDelegates,
382 supportedLocales: AppLocalizations.supportedLocales,
383 home: OnboardingServerSetupScreen(
384 selectedType: ServerType.lmStudio,
385 ),
386 ),
387 ),
388 ),
389 );
390 await tester.pumpAndSettle();
391
392 expect(find.text('Test Connection'), findsOneWidget);
393
394 // Tap Test Connection
395 await tester.tap(find.text('Test Connection'));
396 await tester.pump();
397
398 // Immediately unmount OnboardingServerSetupScreen while test is in flight
399 await tester.pumpWidget(
400 UncontrolledProviderScope(
401 container: container,
402 child: const MaterialApp(home: Scaffold(body: SizedBox())),
403 ),
404 );
405 await tester.pump();
406
407 // Now complete the network response
408 connectionCompleter.complete(
409 ResponseBody.fromString('{"models":[]}', 200),
410 );
411 await tester.pumpAndSettle();
412
413 // Ensure no Null check operator or setState exception was thrown
414 expect(tester.takeException(), isNull);
415 },
416 );
417
418 testWidgets(
419 'AddServerScreen does not crash when unmounted during testConnection',
420 (tester) async {
421 SharedPreferences.setMockInitialValues({});
422 final prefs = await SharedPreferences.getInstance();
423
424 final connectionCompleter = Completer<ResponseBody>();
425 final mockDio = Dio();
426 mockDio.httpClientAdapter = _MockHttpClientAdapter((options) {
427 return connectionCompleter.future;
428 });
429 final mockApiService = ServerApiService(mockDio);
430
431 final container = ProviderContainer(
432 overrides: [
433 sharedPreferencesProvider.overrideWithValue(prefs),
434 settingsProvider.overrideWith(_TestSettingsNotifier.new),
435 chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
436 serverApiServiceProvider.overrideWithValue(mockApiService),
437 ],
438 );
439 addTearDown(container.dispose);
440
441 await tester.pumpWidget(
442 UncontrolledProviderScope(
443 container: container,
444 child: ShadTheme(
445 data: AppTheme.lightShadTheme,
446 child: const MaterialApp(
447 locale: Locale('en'),
448 localizationsDelegates: AppLocalizations.localizationsDelegates,
449 supportedLocales: AppLocalizations.supportedLocales,
450 home: AddServerScreen(),
451 ),
452 ),
453 ),
454 );
455 await tester.pumpAndSettle();
456
457 expect(find.text('Test Connection'), findsOneWidget);
458
459 // Tap Test Connection
460 await tester.tap(find.text('Test Connection'));
461 await tester.pump();
462
463 // Immediately unmount AddServerScreen while test is in flight
464 await tester.pumpWidget(
465 UncontrolledProviderScope(
466 container: container,
467 child: const MaterialApp(home: Scaffold(body: SizedBox())),
468 ),
469 );
470 await tester.pump();
471
472 // Now complete the network response
473 connectionCompleter.complete(
474 ResponseBody.fromString('{"models":[]}', 200),
475 );
476 await tester.pumpAndSettle();
477
478 // Ensure no Null check operator or setState exception was thrown
479 expect(tester.takeException(), isNull);
480 },
481 );
482 });
483
484 group('Issue #65: Server Setup Screens Unmounted setState finally-guard', () {
485 testWidgets(
486 'OnboardingServerSetupScreen does not crash when testConnection completes after unmount',
487 (tester) async {
488 SharedPreferences.setMockInitialValues({});
489 final prefs = await SharedPreferences.getInstance();
490
491 // Use a Completer so we control when the network response resolves —
492 // this lets us unmount the widget before the await resumes, then
493 // resume the future and verify the `finally` setState is a no-op.
494 final connectionCompleter = Completer<ResponseBody>();
495 final mockDio = Dio();
496 mockDio.httpClientAdapter = _MockHttpClientAdapter((options) {
497 return connectionCompleter.future;
498 });
499 final mockApiService = ServerApiService(mockDio);
500
501 final container = ProviderContainer(
502 overrides: [
503 sharedPreferencesProvider.overrideWithValue(prefs),
504 settingsProvider.overrideWith(_TestSettingsNotifier.new),
505 chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
506 serverApiServiceProvider.overrideWithValue(mockApiService),
507 ],
508 );
509 addTearDown(container.dispose);
510
511 await tester.pumpWidget(
512 UncontrolledProviderScope(
513 container: container,
514 child: ShadTheme(
515 data: AppTheme.lightShadTheme,
516 child: const MaterialApp(
517 locale: Locale('en'),
518 localizationsDelegates: AppLocalizations.localizationsDelegates,
519 supportedLocales: AppLocalizations.supportedLocales,
520 home: OnboardingServerSetupScreen(
521 selectedType: ServerType.lmStudio,
522 ),
523 ),
524 ),
525 ),
526 );
527 await tester.pumpAndSettle();
528
529 expect(find.text('Test Connection'), findsOneWidget);
530
531 // Kick off the test connection (this enters the try block and awaits).
532 await tester.tap(find.text('Test Connection'));
533 await tester.pump();
534
535 // Unmount the screen *while* the await is pending. This simulates a
536 // user navigating away (or any other reason for the widget to be
537 // disposed) before the network response arrives.
538 await tester.pumpWidget(
539 UncontrolledProviderScope(
540 container: container,
541 child: const MaterialApp(home: Scaffold(body: SizedBox())),
542 ),
543 );
544 await tester.pump();
545
546 // Now resume the future — this should run the remaining setState
547 // calls in the try, catch, and finally blocks. Without the mounted
548 // guards this would crash with
549 // "Null check operator used on a null value" at
550 // package:flutter/src/widgets/framework.dart:1219 from
551 // `_element!.markNeedsBuild()` (issue #65).
552 connectionCompleter.complete(
553 ResponseBody.fromString('{"models":[]}', 200),
554 );
555 await tester.pumpAndSettle();
556
557 expect(tester.takeException(), isNull);
558 },
559 );
560
561 testWidgets(
562 'OnboardingServerSetupScreen does not crash when testConnection fails after unmount',
563 (tester) async {
564 SharedPreferences.setMockInitialValues({});
565 final prefs = await SharedPreferences.getInstance();
566
567 // Completer that returns a 500 response — exercises the failure
568 // branch (isConnected == false) + the `finally` block while the
569 // widget is already unmounted.
570 final connectionCompleter = Completer<ResponseBody>();
571 final mockDio = Dio();
572 mockDio.httpClientAdapter = _MockHttpClientAdapter((options) {
573 return connectionCompleter.future;
574 });
575 final mockApiService = ServerApiService(mockDio);
576
577 final container = ProviderContainer(
578 overrides: [
579 sharedPreferencesProvider.overrideWithValue(prefs),
580 settingsProvider.overrideWith(_TestSettingsNotifier.new),
581 chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
582 serverApiServiceProvider.overrideWithValue(mockApiService),
583 ],
584 );
585 addTearDown(container.dispose);
586
587 await tester.pumpWidget(
588 UncontrolledProviderScope(
589 container: container,
590 child: ShadTheme(
591 data: AppTheme.lightShadTheme,
592 child: const MaterialApp(
593 locale: Locale('en'),
594 localizationsDelegates: AppLocalizations.localizationsDelegates,
595 supportedLocales: AppLocalizations.supportedLocales,
596 home: OnboardingServerSetupScreen(
597 selectedType: ServerType.openAICompatible,
598 ),
599 ),
600 ),
601 ),
602 );
603 await tester.pumpAndSettle();
604
605 await tester.tap(find.text('Test Connection'));
606 await tester.pump();
607
608 // Unmount before completing the in-flight request.
609 await tester.pumpWidget(
610 UncontrolledProviderScope(
611 container: container,
612 child: const MaterialApp(home: Scaffold(body: SizedBox())),
613 ),
614 );
615 await tester.pump();
616
617 // Complete with a 500 response so the testConnection method returns
618 // false (no exception thrown). This drives both the try branch
619 // (isConnected == false) and the finally branch (sets
620 // _isTesting = false) on the now-disposed state. Without the
621 // mounted guard, the finally setState would crash with
622 // "Null check operator used on a null value" (#65).
623 connectionCompleter.complete(
624 ResponseBody.fromString('{"error":"server error"}', 500),
625 );
626 await tester.pumpAndSettle();
627
628 expect(tester.takeException(), isNull);
629 },
630 );
631 });
632
633 group('Issue #69: Riverpod Ref Disposed Async Race Protection', () {
634 test(
635 'ConnectionStatusNotifier does not throw or access disposed Ref when container is disposed during testConnection',
636 () async {
637 final completer = Completer<ResponseBody>();
638 final dio = Dio()
639 ..httpClientAdapter = _MockHttpClientAdapter((_) => completer.future);
640 final apiService = ServerApiService(dio);
641
642 final server = Server(
643 id: 'server-69-a',
644 name: 'Ollama Test',
645 host: '127.0.0.1',
646 port: 11434,
647 type: ServerType.ollama,
648 createdAt: now,
649 lastConnectedAt: now,
650 );
651
652 final container = ProviderContainer(
653 overrides: [
654 serverApiServiceProvider.overrideWithValue(apiService),
655 activeServerProvider.overrideWith(
656 () => _StubActiveServerNotifier(server),
657 ),
658 serversProvider.overrideWith(() => _StubServersNotifier(server)),
659 ],
660 );
661
662 // Read connection status to trigger build and microtask
663 expect(
664 container.read(connectionStatusProvider),
665 ConnectionStatus.checking,
666 );
667
668 // Allow microtask to run
669 await Future<void>.delayed(Duration.zero);
670
671 // Dispose container while connection test is in flight
672 container.dispose();
673
674 // Complete the network request after disposal
675 completer.complete(
676 ResponseBody.fromString(
677 '{"version":"0.1.0"}',
678 200,
679 headers: {
680 Headers.contentTypeHeader: [Headers.jsonContentType],
681 },
682 ),
683 );
684
685 // Allow any microtasks / async continuations to run without throwing StateError
686 await Future<void>.delayed(const Duration(milliseconds: 50));
687 },
688 );
689
690 test(
691 'ConnectionStatusNotifier ignores stale older generation when server switches rapidly',
692 () async {
693 final completer1 = Completer<ResponseBody>();
694 final completer2 = Completer<ResponseBody>();
695
696 var callCount = 0;
697 final dio = Dio()
698 ..httpClientAdapter = _MockHttpClientAdapter((options) {
699 callCount++;
700 if (callCount == 1) return completer1.future;
701 return completer2.future;
702 });
703 final apiService = ServerApiService(dio);
704
705 final serverA = Server(
706 id: 'server-69-a',
707 name: 'Server A',
708 host: '127.0.0.1',
709 port: 11434,
710 type: ServerType.ollama,
711 createdAt: now,
712 lastConnectedAt: now,
713 );
714 final serverB = Server(
715 id: 'server-69-b',
716 name: 'Server B',
717 host: '127.0.0.1',
718 port: 1234,
719 type: ServerType.lmStudio,
720 createdAt: now,
721 lastConnectedAt: now,
722 );
723
724 final activeNotifier = _MutableActiveServerNotifier(serverA);
725
726 final container = ProviderContainer(
727 overrides: [
728 serverApiServiceProvider.overrideWithValue(apiService),
729 activeServerProvider.overrideWith(() => activeNotifier),
730 serversProvider.overrideWith(() => _StubServersNotifier(serverA)),
731 ],
732 );
733 addTearDown(container.dispose);
734
735 expect(
736 container.read(connectionStatusProvider),
737 ConnectionStatus.checking,
738 );
739 await Future<void>.delayed(Duration.zero);
740
741 // Switch active server to Server B before Server A's check completes
742 activeNotifier.setServer(serverB);
743 container.read(connectionStatusProvider);
744 await Future<void>.delayed(Duration.zero);
745
746 // Server A completes as disconnected
747 completer1.complete(ResponseBody.fromString('error', 500));
748 await Future<void>.delayed(const Duration(milliseconds: 10));
749
750 // Server B completes as connected
751 completer2.complete(
752 ResponseBody.fromString(
753 '{"data":[]}',
754 200,
755 headers: {
756 Headers.contentTypeHeader: [Headers.jsonContentType],
757 },
758 ),
759 );
760 await Future<void>.delayed(const Duration(milliseconds: 10));
761
762 // State should reflect Server B's connected status, not Server A's error
763 expect(
764 container.read(connectionStatusProvider),
765 ConnectionStatus.connected,
766 );
767 },
768 );
769 });
770
771 group('Issue #71: GitHub URL Launcher Safe Failure Handling', () {
772 testWidgets(
773 'GitHubRepoCard does not throw an unhandled exception when tapped',
774 (tester) async {
775 // In the test environment there is no native URL launcher. The
776 // try/catch in _launchUrl must swallow any failure so the app
777 // does not crash — that is the regression guard for issue #71.
778 await tester.pumpWidget(
779 const MaterialApp(
780 localizationsDelegates: [
781 AppLocalizations.delegate,
782 GlobalMaterialLocalizations.delegate,
783 GlobalWidgetsLocalizations.delegate,
784 GlobalCupertinoLocalizations.delegate,
785 ],
786 supportedLocales: AppLocalizations.supportedLocales,
787 home: Scaffold(body: Center(child: GitHubRepoCard())),
788 ),
789 );
790 await tester.pumpAndSettle();
791
792 await tester.tap(find.byType(GitHubRepoCard));
793 await tester.pumpAndSettle();
794
795 expect(tester.takeException(), isNull);
796 },
797 );
798 });
799
800 group(
801 'Issue Crash Fix: Riverpod UncontrolledProviderScope setState during build on route pop',
802 () {
803 testWidgets(
804 'SafeRiverpodScopeHost prevents setState during build when resuming paused providers on route pop',
805 (tester) async {
806 final serverA = Server(
807 id: 'server-initial',
808 name: 'Initial Server',
809 host: '127.0.0.1',
810 port: 11434,
811 type: ServerType.ollama,
812 createdAt: now,
813 lastConnectedAt: now,
814 );
815
816 final serversNotifier = _MutableServersNotifier([serverA]);
817 final container = ProviderContainer(
818 overrides: [serversProvider.overrideWith(() => serversNotifier)],
819 );
820 addTearDown(container.dispose);
821
822 final navigatorKey = GlobalKey<NavigatorState>();
823
824 await tester.pumpWidget(
825 UncontrolledProviderScope(
826 container: container,
827 child: SafeRiverpodScopeHost(
828 container: container,
829 child: MaterialApp(
830 navigatorKey: navigatorKey,
831 home: Consumer(
832 builder: (context, ref, _) {
833 final servers = ref.watch(serversProvider);
834 return Scaffold(
835 body: Text('Count: ${servers.value?.length ?? 0}'),
836 );
837 },
838 ),
839 ),
840 ),
841 ),
842 );
843 await tester.pumpAndSettle();
844
845 expect(find.text('Count: 1'), findsOneWidget);
846
847 // Push a modal route on top (this pauses the home route's subscriptions in Riverpod 3.x)
848 navigatorKey.currentState!.push(
849 MaterialPageRoute(
850 builder: (context) => Scaffold(
851 body: ElevatedButton(
852 onPressed: () {
853 final serverB = Server(
854 id: 'server-added',
855 name: 'Added Server',
856 host: '127.0.0.1',
857 port: 1234,
858 type: ServerType.lmStudio,
859 createdAt: now,
860 lastConnectedAt: now,
861 );
862 serversNotifier.addServerSync(serverB);
863 Navigator.of(context).pop();
864 },
865 child: const Text('Add & Pop'),
866 ),
867 ),
868 ),
869 );
870 await tester.pumpAndSettle();
871
872 expect(find.text('Add & Pop'), findsOneWidget);
873
874 // Tap Add & Pop. This mutates serversProvider and pops while the home route was paused.
875 // Resuming during route pop triggers the build-phase flush.
876 await tester.tap(find.text('Add & Pop'));
877 await tester.pumpAndSettle();
878
879 expect(tester.takeException(), isNull);
880 expect(find.text('Count: 2'), findsOneWidget);
881 },
882 );
883
884 testWidgets(
885 'AddServerScreen save flow and route pop does not crash with setState during build',
886 (tester) async {
887 SharedPreferences.setMockInitialValues({});
888 final prefs = await SharedPreferences.getInstance();
889
890 final serverA = Server(
891 id: 'server-existing',
892 name: 'Existing Server',
893 host: '127.0.0.1',
894 port: 11434,
895 type: ServerType.ollama,
896 createdAt: now,
897 lastConnectedAt: now,
898 );
899
900 final mockDio = Dio();
901 mockDio.httpClientAdapter = _MockHttpClientAdapter((options) {
902 return ResponseBody.fromString('{"models":[]}', 200);
903 });
904 final mockApiService = ServerApiService(mockDio);
905 final serversNotifier = _MutableServersNotifier([serverA]);
906
907 final container = ProviderContainer(
908 overrides: [
909 sharedPreferencesProvider.overrideWithValue(prefs),
910 settingsProvider.overrideWith(_TestSettingsNotifier.new),
911 chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
912 serverApiServiceProvider.overrideWithValue(mockApiService),
913 serversProvider.overrideWith(() => serversNotifier),
914 activeServerProvider.overrideWith(
915 () => _StubActiveServerNotifier(serverA),
916 ),
917 connectionStatusProvider.overrideWith(
918 _StubConnectedStatusNotifier.new,
919 ),
920 ],
921 );
922 addTearDown(container.dispose);
923
924 final navigatorKey = GlobalKey<NavigatorState>();
925
926 await tester.pumpWidget(
927 UncontrolledProviderScope(
928 container: container,
929 child: SafeRiverpodScopeHost(
930 container: container,
931 child: ShadTheme(
932 data: AppTheme.lightShadTheme,
933 child: MaterialApp(
934 navigatorKey: navigatorKey,
935 locale: const Locale('en'),
936 localizationsDelegates:
937 AppLocalizations.localizationsDelegates,
938 supportedLocales: AppLocalizations.supportedLocales,
939 home: Consumer(
940 builder: (context, ref, _) {
941 final servers = ref.watch(serversProvider);
942 return Scaffold(
943 body: Center(
944 child: ElevatedButton(
945 onPressed: () {
946 Navigator.of(context).push(
947 MaterialPageRoute(
948 builder: (_) => const AddServerScreen(),
949 ),
950 );
951 },
952 child: Text(
953 'Open Add Screen (${servers.value?.length ?? 0})',
954 ),
955 ),
956 ),
957 );
958 },
959 ),
960 ),
961 ),
962 ),
963 ),
964 );
965 await tester.pumpAndSettle();
966
967 tester.view.physicalSize = const Size(800, 1800);
968 tester.view.devicePixelRatio = 1.0;
969 addTearDown(() {
970 tester.view.resetPhysicalSize();
971 tester.view.resetDevicePixelRatio();
972 });
973
974 // Open Add Server screen
975 navigatorKey.currentState!.push(
976 MaterialPageRoute(builder: (_) => const AddServerScreen()),
977 );
978 await tester.pumpAndSettle();
979
980 // Enter server details
981 final textFields = find.byType(TextFormField);
982 expect(textFields, findsWidgets);
983
984 await tester.enterText(textFields.at(0), 'New Ollama');
985 await tester.enterText(textFields.at(1), '127.0.0.1');
986 await tester.enterText(textFields.at(2), '11434');
987 await tester.pump();
988
989 // Tap Save Server
990 final saveButton = find.widgetWithText(ElevatedButton, 'Save Server');
991 expect(saveButton, findsOneWidget);
992
993 await tester.tap(saveButton);
994 await tester.pumpAndSettle();
995
996 expect(tester.takeException(), isNull);
997 expect(find.text('Open Add Screen (2)'), findsOneWidget);
998 },
999 );
1000
1001 testWidgets(
1002 'CreatePersonaScreen save flow and route pop does not crash with setState during build',
1003 (tester) async {
1004 SharedPreferences.setMockInitialValues({});
1005 final prefs = await SharedPreferences.getInstance();
1006
1007 final personasNotifier = _StubPersonasNotifier([]);
1008
1009 final container = ProviderContainer(
1010 overrides: [
1011 sharedPreferencesProvider.overrideWithValue(prefs),
1012 personasNotifierProvider.overrideWith(() => personasNotifier),
1013 ],
1014 );
1015 addTearDown(container.dispose);
1016
1017 final navigatorKey = GlobalKey<NavigatorState>();
1018
1019 await tester.pumpWidget(
1020 UncontrolledProviderScope(
1021 container: container,
1022 child: SafeRiverpodScopeHost(
1023 container: container,
1024 child: MaterialApp(
1025 navigatorKey: navigatorKey,
1026 locale: const Locale('en'),
1027 localizationsDelegates:
1028 AppLocalizations.localizationsDelegates,
1029 supportedLocales: AppLocalizations.supportedLocales,
1030 home: Consumer(
1031 builder: (context, ref, _) {
1032 final personas = ref.watch(personasNotifierProvider);
1033 return Scaffold(
1034 body: Center(
1035 child: ElevatedButton(
1036 onPressed: () {
1037 Navigator.of(context).push(
1038 MaterialPageRoute(
1039 builder: (_) => const CreatePersonaScreen(),
1040 ),
1041 );
1042 },
1043 child: Text(
1044 'Open Persona Screen (${personas.value?.length ?? 0})',
1045 ),
1046 ),
1047 ),
1048 );
1049 },
1050 ),
1051 ),
1052 ),
1053 ),
1054 );
1055 await tester.pumpAndSettle();
1056
1057 tester.view.physicalSize = const Size(800, 1800);
1058 tester.view.devicePixelRatio = 1.0;
1059 addTearDown(() {
1060 tester.view.resetPhysicalSize();
1061 tester.view.resetDevicePixelRatio();
1062 });
1063
1064 // Open Create Persona screen
1065 navigatorKey.currentState!.push(
1066 MaterialPageRoute(builder: (_) => const CreatePersonaScreen()),
1067 );
1068 await tester.pumpAndSettle();
1069
1070 // Enter persona details
1071 final textFields = find.byType(TextFormField);
1072 expect(textFields, findsWidgets);
1073
1074 await tester.enterText(textFields.at(0), 'Assistant Persona');
1075 await tester.enterText(
1076 textFields.at(2),
1077 'You are a helpful coding assistant.',
1078 );
1079 await tester.pump();
1080
1081 // Tap Create Persona button
1082 final createButton = find.widgetWithText(FilledButton, 'Create');
1083 expect(createButton, findsOneWidget);
1084
1085 await tester.tap(createButton);
1086 await tester.pumpAndSettle();
1087
1088 expect(tester.takeException(), isNull);
1089 expect(find.text('Open Persona Screen (1)'), findsOneWidget);
1090 },
1091 );
1092 },
1093 );
1094
1095 group('Issue #86: TTS Empty AudioSource RangeError Prevention', () {
1096 test(
1097 'ConcatenatingAudioSource is initialized with at least one chunk to avoid RangeError',
1098 () {
1099 final tempDir = Directory.systemTemp;
1100 final tempFile = File('${tempDir.path}/tts_chunk_test_86.wav');
1101 tempFile.writeAsBytesSync(Uint8List(100));
1102 addTearDown(() {
1103 if (tempFile.existsSync()) tempFile.deleteSync();
1104 });
1105
1106 final firstSource = AudioSource.file(
1107 tempFile.path,
1108 tag: const MediaItem(
1109 id: 'tts_chunk_0',
1110 album: 'LocalMind TTS',
1111 title: 'Test chunk',
1112 ),
1113 );
1114
1115 // ignore: deprecated_member_use
1116 final playlist = ConcatenatingAudioSource(children: [firstSource]);
1117 expect(playlist.children, isNotEmpty);
1118 expect(playlist.children.length, 1);
1119 expect(playlist.children.first, equals(firstSource));
1120 },
1121 );
1122
1123 test(
1124 'TtsNotifier does not initialize player with empty AudioSources on startup',
1125 () {
1126 final notifier = TtsNotifier();
1127 // On startup before playback begins, player should not have an empty audioSource
1128 // which causes RangeError: Valid value range is empty: 0 in just_audio_background.
1129 expect(notifier.player.audioSource, isNull);
1130 },
1131 );
1132
1133 test('stopping playback can clear a previously loaded playlist', () async {
1134 final tempFile = File(
1135 '${Directory.systemTemp.path}/tts_chunk_test_86_clear.wav',
1136 );
1137 await tempFile.writeAsBytes(Uint8List(100));
1138 final player = AudioPlayer();
1139 addTearDown(() async {
1140 await player.dispose();
1141 if (await tempFile.exists()) await tempFile.delete();
1142 });
1143 await player.setAudioSources([
1144 AudioSource.file(
1145 tempFile.path,
1146 tag: const MediaItem(
1147 id: 'tts_chunk_clear',
1148 album: 'LocalMind TTS',
1149 title: 'Clear playlist',
1150 ),
1151 ),
1152 ], preload: false);
1153 expect(player.audioSources, hasLength(1));
1154
1155 await player.stop();
1156 await player.clearAudioSources();
1157
1158 expect(player.audioSources, isEmpty);
1159 });
1160 });
1161 }
1162
1163 class _MutableActiveServerNotifier extends ActiveServerNotifier {
1164 _MutableActiveServerNotifier(this._server);
1165
1166 Server? _server;
1167
1168 @override
1169 Server? build() => _server;
1170
1171 void setServer(Server? server) {
1172 _server = server;
1173 state = server;
1174 }
1175 }
1176
1177 class _StubActiveServerNotifier extends ActiveServerNotifier {
1178 _StubActiveServerNotifier(this.server);
1179
1180 final Server server;
1181
1182 @override
1183 Server? build() => server;
1184 }
1185
1186 class _StubServersNotifier extends ServersNotifier {
1187 _StubServersNotifier(this.server);
1188
1189 final Server server;
1190
1191 @override
1192 Future<List<Server>> build() async => [server];
1193 }
1194
1195 class _MutableServersNotifier extends ServersNotifier {
1196 _MutableServersNotifier(this._servers);
1197
1198 List<Server> _servers;
1199
1200 @override
1201 Future<List<Server>> build() async => _servers;
1202
1203 void addServerSync(Server server) {
1204 _servers = [..._servers, server];
1205 state = AsyncData(_servers);
1206 }
1207
1208 @override
1209 Future<void> addServer(Server server) async {
1210 _servers = [..._servers, server];
1211 state = AsyncData(_servers);
1212 }
1213
1214 @override
1215 Future<void> updateServer(Server server) async {
1216 final index = _servers.indexWhere((s) => s.id == server.id);
1217 if (index != -1) {
1218 _servers[index] = server;
1219 } else {
1220 _servers = [..._servers, server];
1221 }
1222 state = AsyncData(_servers);
1223 }
1224
1225 @override
1226 Future<ConnectionStatus> testConnection(
1227 String serverId,
1228 dynamic apiService,
1229 ) async {
1230 return ConnectionStatus.connected;
1231 }
1232 }
1233
1234 class _StubConnectedStatusNotifier extends ConnectionStatusNotifier {
1235 @override
1236 ConnectionStatus build() => ConnectionStatus.connected;
1237 }
1238
1239 class _MockHttpClientAdapter implements HttpClientAdapter {
1240 final FutureOr<ResponseBody> Function(RequestOptions options) handler;
1241
1242 _MockHttpClientAdapter(this.handler);
1243
1244 @override
1245 Future<ResponseBody> fetch(
1246 RequestOptions options,
1247 Stream<Uint8List>? requestStream,
1248 Future<void>? cancelFuture,
1249 ) async {
1250 return handler(options);
1251 }
1252
1253 @override
1254 void close({bool force = false}) {}
1255 }
1256
1257 class _TestSettingsNotifier extends SettingsNotifier {
1258 @override
1259 AppSettings build() => AppSettings(unloadModelsBeforeLoad: false);
1260 }
1261
1262 class _StubPersonasNotifier extends PersonasNotifier {
1263 _StubPersonasNotifier(this._personas);
1264 final List<Persona> _personas;
1265
1266 @override
1267 Future<List<Persona>> build() async => _personas;
1268
1269 @override
1270 Future<Persona> createPersona({
1271 required String name,
1272 required String emoji,
1273 required String systemPrompt,
1274 String? description,
1275 String? category,
1276 Map<String, dynamic>? preferredParams,
1277 }) async {
1278 final persona = Persona(
1279 id: 'p-${DateTime.now().millisecondsSinceEpoch}',
1280 name: name,
1281 emoji: emoji,
1282 systemPrompt: systemPrompt,
1283 description: description,
1284 category: category,
1285 preferredParams: preferredParams,
1286 createdAt: DateTime.now(),
1287 updatedAt: DateTime.now(),
1288 );
1289 _personas.add(persona);
1290 state = AsyncData([..._personas]);
1291 return persona;
1292 }
1293 }