feat: integrate chat voice and diagnostics client
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/android_audio_output_devices.dart';
|
||||
|
||||
void main() {
|
||||
test('parses Android audio output devices and skips non-maps', () {
|
||||
final devices = parseAndroidAudioOutputDevices([
|
||||
{
|
||||
'id': 12,
|
||||
'name': 'Speaker',
|
||||
'type': 'speaker',
|
||||
'isSelected': true,
|
||||
'isAvailableForCommunication': true,
|
||||
},
|
||||
'bad',
|
||||
{'id': null, 'name': null},
|
||||
]);
|
||||
|
||||
expect(devices, hasLength(2));
|
||||
expect(devices.first.id, '12');
|
||||
expect(devices.first.name, 'Speaker');
|
||||
expect(devices.first.type, 'speaker');
|
||||
expect(devices.first.isSelected, isTrue);
|
||||
expect(devices.first.isAvailableForCommunication, isTrue);
|
||||
expect(devices.last.id, isEmpty);
|
||||
expect(devices.last.name, isEmpty);
|
||||
expect(devices.last.type, 'unknown');
|
||||
});
|
||||
|
||||
test('finds selected Android audio output device', () {
|
||||
final selected = selectedAndroidAudioOutputDevice([
|
||||
const AndroidAudioOutputDevice(
|
||||
id: '1',
|
||||
name: 'Speaker',
|
||||
type: 'speaker',
|
||||
isSelected: false,
|
||||
isAvailableForCommunication: true,
|
||||
),
|
||||
const AndroidAudioOutputDevice(
|
||||
id: '2',
|
||||
name: 'Headset',
|
||||
type: 'wiredHeadset',
|
||||
isSelected: true,
|
||||
isAvailableForCommunication: true,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(selected?.id, '2');
|
||||
expect(selectedAndroidAudioOutputDevice(const []), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/app_bootstrap.dart';
|
||||
|
||||
void main() {
|
||||
test('app version appends platform build number', () {
|
||||
expect(
|
||||
appVersionFromBuildNumber(
|
||||
semverBaseline: 'v1.2.3-rc.4',
|
||||
buildNumber: '56',
|
||||
),
|
||||
'v1.2.3-rc.4+56',
|
||||
);
|
||||
});
|
||||
|
||||
test('app version keeps baseline when build number is empty', () {
|
||||
expect(
|
||||
appVersionFromBuildNumber(semverBaseline: 'v1.2.3-rc.4', buildNumber: ''),
|
||||
'v1.2.3-rc.4',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/audio_lifecycle_service.dart';
|
||||
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||
|
||||
void main() {
|
||||
test('parseBridgeAudioRoute maps platform route names', () {
|
||||
expect(parseBridgeAudioRoute('Earpiece'), rust.BridgeAudioRoute.earpiece);
|
||||
expect(parseBridgeAudioRoute('Speaker'), rust.BridgeAudioRoute.speaker);
|
||||
expect(
|
||||
parseBridgeAudioRoute('WiredHeadset'),
|
||||
rust.BridgeAudioRoute.wiredHeadset,
|
||||
);
|
||||
expect(
|
||||
parseBridgeAudioRoute('BluetoothHfp'),
|
||||
rust.BridgeAudioRoute.bluetoothHfp,
|
||||
);
|
||||
expect(
|
||||
parseBridgeAudioRoute('BluetoothA2dp'),
|
||||
rust.BridgeAudioRoute.bluetoothA2Dp,
|
||||
);
|
||||
expect(parseBridgeAudioRoute('Unknown'), rust.BridgeAudioRoute.unknown);
|
||||
expect(parseBridgeAudioRoute('Other'), rust.BridgeAudioRoute.unknown);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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/services/channel_join_error_mapper.dart';
|
||||
import 'package:chanora_flutter/src/rust/lib.dart' as rust_err;
|
||||
|
||||
void main() {
|
||||
Future<AppL10n> loadEnglishL10n(WidgetTester tester) async {
|
||||
late AppL10n l10n;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
l10n = AppL10n.of(context);
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
return l10n;
|
||||
}
|
||||
|
||||
testWidgets('maps known TS3 server rejection codes', (tester) async {
|
||||
final l10n = await loadEnglishL10n(tester);
|
||||
|
||||
expect(
|
||||
channelJoinErrorMessage(
|
||||
l10n,
|
||||
const rust_err.BridgeError.serverRejected(
|
||||
code: 0x030d,
|
||||
message: 'invalid password',
|
||||
),
|
||||
),
|
||||
l10n.channelJoinFailedPassword,
|
||||
);
|
||||
expect(
|
||||
channelJoinErrorMessage(
|
||||
l10n,
|
||||
const rust_err.BridgeError.serverRejected(
|
||||
code: 0x0a08,
|
||||
message: 'insufficient permissions',
|
||||
),
|
||||
),
|
||||
l10n.channelJoinFailedPermission,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('falls back for generic and unknown errors', (tester) async {
|
||||
final l10n = await loadEnglishL10n(tester);
|
||||
|
||||
expect(
|
||||
channelJoinErrorMessage(
|
||||
l10n,
|
||||
const rust_err.BridgeError.serverRejected(
|
||||
code: 0xffff,
|
||||
message: 'custom server error',
|
||||
),
|
||||
),
|
||||
l10n.channelJoinFailedGeneric('custom server error'),
|
||||
);
|
||||
expect(
|
||||
channelJoinErrorMessage(l10n, 'plain error'),
|
||||
l10n.channelJoinFailedGeneric('plain error'),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/channel_spacer.dart';
|
||||
|
||||
void main() {
|
||||
test('classifies only valid bracketed spacer tags as spacer channels', () {
|
||||
expect(isSpacerChannelName('[Spacer0]Lobby'), isTrue);
|
||||
expect(isSpacerChannelName('[cSpacer]Lobby'), isTrue);
|
||||
expect(isSpacerChannelName('[*spacer0]#=='), isTrue);
|
||||
expect(isSpacerChannelName('Lobby spacer room'), isFalse);
|
||||
expect(isSpacerChannelName('prefix [Spacer0]Lobby'), isFalse);
|
||||
expect(isSpacerChannelName('[xSpacer0]Lobby'), isFalse);
|
||||
});
|
||||
|
||||
test('parses valid spacer names without requiring numeric suffixes', () {
|
||||
final parsed = parseSpacerChannelName('[cSpAcErabc-01] Lobby ');
|
||||
|
||||
expect(parsed.isSpacer, isTrue);
|
||||
expect(parsed.isValid, isTrue);
|
||||
expect(parsed.alignment, SpacerAlignment.center);
|
||||
expect(parsed.isRepeating, isFalse);
|
||||
expect(parsed.uniqueSuffix, 'abc-01');
|
||||
expect(parsed.text, ' Lobby ');
|
||||
expect(parsed.specialType, isNull);
|
||||
expect(parsed.isBlankSpacer, isFalse);
|
||||
expect(parsed.reason, isNull);
|
||||
});
|
||||
|
||||
test('parses all special separator line values', () {
|
||||
expect(
|
||||
parseSpacerChannelName('[Spacer0]___').specialType,
|
||||
SpacerSpecialType.solidLine,
|
||||
);
|
||||
expect(
|
||||
parseSpacerChannelName('[Spacer0]---').specialType,
|
||||
SpacerSpecialType.dashLine,
|
||||
);
|
||||
expect(
|
||||
parseSpacerChannelName('[Spacer0]...').specialType,
|
||||
SpacerSpecialType.dotLine,
|
||||
);
|
||||
expect(
|
||||
parseSpacerChannelName('[Spacer0]-.-').specialType,
|
||||
SpacerSpecialType.dashDotLine,
|
||||
);
|
||||
expect(
|
||||
parseSpacerChannelName('[Spacer0]-..').specialType,
|
||||
SpacerSpecialType.dashDotDotLine,
|
||||
);
|
||||
});
|
||||
|
||||
test('parses repeating spacer text exactly', () {
|
||||
final parsed = parseSpacerChannelName('[*spacer0]#==');
|
||||
|
||||
expect(parsed.isSpacer, isTrue);
|
||||
expect(parsed.isValid, isTrue);
|
||||
expect(parsed.isRepeating, isTrue);
|
||||
expect(parsed.uniqueSuffix, '0');
|
||||
expect(parsed.text, '#==');
|
||||
expect(channelSpacerLabel('[*spacer0]#=='), startsWith('#==#=='));
|
||||
});
|
||||
|
||||
test('parses known blank-looking right spacer', () {
|
||||
final parsed = parseSpacerChannelName('[rSpacer0].');
|
||||
|
||||
expect(parsed.isSpacer, isTrue);
|
||||
expect(parsed.alignment, SpacerAlignment.right);
|
||||
expect(parsed.text, '.');
|
||||
expect(parsed.isBlankSpacer, isTrue);
|
||||
expect(channelSpacerLabel('[rSpacer0].'), isEmpty);
|
||||
});
|
||||
|
||||
test('reports malformed spacer-like names', () {
|
||||
final missingBracket = parseSpacerChannelName('[cSpacer0');
|
||||
expect(missingBracket.isSpacer, isFalse);
|
||||
expect(missingBracket.isValid, isFalse);
|
||||
expect(missingBracket.reason, 'missing closing bracket');
|
||||
|
||||
final invalidFlag = parseSpacerChannelName('[xSpacer0]Lobby');
|
||||
expect(invalidFlag.isSpacer, isFalse);
|
||||
expect(invalidFlag.isValid, isFalse);
|
||||
expect(invalidFlag.reason, 'invalid spacer tag');
|
||||
|
||||
final duplicateAlignment = parseSpacerChannelName('[lcSpacer0]Lobby');
|
||||
expect(duplicateAlignment.isSpacer, isFalse);
|
||||
expect(duplicateAlignment.isValid, isFalse);
|
||||
expect(duplicateAlignment.reason, 'multiple alignment flags');
|
||||
});
|
||||
|
||||
test('reports ordinary channel names as non-spacers', () {
|
||||
final parsed = parseSpacerChannelName('A channel with spacer in its name');
|
||||
|
||||
expect(parsed.isSpacer, isFalse);
|
||||
expect(parsed.isValid, isFalse);
|
||||
expect(parsed.reason, 'not a spacer channel name');
|
||||
expect(
|
||||
channelSpacerLabel('A channel with spacer in its name'),
|
||||
'A channel with spacer in its name',
|
||||
);
|
||||
});
|
||||
|
||||
test('formats spacer names deterministically', () {
|
||||
final formatted = formatSpacerChannelName(
|
||||
const SpacerChannelNameFormatOptions(
|
||||
alignment: SpacerAlignment.right,
|
||||
isRepeating: true,
|
||||
uniqueSuffix: 'abc',
|
||||
text: '#==',
|
||||
),
|
||||
);
|
||||
|
||||
expect(formatted, '[*rSpacerabc]#==');
|
||||
|
||||
final parsed = parseSpacerChannelName(formatted);
|
||||
expect(parsed.isSpacer, isTrue);
|
||||
expect(parsed.alignment, SpacerAlignment.right);
|
||||
expect(parsed.isRepeating, isTrue);
|
||||
expect(parsed.uniqueSuffix, 'abc');
|
||||
expect(parsed.text, '#==');
|
||||
});
|
||||
|
||||
test('formats existing display labels through the utility', () {
|
||||
expect(channelSpacerLabel('[cSpacer]Lobby'), 'Lobby');
|
||||
expect(channelSpacerLabel('[Spacer0]___'), '────────');
|
||||
expect(channelSpacerLabel('Lobby'), 'Lobby');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/l10n/generated/app_localizations_en.dart';
|
||||
import 'package:chanora_flutter/services/connection_phase_state.dart';
|
||||
|
||||
void main() {
|
||||
final l10n = AppL10nEn();
|
||||
|
||||
test('connection phases expose shared predicates', () {
|
||||
expect(ConnectionPhase.idle.isServerReachable, isFalse);
|
||||
expect(ConnectionPhase.connecting.isServerReachable, isFalse);
|
||||
expect(ConnectionPhase.synchronizing.isServerReachable, isTrue);
|
||||
expect(ConnectionPhase.connected.isServerReachable, isTrue);
|
||||
expect(ConnectionPhase.reconnecting.isServerReachable, isTrue);
|
||||
expect(ConnectionPhase.disconnected.isServerReachable, isFalse);
|
||||
|
||||
expect(ConnectionPhase.synchronizing.canOpenChat, isTrue);
|
||||
expect(ConnectionPhase.connected.canOpenChat, isTrue);
|
||||
expect(ConnectionPhase.reconnecting.canOpenChat, isFalse);
|
||||
expect(ConnectionPhase.connected.canDisconnect, isTrue);
|
||||
});
|
||||
|
||||
test('connection status text maps all phases', () {
|
||||
expect(
|
||||
connectionStatusText(phase: ConnectionPhase.idle, l10n: l10n),
|
||||
l10n.statusIdle,
|
||||
);
|
||||
expect(
|
||||
connectionStatusText(phase: ConnectionPhase.connecting, l10n: l10n),
|
||||
l10n.statusConnecting,
|
||||
);
|
||||
expect(
|
||||
connectionStatusText(phase: ConnectionPhase.synchronizing, l10n: l10n),
|
||||
'Synchronizing...',
|
||||
);
|
||||
expect(
|
||||
connectionStatusText(
|
||||
phase: ConnectionPhase.connected,
|
||||
l10n: l10n,
|
||||
serverName: 'Server',
|
||||
),
|
||||
'Connected to Server',
|
||||
);
|
||||
expect(
|
||||
connectionStatusText(
|
||||
phase: ConnectionPhase.reconnecting,
|
||||
l10n: l10n,
|
||||
reconnectAttempt: 2,
|
||||
reconnectDelay: 5,
|
||||
),
|
||||
l10n.statusReconnecting(2, 5),
|
||||
);
|
||||
expect(
|
||||
connectionStatusText(
|
||||
phase: ConnectionPhase.reconnecting,
|
||||
l10n: l10n,
|
||||
lostReason: 'network',
|
||||
),
|
||||
'Connection lost: network',
|
||||
);
|
||||
expect(
|
||||
connectionStatusText(phase: ConnectionPhase.disconnected, l10n: l10n),
|
||||
l10n.statusIdle,
|
||||
);
|
||||
});
|
||||
|
||||
test('connection phases map to token icons', () {
|
||||
final scheme = ColorScheme.fromSeed(seedColor: Colors.indigo);
|
||||
|
||||
expect(ConnectionPhase.idle.tokens(scheme).icon, Icons.cloud_off);
|
||||
expect(ConnectionPhase.connecting.tokens(scheme).icon, Icons.sync);
|
||||
expect(
|
||||
ConnectionPhase.synchronizing.tokens(scheme).icon,
|
||||
Icons.hourglass_top,
|
||||
);
|
||||
expect(ConnectionPhase.connected.tokens(scheme).icon, Icons.cloud_done);
|
||||
expect(ConnectionPhase.reconnecting.tokens(scheme).icon, Icons.restart_alt);
|
||||
expect(ConnectionPhase.disconnected.tokens(scheme).icon, Icons.cloud_off);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/snapshot_state_mapper.dart';
|
||||
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||
|
||||
void main() {
|
||||
rust.BridgeChannel channel({int neededTalkPower = 0}) {
|
||||
return rust.BridgeChannel(
|
||||
id: BigInt.one,
|
||||
parent: BigInt.zero,
|
||||
name: 'Lobby',
|
||||
order: 0,
|
||||
hasPassword: false,
|
||||
neededTalkPower: neededTalkPower,
|
||||
);
|
||||
}
|
||||
|
||||
rust.BridgeClient client({
|
||||
BigInt? id,
|
||||
BigInt? channelId,
|
||||
int talkPower = 0,
|
||||
bool talkPowerGranted = false,
|
||||
bool inputMuted = false,
|
||||
bool outputMuted = false,
|
||||
}) {
|
||||
return rust.BridgeClient(
|
||||
id: id ?? BigInt.from(7),
|
||||
channel: channelId ?? BigInt.one,
|
||||
name: 'Me',
|
||||
inputMuted: inputMuted,
|
||||
outputMuted: outputMuted,
|
||||
isSpeaking: false,
|
||||
isServerQuery: false,
|
||||
talkPower: talkPower,
|
||||
talkPowerGranted: talkPowerGranted,
|
||||
);
|
||||
}
|
||||
|
||||
rust.BridgeSnapshot snapshot({
|
||||
required List<rust.BridgeChannel> channels,
|
||||
required List<rust.BridgeClient> clients,
|
||||
BigInt? ownClientId,
|
||||
}) {
|
||||
return rust.BridgeSnapshot(
|
||||
serverName: 'Server',
|
||||
welcomeMessage: '',
|
||||
platform: '',
|
||||
version: '',
|
||||
channels: channels,
|
||||
clients: clients,
|
||||
ownClientId: ownClientId ?? BigInt.from(7),
|
||||
);
|
||||
}
|
||||
|
||||
test('extracts own channel and mute state', () {
|
||||
final state = ownClientSnapshotState(
|
||||
snapshot(
|
||||
channels: [channel()],
|
||||
clients: [client(inputMuted: true, outputMuted: true)],
|
||||
),
|
||||
);
|
||||
|
||||
expect(state?.channelId, BigInt.one);
|
||||
expect(state?.inputMuted, isTrue);
|
||||
expect(state?.outputMuted, isTrue);
|
||||
expect(state?.talkPowerOk, isTrue);
|
||||
expect(state?.talkPower, 0);
|
||||
expect(state?.talkPowerGranted, isFalse);
|
||||
expect(state?.neededTalkPower, 0);
|
||||
});
|
||||
|
||||
test('detects insufficient talk power', () {
|
||||
final state = ownClientSnapshotState(
|
||||
snapshot(
|
||||
channels: [channel(neededTalkPower: 20)],
|
||||
clients: [client(talkPower: 10)],
|
||||
),
|
||||
);
|
||||
|
||||
expect(state?.talkPowerOk, isFalse);
|
||||
expect(state?.talkPower, 10);
|
||||
expect(state?.neededTalkPower, 20);
|
||||
});
|
||||
|
||||
test('accepts granted or sufficient talk power', () {
|
||||
final sufficient = ownClientSnapshotState(
|
||||
snapshot(
|
||||
channels: [channel(neededTalkPower: 20)],
|
||||
clients: [client(talkPower: 20)],
|
||||
),
|
||||
);
|
||||
final granted = ownClientSnapshotState(
|
||||
snapshot(
|
||||
channels: [channel(neededTalkPower: 20)],
|
||||
clients: [client(talkPowerGranted: true)],
|
||||
),
|
||||
);
|
||||
|
||||
expect(sufficient?.talkPowerOk, isTrue);
|
||||
expect(granted?.talkPowerOk, isTrue);
|
||||
});
|
||||
|
||||
test('returns null when own client is absent', () {
|
||||
final state = ownClientSnapshotState(
|
||||
snapshot(
|
||||
channels: [channel()],
|
||||
clients: [client(id: BigInt.from(9))],
|
||||
),
|
||||
);
|
||||
|
||||
expect(state, isNull);
|
||||
});
|
||||
|
||||
test('resolves channel name by id', () {
|
||||
final snap = snapshot(
|
||||
channels: [
|
||||
channel(),
|
||||
rust.BridgeChannel(
|
||||
id: BigInt.from(2),
|
||||
parent: BigInt.zero,
|
||||
name: 'Raid Room',
|
||||
order: 1,
|
||||
hasPassword: false,
|
||||
neededTalkPower: 0,
|
||||
),
|
||||
],
|
||||
clients: [client()],
|
||||
);
|
||||
|
||||
expect(snapshotChannelName(snap, BigInt.from(2)), 'Raid Room');
|
||||
});
|
||||
|
||||
test('returns empty channel name when snapshot or channel is absent', () {
|
||||
final snap = snapshot(channels: [channel()], clients: [client()]);
|
||||
|
||||
expect(snapshotChannelName(null, BigInt.one), isEmpty);
|
||||
expect(snapshotChannelName(snap, null), isEmpty);
|
||||
expect(snapshotChannelName(snap, BigInt.from(99)), isEmpty);
|
||||
});
|
||||
|
||||
test('resolves needed talk power by channel id', () {
|
||||
final snap = snapshot(
|
||||
channels: [channel(neededTalkPower: 30)],
|
||||
clients: [client()],
|
||||
);
|
||||
|
||||
expect(snapshotNeededTalkPower(snap, BigInt.one), 30);
|
||||
expect(snapshotNeededTalkPower(null, BigInt.one), isNull);
|
||||
expect(snapshotNeededTalkPower(snap, null), isNull);
|
||||
expect(snapshotNeededTalkPower(snap, BigInt.from(99)), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/ts3_server_link.dart';
|
||||
|
||||
void main() {
|
||||
test('parses encoded add-bookmark TeamSpeak link', () {
|
||||
final link = parseTs3ServerLink(
|
||||
'ts3server://teamspeak.app%3Faddbookmark%3DVigorous%20Pro/',
|
||||
);
|
||||
|
||||
expect(link, isNotNull);
|
||||
expect(link!.host, 'teamspeak.app');
|
||||
expect(link.hostWithPort, 'teamspeak.app');
|
||||
expect(link.addBookmark, 'Vigorous Pro');
|
||||
});
|
||||
|
||||
test('parses full TeamSpeak server link parameters', () {
|
||||
final link = parseTs3ServerLink(
|
||||
'ts3server://ts3.hoster.com?port=9987&nickname=UserNickname'
|
||||
'&password=serverPassword&channel=MyDefaultChannel&cid=123'
|
||||
'&channelpassword=defaultChannelPassword&token=TokenKey'
|
||||
'&addbookmark=MyBookMarkLabel',
|
||||
);
|
||||
|
||||
expect(link, isNotNull);
|
||||
expect(link!.host, 'ts3.hoster.com');
|
||||
expect(link.hostWithPort, 'ts3.hoster.com:9987');
|
||||
expect(link.port, 9987);
|
||||
expect(link.nickname, 'UserNickname');
|
||||
expect(link.password, 'serverPassword');
|
||||
expect(link.channel, 'MyDefaultChannel');
|
||||
expect(link.cid, '123');
|
||||
expect(link.channelPassword, 'defaultChannelPassword');
|
||||
expect(link.token, 'TokenKey');
|
||||
expect(link.addBookmark, 'MyBookMarkLabel');
|
||||
});
|
||||
|
||||
test('keeps explicit host port without duplicating port query', () {
|
||||
final link = parseTs3ServerLink('ts3server://ts3.hoster.com:9987');
|
||||
|
||||
expect(link, isNotNull);
|
||||
expect(link!.hostWithPort, 'ts3.hoster.com:9987');
|
||||
});
|
||||
}
|
||||
@@ -16,7 +16,6 @@ void main() {
|
||||
|
||||
expect(settings.host, isEmpty);
|
||||
expect(settings.nickname, isEmpty);
|
||||
expect(settings.showPokeDialogs, isTrue);
|
||||
});
|
||||
|
||||
test('saves and loads host and nickname independently', () async {
|
||||
@@ -33,21 +32,6 @@ void main() {
|
||||
expect(settings.nickname, 'Chanora');
|
||||
});
|
||||
|
||||
test('saves poke dialog preference independently', () async {
|
||||
await service.saveSettings(showPokeDialogs: false);
|
||||
var settings = await service.loadSettings();
|
||||
|
||||
expect(settings.showPokeDialogs, isFalse);
|
||||
expect(settings.host, isEmpty);
|
||||
expect(settings.nickname, isEmpty);
|
||||
|
||||
await service.saveSettings(host: 'example.com');
|
||||
settings = await service.loadSettings();
|
||||
|
||||
expect(settings.showPokeDialogs, isFalse);
|
||||
expect(settings.host, 'example.com');
|
||||
});
|
||||
|
||||
test('tracks permission explanation flag', () async {
|
||||
expect(await service.hasExplainedPermissions(), isFalse);
|
||||
|
||||
|
||||
@@ -190,6 +190,34 @@ void main() {
|
||||
expect(states, [true, false]);
|
||||
});
|
||||
|
||||
testWidgets('on-screen PTT can release from pan gestures', (tester) async {
|
||||
final states = <bool>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: VoicePttButton(
|
||||
active: false,
|
||||
listenForPan: true,
|
||||
onHeldChanged: states.add,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final center = tester.getCenter(find.byType(VoicePttButton));
|
||||
final gesture = await tester.startGesture(center);
|
||||
await tester.pump();
|
||||
await gesture.moveBy(const Offset(0, 24));
|
||||
await tester.pump();
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
|
||||
expect(states, [true, false]);
|
||||
});
|
||||
|
||||
testWidgets('iOS permission service maps channel states and settings', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user