feat: integrate chat voice and diagnostics client

This commit is contained in:
Edison Jwa
2026-05-23 06:51:55 +09:00
parent 7e28791ec2
commit 7d5d8c2c90
93 changed files with 10273 additions and 3347 deletions
@@ -0,0 +1,32 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/audio_device_list_tile.dart';
void main() {
testWidgets(
'audio device tile renders loading state before devices resolve',
(tester) async {
final pendingDevices = Completer<rust.BridgeAudioDeviceList>();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: AudioDeviceListTile(
label: 'Input',
kind: AudioDeviceKind.input,
loadDevices: () => pendingDevices.future,
),
),
),
);
expect(find.text('Input'), findsOneWidget);
expect(find.text('Loading...'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
},
);
}
@@ -0,0 +1,81 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/audio_processing_config_state.dart';
void main() {
const baseConfig = rust.BridgeAudioProcessingConfig(
route: rust.BridgeAudioRoute.unknown,
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: rust.BridgeVadBackend.disabled,
aec: rust.BridgeEffectOwner.platform,
ns: rust.BridgeEffectOwner.off,
agc: rust.BridgeEffectOwner.webrtcApm,
hpfEnabled: true,
limiterEnabled: false,
vadHangoverMs: 500,
vadPreRollMs: 160,
vadMinTxMs: 200,
debugWavDumpEnabled: true,
);
test('normalizes hidden disabled VAD backend for UI state', () {
final state = AudioProcessingConfigState.fromConfig(baseConfig);
expect(state.vadBackend, rust.BridgeVadBackend.webrtcVad);
expect(state.preferHardware, isTrue);
expect(state.nsEnabled, isFalse);
expect(state.aecEnabled, isTrue);
expect(state.agcEnabled, isTrue);
});
test('default config uses platform processing and Silero VAD', () {
expect(
defaultAudioProcessingConfig.processingBackend,
rust.BridgeAudioBackend.platformVoiceProcessing,
);
expect(
defaultAudioProcessingConfig.vadBackend,
rust.BridgeVadBackend.sileroOnnx,
);
expect(defaultAudioProcessingConfig.aec, rust.BridgeEffectOwner.platform);
expect(defaultAudioProcessingConfig.ns, rust.BridgeEffectOwner.platform);
expect(defaultAudioProcessingConfig.agc, rust.BridgeEffectOwner.platform);
});
test('builds Android hardware config consistently', () {
final state = AudioProcessingConfigState.fromConfig(baseConfig)
..preferHardware = true
..nsEnabled = true
..aecEnabled = false
..agcEnabled = true;
final config = state.buildConfig(base: baseConfig, isAndroid: true);
expect(
config.processingBackend,
rust.BridgeAudioBackend.platformVoiceProcessing,
);
expect(config.vadBackend, rust.BridgeVadBackend.webrtcVad);
expect(config.aec, rust.BridgeEffectOwner.off);
expect(config.ns, rust.BridgeEffectOwner.platform);
expect(config.agc, rust.BridgeEffectOwner.platform);
expect(config.debugWavDumpEnabled, isTrue);
});
test('builds Sonora config with WebRTC-owned enabled effects', () {
final state = AudioProcessingConfigState.fromConfig(baseConfig)
..iosMode = rust.BridgeIosVoiceProcessingMode.sonoraExperimental
..nsEnabled = true
..aecEnabled = false
..agcEnabled = true;
final config = state.buildConfig(base: baseConfig, isAndroid: false);
expect(config.processingBackend, rust.BridgeAudioBackend.webrtcApm);
expect(config.aec, rust.BridgeEffectOwner.off);
expect(config.ns, rust.BridgeEffectOwner.webrtcApm);
expect(config.agc, rust.BridgeEffectOwner.webrtcApm);
});
}
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chanora_flutter/services/link_trust_service.dart';
import 'package:chanora_flutter/services/ts3_server_link.dart';
import 'package:chanora_flutter/widgets/bbcode_text.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('renders links without underline decoration', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: BbCodeText(
'https://example.com',
linkTrust: LinkTrustService.instance,
),
),
);
final linkText = tester.widget<Text>(find.text('https://example.com'));
expect(linkText.style?.color, Colors.blue);
expect(linkText.style?.decoration, isNull);
});
testWidgets('handles TeamSpeak server links without browser launch', (
tester,
) async {
Ts3ServerLink? tapped;
await tester.pumpWidget(
MaterialApp(
home: BbCodeText(
'ts3server://teamspeak.app%3Faddbookmark%3DVigorous%20Pro/',
linkTrust: LinkTrustService.instance,
onTs3ServerLink: (link) async => tapped = link,
),
),
);
await tester.tap(
find.text('ts3server://teamspeak.app%3Faddbookmark%3DVigorous%20Pro/'),
);
await tester.pump();
expect(tapped, isNotNull);
expect(tapped!.host, 'teamspeak.app');
expect(tapped!.addBookmark, 'Vigorous Pro');
});
}
@@ -331,25 +331,98 @@ void main() {
);
});
testWidgets('chat hub does not expose pokes as a chat tab', (tester) async {
testWidgets('chat sidebar keeps server and channel fixed above privates', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: ChatPage(
messages: [
entry(rust.BridgeMessageTarget.poke(BigInt.from(2))),
ChatEntry(
senderId: BigInt.from(2),
senderName: 'Alpha',
message: 'Private',
target: rust.BridgeMessageTarget.client(BigInt.from(2)),
),
entry(const rust.BridgeMessageTarget.server()),
],
snapshot: snapshot(channels: const [], clients: const []),
snapshot: snapshot(
channels: const [],
clients: [
client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.zero),
],
),
),
),
);
expect(find.text('Direct Messages'), findsOneWidget);
expect(find.text('Server Activity'), findsOneWidget);
expect(find.text('Server'), findsWidgets);
expect(find.text('Channel'), findsOneWidget);
expect(find.text('Alpha'), findsWidgets);
expect(find.text('Pokes'), findsNothing);
expect(find.text('No pokes'), findsNothing);
final serverTop = tester.getTopLeft(find.text('Server').first).dy;
final channelTop = tester.getTopLeft(find.text('Channel')).dy;
final privateTop = tester.getTopLeft(find.text('Alpha').first).dy;
expect(serverTop, lessThan(channelTop));
expect(channelTop, lessThan(privateTop));
});
testWidgets(
'plus opens user picker and close removes selected private chat',
(tester) async {
await tester.pumpWidget(
MaterialApp(
home: ChatPage(
messages: [
ChatEntry(
senderId: BigInt.from(2),
senderName: 'Alpha',
message: 'Private',
target: rust.BridgeMessageTarget.client(BigInt.from(2)),
),
],
snapshot: snapshot(
channels: const [],
clients: [
client(
id: BigInt.from(2),
name: 'Alpha',
channelId: BigInt.zero,
),
client(
id: BigInt.from(3),
name: 'Bravo',
channelId: BigInt.zero,
),
],
),
initialTarget: rust.BridgeMessageTarget.client(BigInt.from(2)),
initialClientName: 'Alpha',
),
),
);
expect(find.text('Alpha'), findsWidgets);
expect(find.byTooltip('Close chat'), findsOneWidget);
await tester.tap(find.byTooltip('Close chat'));
await tester.pump();
expect(find.text('Alpha'), findsNothing);
expect(find.text('Server Activity'), findsOneWidget);
await tester.tap(find.byTooltip('New private chat'));
await tester.pumpAndSettle();
expect(find.text('Search clients...'), findsOneWidget);
expect(find.text('Bravo'), findsOneWidget);
},
);
testWidgets('channel chat displays localized poke history in English', (
tester,
) async {
@@ -0,0 +1,437 @@
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/snapshot_view.dart';
void main() {
rust.BridgeChannel channel({
required int id,
int parent = 0,
required String name,
int order = 0,
bool hasPassword = false,
int neededTalkPower = 0,
}) {
return rust.BridgeChannel(
id: BigInt.from(id),
parent: BigInt.from(parent),
name: name,
order: order,
hasPassword: hasPassword,
neededTalkPower: neededTalkPower,
);
}
rust.BridgeClient client({
required int id,
required int channelId,
required String name,
bool speaking = false,
int talkPower = 0,
bool talkPowerGranted = false,
}) {
return rust.BridgeClient(
id: BigInt.from(id),
channel: BigInt.from(channelId),
name: name,
inputMuted: false,
outputMuted: false,
isSpeaking: speaking,
isServerQuery: false,
talkPower: talkPower,
talkPowerGranted: talkPowerGranted,
);
}
Widget snapshotHarness({
required List<rust.BridgeChannel> channels,
required List<rust.BridgeClient> clients,
BigInt? ownClientId,
BigInt? currentVoiceChannelId,
rust.BridgeAudioStats? audioStats,
}) {
return MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SnapshotView(
snapshot: rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: channels,
clients: clients,
ownClientId: ownClientId ?? BigInt.from(100),
),
audioStats: audioStats,
currentVoiceChannelId: currentVoiceChannelId,
pendingVoiceChannelId: null,
localInputMuted: false,
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {},
),
),
);
}
testWidgets('renders channel tree with users and subchannels expanded', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [
channel(id: 1, name: 'Default Channel'),
channel(id: 2, parent: 1, name: 'Default Sub Channel'),
channel(id: 3, name: 'Quiet Zone'),
],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 2, name: 'Bob'),
client(id: 102, channelId: 3, name: 'Carol'),
],
ownClientId: BigInt.from(100),
currentVoiceChannelId: BigInt.from(1),
),
);
await tester.pumpAndSettle();
expect(find.text('Default Channel'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Default Sub Channel'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
expect(find.text('Quiet Zone'), findsOneWidget);
expect(find.text('Carol'), findsOneWidget);
expect(
tester.getTopLeft(find.text('Alice')).dy,
greaterThan(tester.getTopLeft(find.text('Default Channel')).dy),
);
expect(
tester.getTopLeft(find.text('Default Sub Channel')).dy,
greaterThan(tester.getTopLeft(find.text('Alice')).dy),
);
expect(
tester.getTopLeft(find.text('Bob')).dy,
greaterThan(tester.getTopLeft(find.text('Default Sub Channel')).dy),
);
});
testWidgets('collapsing a channel hides users and child channels', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [
channel(id: 1, name: 'Default Channel'),
channel(id: 2, parent: 1, name: 'Default Sub Channel'),
channel(id: 3, name: 'Quiet Zone'),
],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 2, name: 'Bob'),
client(id: 102, channelId: 3, name: 'Carol'),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.expand_more).first);
await tester.pumpAndSettle();
expect(find.text('Default Channel'), findsOneWidget);
expect(find.text('Alice'), findsNothing);
expect(find.text('Default Sub Channel'), findsNothing);
expect(find.text('Bob'), findsNothing);
expect(find.text('Quiet Zone'), findsOneWidget);
expect(find.text('Carol'), findsOneWidget);
await tester.tap(find.byIcon(Icons.chevron_right).first);
await tester.pumpAndSettle();
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Default Sub Channel'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
});
testWidgets('channel tree uses compact fixed columns', (tester) async {
await tester.pumpWidget(
snapshotHarness(
channels: [
channel(id: 1, name: 'Default Channel'),
channel(id: 2, parent: 1, name: 'Default Sub Channel'),
channel(id: 3, name: 'Empty Channel'),
],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 2, name: 'Bob'),
],
),
);
await tester.pumpAndSettle();
final parentX = tester.getTopLeft(find.text('Default Channel')).dx;
final parentUserX = tester.getTopLeft(find.text('Alice')).dx;
final childX = tester.getTopLeft(find.text('Default Sub Channel')).dx;
final childUserX = tester.getTopLeft(find.text('Bob')).dx;
expect(find.text('Channels'), findsNothing);
expect(find.byIcon(Icons.tag), findsNWidgets(3));
expect(parentX, inInclusiveRange(58, 66));
expect(parentUserX - parentX, inInclusiveRange(22, 28));
expect(childX - parentX, inInclusiveRange(10, 14));
expect(childUserX - childX, inInclusiveRange(22, 28));
});
testWidgets('password channel shows lock at row end', (tester) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Locked Channel', hasPassword: true)],
clients: const [],
),
);
await tester.pumpAndSettle();
final rowRight = tester.getTopRight(find.text('Locked Channel')).dx;
final lockLeft = tester.getTopLeft(find.byIcon(Icons.lock_outline)).dx;
expect(find.byIcon(Icons.tag), findsOneWidget);
expect(lockLeft, greaterThan(rowRight));
});
testWidgets('current user row does not show speaking background while idle', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [client(id: 100, channelId: 1, name: 'Alice')],
ownClientId: BigInt.from(100),
currentVoiceChannelId: BigInt.from(1),
),
);
await tester.pumpAndSettle();
expect(find.text('Alice'), findsOneWidget);
expect(find.byIcon(Icons.tag), findsOneWidget);
expect(find.byIcon(Icons.mic_none), findsOneWidget);
expect(find.byType(ListTile), findsOneWidget);
final userHighlight = tester.widget<AnimatedContainer>(
find.ancestor(
of: find.text('Alice'),
matching: find.byType(AnimatedContainer),
),
);
expect(userHighlight.decoration, isNull);
});
testWidgets(
'local voice activity does not light speaking state when blocked',
(tester) async {
await tester.pumpWidget(
snapshotHarness(
channels: [
channel(id: 1, name: 'Default Channel', neededTalkPower: 10),
],
clients: [client(id: 100, channelId: 1, name: 'Alice', talkPower: 0)],
ownClientId: BigInt.from(100),
currentVoiceChannelId: BigInt.from(1),
audioStats: const rust.BridgeAudioStats(
framesSent: 1,
framesReceived: 0,
pttActive: true,
),
),
);
await tester.pumpAndSettle();
final userText = tester.widget<Text>(find.text('Alice'));
expect(find.byIcon(Icons.volume_off), findsOneWidget);
expect(find.byIcon(Icons.mic), findsNothing);
expect(userText.style?.fontWeight, isNot(FontWeight.w600));
},
);
testWidgets('spacer channels render as layout rows and keep channel taps', (
tester,
) async {
final spacerChannel = rust.BridgeChannel(
id: BigInt.from(42),
parent: BigInt.from(7),
name: '[cSpacerabc]Spacer Heading',
order: 99,
hasPassword: false,
neededTalkPower: 12,
);
final normalChannel = rust.BridgeChannel(
id: BigInt.from(43),
parent: BigInt.zero,
name: 'Normal Room',
order: 100,
hasPassword: false,
neededTalkPower: 0,
);
rust.BridgeChannel? tapped;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SnapshotView(
snapshot: rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: [spacerChannel, normalChannel],
clients: const [],
ownClientId: BigInt.one,
),
audioStats: null,
currentVoiceChannelId: null,
pendingVoiceChannelId: null,
localInputMuted: false,
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
onJoinChannel: (channel) => tapped = channel,
onJoinChannelWithPassword: (_) {},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Spacer Heading'), findsOneWidget);
expect(find.text('[cSpacerabc]Spacer Heading'), findsNothing);
expect(find.byIcon(Icons.tag), findsOneWidget);
final spacerText = tester.widget<Text>(find.text('Spacer Heading'));
expect(spacerText.textAlign, TextAlign.center);
await tester.tap(find.text('Spacer Heading'));
await tester.pump();
expect(tapped, isNotNull);
expect(tapped!.id, BigInt.from(42));
expect(tapped!.parent, BigInt.from(7));
expect(tapped!.name, '[cSpacerabc]Spacer Heading');
expect(tapped!.order, 99);
expect(tapped!.hasPassword, isFalse);
expect(tapped!.neededTalkPower, 12);
});
testWidgets('separator spacers render as line painters without raw text', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SnapshotView(
snapshot: rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: [
rust.BridgeChannel(
id: BigInt.from(1),
parent: BigInt.zero,
name: '[spacer]---',
order: 1,
hasPassword: false,
neededTalkPower: 0,
),
],
clients: const [],
ownClientId: BigInt.one,
),
audioStats: null,
currentVoiceChannelId: null,
pendingVoiceChannelId: null,
localInputMuted: false,
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('[spacer]---'), findsNothing);
expect(find.text('---'), findsNothing);
expect(find.byType(CustomPaint), findsWidgets);
expect(find.byIcon(Icons.tag), findsNothing);
});
testWidgets('repeating and blank spacers hide raw tags', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SnapshotView(
snapshot: rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: [
rust.BridgeChannel(
id: BigInt.from(1),
parent: BigInt.zero,
name: '[*spacer0]#==',
order: 1,
hasPassword: false,
neededTalkPower: 0,
),
rust.BridgeChannel(
id: BigInt.from(2),
parent: BigInt.zero,
name: '[rSpacer0].',
order: 2,
hasPassword: false,
neededTalkPower: 0,
),
],
clients: const [],
ownClientId: BigInt.one,
),
audioStats: null,
currentVoiceChannelId: null,
pendingVoiceChannelId: null,
localInputMuted: false,
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('[*spacer0]#=='), findsNothing);
expect(find.text('[rSpacer0].'), findsNothing);
expect(find.text('.'), findsNothing);
expect(find.textContaining('#==#=='), findsOneWidget);
expect(find.byIcon(Icons.tag), findsNothing);
});
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/widgets/talk_power_warning.dart';
void main() {
test('talk power policy only blocks insufficient ungranted clients', () {
expect(
isTalkPowerBlocked(
talkPower: 5,
neededTalkPower: 10,
talkPowerGranted: false,
),
isTrue,
);
expect(
isTalkPowerBlocked(
talkPower: 10,
neededTalkPower: 10,
talkPowerGranted: false,
),
isFalse,
);
expect(
isTalkPowerBlocked(
talkPower: 5,
neededTalkPower: 10,
talkPowerGranted: true,
),
isFalse,
);
expect(
isTalkPowerBlocked(
talkPower: null,
neededTalkPower: 10,
talkPowerGranted: false,
),
isFalse,
);
});
testWidgets('talk power warning hides when not blocked', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: TalkPowerWarning(
talkPower: 10,
neededTalkPower: 10,
talkPowerGranted: false,
),
),
);
expect(find.textContaining('Insufficient talk power'), findsNothing);
expect(find.byType(SizedBox), findsOneWidget);
});
testWidgets('talk power warning renders blocked values', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: TalkPowerWarning(
talkPower: 5,
neededTalkPower: 10,
talkPowerGranted: false,
),
),
),
);
expect(find.text('Insufficient talk power (5 < 10)'), findsOneWidget);
expect(find.byIcon(Icons.warning_amber), findsOneWidget);
});
}
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/voice_settings_controls.dart';
void main() {
test('shared transmit mode segments expose all modes', () {
expect(transmitModeSegments.map((s) => s.value), [
rust.BridgeTransmitMode.ptt,
rust.BridgeTransmitMode.continuous,
rust.BridgeTransmitMode.voiceActivity,
]);
});
test('shared Android processing segments expose hardware and WebRTC', () {
expect(androidProcessingSegments.map((s) => s.value), [true, false]);
});
test('shared iOS processing segments expose VPIO and Sonora', () {
expect(iosProcessingSegments.map((s) => s.value), [
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
]);
});
test('shared VAD segments expose supported non-disabled backends', () {
expect(vadBackendSegments.map((s) => s.value), [
rust.BridgeVadBackend.webrtcVad,
rust.BridgeVadBackend.sileroOnnx,
rust.BridgeVadBackend.tenVad,
]);
});
testWidgets('shared segmented style applies compact visual density', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
final style = voiceSegmentedButtonStyle(Theme.of(context));
return SegmentedButton<bool>(
style: style,
segments: androidProcessingSegments,
selected: const {true},
);
},
),
),
);
expect(find.byType(SegmentedButton<bool>), findsOneWidget);
});
testWidgets('audio processing toggle row renders dense layout', (
tester,
) async {
var value = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) {
return AudioProcessingToggleRow(
dense: true,
label: 'Noise suppression',
subtitle: 'Wiener filter',
value: value,
onChanged: (next) => setState(() => value = next),
);
},
),
),
),
);
await tester.tap(find.byType(Switch));
await tester.pump();
expect(value, isTrue);
expect(find.text('Noise suppression'), findsOneWidget);
expect(find.text('Wiener filter'), findsOneWidget);
});
}
@@ -0,0 +1,106 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations_en.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/voice_status_summary.dart';
void main() {
final l10n = AppL10nEn();
test('voice mode labels follow localization', () {
expect(
voiceModeLabel(l10n, rust.BridgeTransmitMode.ptt),
l10n.voiceModePtt,
);
expect(
voiceModeLabel(l10n, rust.BridgeTransmitMode.continuous),
l10n.voiceModeContinuous,
);
expect(
voiceModeLabel(l10n, rust.BridgeTransmitMode.voiceActivity),
l10n.voiceModeVoiceActivity,
);
});
test('PTT summary shows touch hold hint and release tail', () {
final summary = voiceStatusSummary(
l10n: l10n,
transmitMode: rust.BridgeTransmitMode.ptt,
releaseTailMs: 200,
pttBoundKeyLabel: '',
isTouchOnly: true,
inputMuted: false,
outputMuted: false,
pttActive: false,
);
expect(summary.line1, '${l10n.voiceModePtt} · ${l10n.voicePttHoldHint}');
expect(summary.line2, '200${l10n.voiceReleaseTailHint} release tail · off');
expect(summary.statusText, l10n.voiceMicOff);
expect(summary.micOn, isFalse);
});
test('PTT summary shows bound key on hardware hosts', () {
final summary = voiceStatusSummary(
l10n: l10n,
transmitMode: rust.BridgeTransmitMode.ptt,
releaseTailMs: 120,
pttBoundKeyLabel: 'Space',
isTouchOnly: false,
inputMuted: false,
outputMuted: false,
pttActive: true,
);
expect(summary.line1, '${l10n.voiceModePtt} · Space');
expect(summary.micOn, isTrue);
expect(summary.statusText, l10n.voiceMicOn);
});
test('continuous mode is active unless muted or talk power blocked', () {
final active = voiceStatusSummary(
l10n: l10n,
transmitMode: rust.BridgeTransmitMode.continuous,
releaseTailMs: 0,
pttBoundKeyLabel: '',
isTouchOnly: false,
inputMuted: false,
outputMuted: false,
pttActive: false,
);
final muted = voiceStatusSummary(
l10n: l10n,
transmitMode: rust.BridgeTransmitMode.continuous,
releaseTailMs: 0,
pttBoundKeyLabel: '',
isTouchOnly: false,
inputMuted: true,
outputMuted: false,
pttActive: false,
);
expect(active.micOn, isTrue);
expect(muted.micOn, isFalse);
expect(muted.statusText, '${l10n.voiceMicOff} (muted)');
});
test('talk power block overrides active mic state', () {
final summary = voiceStatusSummary(
l10n: l10n,
transmitMode: rust.BridgeTransmitMode.continuous,
releaseTailMs: 0,
pttBoundKeyLabel: '',
isTouchOnly: false,
inputMuted: false,
outputMuted: false,
pttActive: true,
talkPower: 5,
neededTalkPower: 10,
talkPowerGranted: false,
);
expect(summary.talkPowerBlocked, isTrue);
expect(summary.micOn, isFalse);
expect(summary.statusText, 'Insufficient permission');
});
}