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:
@@ -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,
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user