diff --git a/apps/chanora_flutter/test/widgets/connect_widgets_test.dart b/apps/chanora_flutter/test/widgets/connect_widgets_test.dart new file mode 100644 index 0000000..d45a75a --- /dev/null +++ b/apps/chanora_flutter/test/widgets/connect_widgets_test.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/l10n/generated/app_localizations.dart'; +import 'package:chanora_flutter/src/rust/api.dart' as rust; +import 'package:chanora_flutter/widgets/connect_widgets.dart'; + +void main() { + group('ConnectForm', () { + late TextEditingController hostCtl; + late TextEditingController nickCtl; + late TextEditingController passwordCtl; + + Widget buildForm({ + VoidCallback? onConnect, + VoidCallback? onAddBookmark, + }) { + return MaterialApp( + localizationsDelegates: AppL10n.localizationsDelegates, + supportedLocales: AppL10n.supportedLocales, + home: Scaffold( + body: ConnectForm( + hostCtl: hostCtl, + nickCtl: nickCtl, + passwordCtl: passwordCtl, + onConnect: onConnect ?? () {}, + onAddBookmark: onAddBookmark ?? () {}, + ), + ), + ); + } + + setUp(() { + hostCtl = TextEditingController(); + nickCtl = TextEditingController(); + passwordCtl = TextEditingController(); + }); + + tearDown(() { + hostCtl.dispose(); + nickCtl.dispose(); + passwordCtl.dispose(); + }); + + testWidgets('renders all three text fields', (tester) async { + await tester.pumpWidget(buildForm()); + + expect(find.byType(TextField), findsNWidgets(3)); + expect(find.byIcon(Icons.dns_outlined), findsOneWidget); + expect(find.byIcon(Icons.login), findsOneWidget); + expect(find.byIcon(Icons.bookmark_add_outlined), findsOneWidget); + }); + + testWidgets('connect callback fires on button tap', (tester) async { + var connected = false; + await tester.pumpWidget(buildForm( + onConnect: () => connected = true, + )); + + await tester.tap(find.byIcon(Icons.login)); + expect(connected, isTrue); + }); + + testWidgets('bookmark callback fires on button tap', (tester) async { + var bookmarked = false; + await tester.pumpWidget(buildForm( + onAddBookmark: () => bookmarked = true, + )); + + await tester.tap(find.byIcon(Icons.bookmark_add_outlined)); + expect(bookmarked, isTrue); + }); + + testWidgets('host field lowercases and strips whitespace', (tester) async { + await tester.pumpWidget(buildForm()); + + await tester.enterText( + find.widgetWithText(TextField, 'host[:port]'), + ' MyServer.COM ', + ); + await tester.pump(); + + expect(hostCtl.text, 'myserver.com'); + }); + + testWidgets('password field is obscured', (tester) async { + await tester.pumpWidget(buildForm()); + + final passwordField = tester.widgetList( + find.byType(TextField), + ).last; + expect(passwordField.obscureText, isTrue); + }); + + testWidgets('form uses outlined border decoration', (tester) async { + await tester.pumpWidget(buildForm()); + + final fields = tester.widgetList(find.byType(TextField)); + for (final field in fields) { + final decoration = field.decoration as InputDecoration; + expect(decoration.border, isA()); + } + }); + }); + + group('BookmarkList', () { + Widget buildBookmarkList({ + required List bookmarks, + ValueChanged? onConnect, + ValueChanged? onDelete, + }) { + return MaterialApp( + localizationsDelegates: AppL10n.localizationsDelegates, + supportedLocales: AppL10n.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: BookmarkList( + bookmarks: bookmarks, + onConnect: onConnect ?? (_) {}, + onDelete: onDelete ?? (_) {}, + ), + ), + ), + ); + } + + const testBookmark = rust.BridgeBookmark( + id: 1, + displayName: 'My Server', + host: 'ts.example.com', + nickname: 'TestUser', + password: '', + ); + + const testBookmark2 = rust.BridgeBookmark( + id: 2, + displayName: 'Work Server', + host: 'work.ts.com', + nickname: 'WorkNick', + password: 'secret', + ); + + testWidgets('shows empty message when no bookmarks', (tester) async { + await tester.pumpWidget(buildBookmarkList(bookmarks: const [])); + + expect(find.byType(BookmarkList), findsOneWidget); + expect(find.byType(Card), findsNothing); + }); + + testWidgets('renders bookmark cards with name and host', (tester) async { + await tester.pumpWidget(buildBookmarkList( + bookmarks: const [testBookmark], + )); + + expect(find.text('My Server'), findsOneWidget); + expect(find.text('ts.example.com — TestUser'), findsOneWidget); + expect(find.byType(Card), findsOneWidget); + }); + + testWidgets('renders multiple bookmarks', (tester) async { + await tester.pumpWidget(buildBookmarkList( + bookmarks: const [testBookmark, testBookmark2], + )); + + expect(find.text('My Server'), findsOneWidget); + expect(find.text('Work Server'), findsOneWidget); + expect(find.byType(Card), findsNWidgets(2)); + }); + + testWidgets('connect callback fires with correct bookmark', (tester) async { + rust.BridgeBookmark? connectedBookmark; + await tester.pumpWidget(buildBookmarkList( + bookmarks: const [testBookmark], + onConnect: (b) => connectedBookmark = b, + )); + + final connectButtons = find.byIcon(Icons.login); + await tester.tap(connectButtons.first); + + expect(connectedBookmark, testBookmark); + }); + + testWidgets('delete callback fires with correct bookmark', (tester) async { + rust.BridgeBookmark? deletedBookmark; + await tester.pumpWidget(buildBookmarkList( + bookmarks: const [testBookmark], + onDelete: (b) => deletedBookmark = b, + )); + + final deleteButtons = find.byIcon(Icons.delete_outline); + await tester.tap(deleteButtons.first); + + expect(deletedBookmark, testBookmark); + }); + + testWidgets('each bookmark card has connect and delete buttons', (tester) async { + await tester.pumpWidget(buildBookmarkList( + bookmarks: const [testBookmark], + )); + + expect(find.byIcon(Icons.login), findsOneWidget); + expect(find.byIcon(Icons.delete_outline), findsOneWidget); + }); + }); +} diff --git a/apps/chanora_flutter/test/widgets/voice_bar_test.dart b/apps/chanora_flutter/test/widgets/voice_bar_test.dart new file mode 100644 index 0000000..076ff83 --- /dev/null +++ b/apps/chanora_flutter/test/widgets/voice_bar_test.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/l10n/generated/app_localizations.dart'; +import 'package:chanora_flutter/src/rust/api.dart' as rust; +import 'package:chanora_flutter/widgets/voice_bar.dart'; + +void main() { + const defaultStats = rust.BridgeAudioStats( + framesSent: 100, + framesReceived: 200, + pttActive: false, + inputLevel: -30.0, + ); + + Widget buildVoiceBar({ + bool inChannel = true, + rust.BridgeTransmitMode transmitMode = rust.BridgeTransmitMode.ptt, + bool hardMute = false, + bool outputMuted = false, + bool talkPowerBlocked = false, + int releaseTailMs = 150, + String channelName = 'Test Channel', + rust.BridgeAudioStats? audioStats = defaultStats, + double? inputLevel, + String pttLevel = 'L1WindowsHook', + String pttBackendId = 'windows-raw-input', + String pttBoundInputClass = 'keyboard', + String pttBoundKeyLabel = 'Space', + VoidCallback? onConfigure, + ValueChanged? onPttHeldChanged, + }) { + return MaterialApp( + localizationsDelegates: AppL10n.localizationsDelegates, + supportedLocales: AppL10n.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: VoiceBar( + inChannel: inChannel, + transmitMode: transmitMode, + hardMute: hardMute, + outputMuted: outputMuted, + talkPowerBlocked: talkPowerBlocked, + releaseTailMs: releaseTailMs, + channelName: channelName, + audioStats: audioStats, + inputLevel: inputLevel, + pttLevel: pttLevel, + pttBackendId: pttBackendId, + pttBoundInputClass: pttBoundInputClass, + pttBoundKeyLabel: pttBoundKeyLabel, + onConfigure: onConfigure ?? () {}, + onPttHeldChanged: onPttHeldChanged ?? (_) {}, + ), + ), + ), + ); + } + + testWidgets('VoiceBar renders in PTT mode with stats', (tester) async { + await tester.pumpWidget(buildVoiceBar()); + + expect(find.byType(VoiceBar), findsOneWidget); + expect(find.byIcon(Icons.radio_button_checked), findsOneWidget); + expect(find.byIcon(Icons.tune), findsOneWidget); + }); + + testWidgets('VoiceBar renders in continuous mode', (tester) async { + await tester.pumpWidget(buildVoiceBar( + transmitMode: rust.BridgeTransmitMode.continuous, + )); + + expect(find.byType(VoiceBar), findsOneWidget); + expect(find.byIcon(Icons.podcasts), findsOneWidget); + }); + + testWidgets('VoiceBar shows channel status when in channel', (tester) async { + await tester.pumpWidget(buildVoiceBar(inChannel: true)); + + expect(find.byType(VoiceBar), findsOneWidget); + }); + + testWidgets('VoiceBar hides channel status when not in channel', (tester) async { + await tester.pumpWidget(buildVoiceBar( + inChannel: false, + channelName: '', + )); + + expect(find.byType(VoiceBar), findsOneWidget); + }); + + testWidgets('VoiceBar shows talk power blocked indicator', (tester) async { + await tester.pumpWidget(buildVoiceBar( + talkPowerBlocked: true, + )); + + expect(find.text('Insufficient talk power'), findsOneWidget); + }); + + testWidgets('VoiceBar shows audio stats line when stats available', (tester) async { + await tester.pumpWidget(buildVoiceBar()); + + expect(find.byType(VoiceBar), findsOneWidget); + }); + + testWidgets('VoiceBar hides audio stats line when stats null', (tester) async { + await tester.pumpWidget(buildVoiceBar( + audioStats: null, + )); + + expect(find.byType(VoiceBar), findsOneWidget); + }); + + testWidgets('VoiceBar onConfigure callback fires', (tester) async { + var configured = false; + await tester.pumpWidget(buildVoiceBar( + onConfigure: () => configured = true, + )); + + await tester.tap(find.byIcon(Icons.tune)); + expect(configured, isTrue); + }); + + testWidgets('VoiceBar renders PTT capability badge in PTT mode', (tester) async { + await tester.pumpWidget(buildVoiceBar( + transmitMode: rust.BridgeTransmitMode.ptt, + pttLevel: 'L1WindowsHook', + )); + + expect(find.byIcon(Icons.public), findsOneWidget); + }); + + testWidgets('VoiceBar does not render PTT badge in continuous mode', (tester) async { + await tester.pumpWidget(buildVoiceBar( + transmitMode: rust.BridgeTransmitMode.continuous, + )); + + expect(find.byIcon(Icons.public), findsNothing); + }); + + testWidgets('VoiceBar renders with active PTT state', (tester) async { + const activeStats = rust.BridgeAudioStats( + framesSent: 500, + framesReceived: 300, + pttActive: true, + inputLevel: -10.0, + ); + + await tester.pumpWidget(buildVoiceBar(audioStats: activeStats)); + + expect(find.byType(VoiceBar), findsOneWidget); + }); + + testWidgets('VoiceBar renders with input level override', (tester) async { + await tester.pumpWidget(buildVoiceBar( + inputLevel: -20.0, + )); + + expect(find.byType(VoiceBar), findsOneWidget); + }); + + testWidgets('VoiceBar renders with null input level', (tester) async { + await tester.pumpWidget(buildVoiceBar( + audioStats: null, + inputLevel: null, + )); + + expect(find.byType(VoiceBar), findsOneWidget); + }); +} diff --git a/apps/chanora_flutter/test/widgets/voice_settings_test.dart b/apps/chanora_flutter/test/widgets/voice_settings_test.dart new file mode 100644 index 0000000..413ec7a --- /dev/null +++ b/apps/chanora_flutter/test/widgets/voice_settings_test.dart @@ -0,0 +1,152 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/src/rust/api.dart' as rust; +import 'package:chanora_flutter/widgets/voice_settings.dart'; + +rust.BridgeAudioProcessingConfig _defaultConfig() { + return rust.BridgeAudioProcessingConfig( + route: rust.BridgeAudioRoute.unknown, + iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing, + processingBackend: rust.BridgeAudioBackend.webrtcApm, + vadBackend: rust.BridgeVadBackend.sileroOnnx, + aec: rust.BridgeEffectOwner.webrtcApm, + ns: rust.BridgeEffectOwner.webrtcApm, + agc: rust.BridgeEffectOwner.webrtcApm, + hpfEnabled: true, + limiterEnabled: true, + vadHangoverMs: 500, + vadPreRollMs: 160, + vadMinTxMs: 200, + debugWavDumpEnabled: false, + ); +} + +void main() { + group('VoiceSettingsResult', () { + test('stores PTT mode result', () { + final config = _defaultConfig(); + final result = VoiceSettingsResult( + mode: rust.BridgeTransmitMode.ptt, + releaseTailMs: 200, + bindKeyRequested: true, + audioConfig: config, + ); + + expect(result.mode, rust.BridgeTransmitMode.ptt); + expect(result.releaseTailMs, 200); + expect(result.bindKeyRequested, isTrue); + expect(result.audioConfig, config); + }); + + test('stores continuous mode result without key binding', () { + final config = _defaultConfig(); + final result = VoiceSettingsResult( + mode: rust.BridgeTransmitMode.continuous, + releaseTailMs: 0, + bindKeyRequested: false, + audioConfig: config, + ); + + expect(result.mode, rust.BridgeTransmitMode.continuous); + expect(result.releaseTailMs, 0); + expect(result.bindKeyRequested, isFalse); + }); + + test('stores voice activity mode result', () { + final config = _defaultConfig(); + final result = VoiceSettingsResult( + mode: rust.BridgeTransmitMode.voiceActivity, + releaseTailMs: 100, + bindKeyRequested: false, + audioConfig: config, + ); + + expect(result.mode, rust.BridgeTransmitMode.voiceActivity); + expect(result.releaseTailMs, 100); + }); + + test('preserves audio config fields', () { + final config = rust.BridgeAudioProcessingConfig( + route: rust.BridgeAudioRoute.bluetoothHfp, + iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing, + processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing, + vadBackend: rust.BridgeVadBackend.webrtcVad, + aec: rust.BridgeEffectOwner.platform, + ns: rust.BridgeEffectOwner.off, + agc: rust.BridgeEffectOwner.off, + hpfEnabled: false, + limiterEnabled: false, + vadHangoverMs: 300, + vadPreRollMs: 100, + vadMinTxMs: 150, + debugWavDumpEnabled: true, + ); + final result = VoiceSettingsResult( + mode: rust.BridgeTransmitMode.ptt, + releaseTailMs: 250, + bindKeyRequested: false, + audioConfig: config, + ); + + expect(result.audioConfig.route, rust.BridgeAudioRoute.bluetoothHfp); + expect(result.audioConfig.vadBackend, rust.BridgeVadBackend.webrtcVad); + expect(result.audioConfig.ns, rust.BridgeEffectOwner.off); + expect(result.audioConfig.hpfEnabled, isFalse); + expect(result.audioConfig.debugWavDumpEnabled, isTrue); + }); + + test('release tail is always a round number', () { + final result = VoiceSettingsResult( + mode: rust.BridgeTransmitMode.ptt, + releaseTailMs: 123, + bindKeyRequested: false, + audioConfig: _defaultConfig(), + ); + + expect(result.releaseTailMs, 123); + expect(result.releaseTailMs, equals(result.releaseTailMs.round())); + }); + + test('bindKeyRequested defaults to false for save action', () { + final result = VoiceSettingsResult( + mode: rust.BridgeTransmitMode.ptt, + releaseTailMs: 150, + bindKeyRequested: false, + audioConfig: _defaultConfig(), + ); + + expect(result.bindKeyRequested, isFalse); + }); + + test('bindKeyRequested is true for bind-key action', () { + final result = VoiceSettingsResult( + mode: rust.BridgeTransmitMode.ptt, + releaseTailMs: 150, + bindKeyRequested: true, + audioConfig: _defaultConfig(), + ); + + expect(result.bindKeyRequested, isTrue); + }); + }); + + group('VoiceSettingsDialog state management', () { + test('release tail is clamped between 0 and 500', () { + expect(999.clamp(0, 500), 500); + expect((-10).clamp(0, 500), 0); + expect(250.clamp(0, 500), 250); + }); + + test('transmit mode enum covers all three modes', () { + expect(rust.BridgeTransmitMode.values, hasLength(3)); + expect( + rust.BridgeTransmitMode.values, + containsAll([ + rust.BridgeTransmitMode.ptt, + rust.BridgeTransmitMode.continuous, + rust.BridgeTransmitMode.voiceActivity, + ]), + ); + }); + }); +}