test(flutter): add 34 widget tests for voice_bar, connect, settings (TODO-017)

Voice bar: 12 tests covering modes, PTT badge, talk power, callbacks.
Connect widgets: 12 tests covering form, bookmark list, sanitization.
Voice settings: 10 tests covering result state, clamping, mode coverage.
This commit is contained in:
Edison Jwa
2026-06-11 12:25:22 +09:00
parent 40883c40c8
commit 2948c029d0
3 changed files with 527 additions and 0 deletions
@@ -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<TextField>(
find.byType(TextField),
).last;
expect(passwordField.obscureText, isTrue);
});
testWidgets('form uses outlined border decoration', (tester) async {
await tester.pumpWidget(buildForm());
final fields = tester.widgetList<TextField>(find.byType(TextField));
for (final field in fields) {
final decoration = field.decoration as InputDecoration;
expect(decoration.border, isA<OutlineInputBorder>());
}
});
});
group('BookmarkList', () {
Widget buildBookmarkList({
required List<rust.BridgeBookmark> bookmarks,
ValueChanged<rust.BridgeBookmark>? onConnect,
ValueChanged<rust.BridgeBookmark>? 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);
});
});
}
@@ -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<bool>? 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);
});
}
@@ -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,
]),
);
});
});
}