| 1 | import 'dart:convert'; |
| 2 | |
| 3 | import 'package:flutter/foundation.dart'; |
| 4 | import 'package:flutter/services.dart'; |
| 5 | import 'package:flutter_riverpod/flutter_riverpod.dart'; |
| 6 | import 'package:flutter_test/flutter_test.dart'; |
| 7 | import 'package:localmind/features/stt/providers/stt_providers.dart'; |
| 8 | import 'package:localmind/features/stt/utils/stt_error_messages.dart'; |
| 9 | import 'package:speech_to_text/speech_to_text.dart'; |
| 10 | import 'package:speech_to_text_platform_interface/speech_to_text_platform_interface.dart'; |
| 11 | |
| 12 | void main() { |
| 13 | TestWidgetsFlutterBinding.ensureInitialized(); |
| 14 | |
| 15 | late _FakeSpeechPlatform platform; |
| 16 | late SpeechToTextPlatform original; |
| 17 | late ProviderContainer container; |
| 18 | |
| 19 | setUp(() { |
| 20 | original = SpeechToTextPlatform.instance; |
| 21 | platform = _FakeSpeechPlatform(); |
| 22 | SpeechToTextPlatform.instance = platform; |
| 23 | container = ProviderContainer( |
| 24 | overrides: [ |
| 25 | sttProvider.overrideWith( |
| 26 | () => SttNotifier(speechFactory: SpeechToText.withMethodChannel), |
| 27 | ), |
| 28 | ], |
| 29 | ); |
| 30 | }); |
| 31 | |
| 32 | tearDown(() { |
| 33 | container.dispose(); |
| 34 | SpeechToTextPlatform.instance = original; |
| 35 | debugDefaultTargetPlatformOverride = null; |
| 36 | }); |
| 37 | |
| 38 | Future<void> startListening() => |
| 39 | container.read(sttProvider.notifier).startListening(onResult: (_) {}); |
| 40 | |
| 41 | Future<void> waitForRetry() => Future<void>.delayed( |
| 42 | SttNotifier.clientErrorRetryDelay + const Duration(milliseconds: 50), |
| 43 | ); |
| 44 | |
| 45 | test('retries once on error_client with the on-device recognizer', () async { |
| 46 | debugDefaultTargetPlatformOverride = TargetPlatform.android; |
| 47 | await startListening(); |
| 48 | expect(platform.onDeviceFlags, [false]); |
| 49 | |
| 50 | platform.emitError('error_client'); |
| 51 | await waitForRetry(); |
| 52 | |
| 53 | expect(platform.onDeviceFlags, [false, true]); |
| 54 | expect(container.read(sttProvider).error, isNull); |
| 55 | }); |
| 56 | |
| 57 | test('surfaces error_client when the retry fails too', () async { |
| 58 | debugDefaultTargetPlatformOverride = TargetPlatform.android; |
| 59 | await startListening(); |
| 60 | platform.emitError('error_client'); |
| 61 | await waitForRetry(); |
| 62 | platform.emitError('error_client'); |
| 63 | await Future<void>.delayed(Duration.zero); |
| 64 | |
| 65 | expect(platform.onDeviceFlags, [false, true]); |
| 66 | expect(container.read(sttProvider).error, 'error_client'); |
| 67 | |
| 68 | // Neither recognizer worked, so the next attempt uses the default one. |
| 69 | await startListening(); |
| 70 | expect(platform.onDeviceFlags.last, isFalse); |
| 71 | }); |
| 72 | |
| 73 | test('does not retry after the caller stopped listening', () async { |
| 74 | await startListening(); |
| 75 | platform.emitError('error_client'); |
| 76 | await container.read(sttProvider.notifier).cancelListening(); |
| 77 | await waitForRetry(); |
| 78 | |
| 79 | expect(platform.onDeviceFlags, hasLength(1)); |
| 80 | }); |
| 81 | |
| 82 | test('reports a missing recognizer as stt_unavailable', () async { |
| 83 | platform.initError = PlatformException(code: 'recognizerNotAvailable'); |
| 84 | final available = await container.read(sttProvider.notifier).initSpeech(); |
| 85 | |
| 86 | expect(available, isFalse); |
| 87 | expect(container.read(sttProvider).error, sttUnavailableCode); |
| 88 | }); |
| 89 | } |
| 90 | |
| 91 | class _FakeSpeechPlatform extends SpeechToTextPlatform { |
| 92 | final List<bool> onDeviceFlags = []; |
| 93 | PlatformException? initError; |
| 94 | |
| 95 | void emitError(String code) { |
| 96 | onError?.call(jsonEncode({'errorMsg': code, 'permanent': true})); |
| 97 | } |
| 98 | |
| 99 | @override |
| 100 | Future<bool> hasPermission() async => true; |
| 101 | |
| 102 | @override |
| 103 | Future<bool> initialize({ |
| 104 | debugLogging = false, |
| 105 | List<SpeechConfigOption>? options, |
| 106 | }) async { |
| 107 | final error = initError; |
| 108 | if (error != null) throw error; |
| 109 | return true; |
| 110 | } |
| 111 | |
| 112 | @override |
| 113 | Future<bool> listen({ |
| 114 | String? localeId, |
| 115 | partialResults = true, |
| 116 | onDevice = false, |
| 117 | int listenMode = 0, |
| 118 | sampleRate = 0, |
| 119 | SpeechListenOptions? options, |
| 120 | }) async { |
| 121 | onDeviceFlags.add(options?.onDevice ?? onDevice as bool); |
| 122 | return true; |
| 123 | } |
| 124 | |
| 125 | @override |
| 126 | Future<void> stop() async {} |
| 127 | |
| 128 | @override |
| 129 | Future<void> cancel() async {} |
| 130 | |
| 131 | @override |
| 132 | Future<List<dynamic>> locales() async => []; |
| 133 | } |