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,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);