| 1 | import 'package:flutter/material.dart'; |
| 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; |
| 3 | import 'package:flutter_test/flutter_test.dart'; |
| 4 | import 'package:localmind/core/models/enums.dart'; |
| 5 | import 'package:localmind/features/tts/providers/tts_providers.dart'; |
| 6 | import 'package:localmind/features/voice_mode/providers/voice_mode_provider.dart'; |
| 7 | import 'package:localmind/features/voice_mode/views/components/voice_transcript.dart'; |
| 8 | |
| 9 | void main() { |
| 10 | testWidgets('highlights the word reported by native TTS progress', ( |
| 11 | tester, |
| 12 | ) async { |
| 13 | const response = 'Hello world from LocalMind'; |
| 14 | const ttsState = TtsState( |
| 15 | isSpeaking: true, |
| 16 | activeEngine: EngineId.system, |
| 17 | playingContent: response, |
| 18 | chunks: [response], |
| 19 | currentChunkIndex: 0, |
| 20 | spokenCharOffset: 6, |
| 21 | ); |
| 22 | |
| 23 | await tester.pumpWidget( |
| 24 | ProviderScope( |
| 25 | overrides: [ttsProvider.overrideWith(() => _StubTtsNotifier(ttsState))], |
| 26 | child: const MaterialApp( |
| 27 | home: Scaffold( |
| 28 | body: VoiceTranscript( |
| 29 | phase: VoiceModePhase.speaking, |
| 30 | transcript: '', |
| 31 | response: response, |
| 32 | ), |
| 33 | ), |
| 34 | ), |
| 35 | ), |
| 36 | ); |
| 37 | |
| 38 | final richText = tester.widget<RichText>(find.byType(RichText).last); |
| 39 | final root = richText.text as TextSpan; |
| 40 | final wordSpan = root.children!.whereType<TextSpan>().singleWhere( |
| 41 | (span) => span.text == 'world', |
| 42 | ); |
| 43 | |
| 44 | expect(wordSpan.style?.fontWeight, FontWeight.w700); |
| 45 | expect(wordSpan.style?.color, isNot(root.style?.color)); |
| 46 | }); |
| 47 | |
| 48 | test('resetProgress clears a native spoken character offset', () { |
| 49 | const state = TtsState(spokenCharOffset: 12); |
| 50 | |
| 51 | expect(state.copyWith(resetProgress: true).spokenCharOffset, -1); |
| 52 | }); |
| 53 | |
| 54 | testWidgets('auto-scrolls to keep a later spoken word visible', ( |
| 55 | tester, |
| 56 | ) async { |
| 57 | final response = List.generate(80, (index) => 'word$index').join(' '); |
| 58 | final activeOffset = response.indexOf('word70'); |
| 59 | final ttsState = TtsState( |
| 60 | isSpeaking: true, |
| 61 | activeEngine: EngineId.system, |
| 62 | playingContent: response, |
| 63 | chunks: [response], |
| 64 | currentChunkIndex: 0, |
| 65 | spokenCharOffset: 0, |
| 66 | ); |
| 67 | final notifier = _StubTtsNotifier(ttsState); |
| 68 | |
| 69 | await tester.pumpWidget( |
| 70 | ProviderScope( |
| 71 | overrides: [ttsProvider.overrideWith(() => notifier)], |
| 72 | child: MaterialApp( |
| 73 | home: Scaffold( |
| 74 | body: SizedBox( |
| 75 | width: 320, |
| 76 | child: VoiceTranscript( |
| 77 | phase: VoiceModePhase.speaking, |
| 78 | transcript: '', |
| 79 | response: response, |
| 80 | ), |
| 81 | ), |
| 82 | ), |
| 83 | ), |
| 84 | ), |
| 85 | ); |
| 86 | |
| 87 | notifier.setSpokenCharOffset(activeOffset); |
| 88 | await tester.pump(); |
| 89 | |
| 90 | final updatedText = tester.widget<RichText>(find.byType(RichText).last); |
| 91 | final updatedRoot = updatedText.text as TextSpan; |
| 92 | final activeWord = updatedRoot.children!.whereType<TextSpan>().singleWhere( |
| 93 | (span) => span.text == 'word70', |
| 94 | ); |
| 95 | expect(activeWord.style?.fontWeight, FontWeight.w700); |
| 96 | |
| 97 | await tester.pump(); |
| 98 | await tester.pump(const Duration(milliseconds: 300)); |
| 99 | |
| 100 | final scrollView = tester.widget<SingleChildScrollView>( |
| 101 | find.byKey(const ValueKey('speaking-scroll')), |
| 102 | ); |
| 103 | expect(scrollView.controller!.offset, greaterThan(0)); |
| 104 | }); |
| 105 | } |
| 106 | |
| 107 | class _StubTtsNotifier extends TtsNotifier { |
| 108 | _StubTtsNotifier(this.initialState); |
| 109 | |
| 110 | final TtsState initialState; |
| 111 | |
| 112 | @override |
| 113 | TtsState build() => initialState; |
| 114 | |
| 115 | void setSpokenCharOffset(int offset) { |
| 116 | state = state.copyWith( |
| 117 | spokenCharOffset: offset, |
| 118 | playingContent: state.playingContent, |
| 119 | ); |
| 120 | } |
| 121 | } |