chore: restore product scaffold to rollback baseline
This commit is contained in:
@@ -21,6 +21,69 @@ void main() {
|
||||
expect(ConnectionPhase.connected.canDisconnect, isTrue);
|
||||
});
|
||||
|
||||
test('server-reachable phases show loading until snapshot is available', () {
|
||||
expect(
|
||||
ConnectionPhase.connecting.shouldShowSnapshotLoading(hasSnapshot: false),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
ConnectionPhase.synchronizing.shouldShowSnapshotLoading(
|
||||
hasSnapshot: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ConnectionPhase.connected.shouldShowSnapshotLoading(hasSnapshot: false),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ConnectionPhase.reconnecting.shouldShowSnapshotLoading(
|
||||
hasSnapshot: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ConnectionPhase.synchronizing.shouldShowSnapshotLoading(
|
||||
hasSnapshot: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('chat only opens after a snapshot is available', () {
|
||||
expect(
|
||||
ConnectionPhase.synchronizing.canOpenChatWithSnapshot(hasSnapshot: false),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
ConnectionPhase.synchronizing.canOpenChatWithSnapshot(hasSnapshot: true),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ConnectionPhase.connected.canOpenChatWithSnapshot(hasSnapshot: true),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ConnectionPhase.reconnecting.canOpenChatWithSnapshot(hasSnapshot: true),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('snapshot application completes synchronizing phase', () {
|
||||
expect(
|
||||
phaseAfterSnapshotApplied(ConnectionPhase.synchronizing),
|
||||
ConnectionPhase.connected,
|
||||
);
|
||||
expect(
|
||||
phaseAfterSnapshotApplied(ConnectionPhase.connected),
|
||||
ConnectionPhase.connected,
|
||||
);
|
||||
expect(
|
||||
phaseAfterSnapshotApplied(ConnectionPhase.reconnecting),
|
||||
ConnectionPhase.reconnecting,
|
||||
);
|
||||
});
|
||||
|
||||
test('connection status text maps all phases', () {
|
||||
expect(
|
||||
connectionStatusText(phase: ConnectionPhase.idle, l10n: l10n),
|
||||
@@ -78,4 +141,22 @@ void main() {
|
||||
expect(ConnectionPhase.reconnecting.tokens(scheme).icon, Icons.restart_alt);
|
||||
expect(ConnectionPhase.disconnected.tokens(scheme).icon, Icons.cloud_off);
|
||||
});
|
||||
|
||||
test('connected event preserves connected phase when snapshot exists', () {
|
||||
expect(
|
||||
phaseAfterConnectedEvent(ConnectionPhase.connected, hasSnapshot: true),
|
||||
ConnectionPhase.connected,
|
||||
);
|
||||
});
|
||||
|
||||
test('connected event synchronizes when snapshot is missing', () {
|
||||
expect(
|
||||
phaseAfterConnectedEvent(ConnectionPhase.connected, hasSnapshot: false),
|
||||
ConnectionPhase.synchronizing,
|
||||
);
|
||||
expect(
|
||||
phaseAfterConnectedEvent(ConnectionPhase.reconnecting, hasSnapshot: true),
|
||||
ConnectionPhase.synchronizing,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/prefetch_debouncer.dart';
|
||||
|
||||
void main() {
|
||||
test('debounces host edits and prefetches latest trimmed host', () async {
|
||||
final calls = <String>[];
|
||||
final debouncer = PrefetchDebouncer(
|
||||
delay: const Duration(milliseconds: 20),
|
||||
onPrefetch: (host) async => calls.add(host),
|
||||
);
|
||||
|
||||
debouncer.schedule(' first.example.com ');
|
||||
debouncer.schedule(' second.example.com ');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 35));
|
||||
|
||||
expect(calls, ['second.example.com']);
|
||||
debouncer.dispose();
|
||||
});
|
||||
|
||||
test('skips empty hosts', () async {
|
||||
final calls = <String>[];
|
||||
final debouncer = PrefetchDebouncer(
|
||||
delay: const Duration(milliseconds: 10),
|
||||
onPrefetch: (host) async => calls.add(host),
|
||||
);
|
||||
|
||||
debouncer.schedule(' ');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 25));
|
||||
|
||||
expect(calls, isEmpty);
|
||||
debouncer.dispose();
|
||||
});
|
||||
|
||||
test('dispose cancels pending prefetch', () async {
|
||||
final calls = <String>[];
|
||||
final debouncer = PrefetchDebouncer(
|
||||
delay: const Duration(milliseconds: 30),
|
||||
onPrefetch: (host) async => calls.add(host),
|
||||
);
|
||||
|
||||
debouncer.schedule('example.com');
|
||||
debouncer.dispose();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 45));
|
||||
|
||||
expect(calls, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -18,7 +18,6 @@ void main() {
|
||||
rust.BridgeClient client({
|
||||
BigInt? id,
|
||||
BigInt? channelId,
|
||||
String uid = '',
|
||||
int talkPower = 0,
|
||||
bool talkPowerGranted = false,
|
||||
bool inputMuted = false,
|
||||
@@ -26,7 +25,6 @@ void main() {
|
||||
}) {
|
||||
return rust.BridgeClient(
|
||||
id: id ?? BigInt.from(7),
|
||||
uid: uid,
|
||||
channel: channelId ?? BigInt.one,
|
||||
name: 'Me',
|
||||
inputMuted: inputMuted,
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/startup_dependency_check.dart';
|
||||
|
||||
void main() {
|
||||
tearDown(() {
|
||||
debugResetStartupDependencyCheck();
|
||||
});
|
||||
|
||||
test('non-linux hosts skip startup dependency issues', () async {
|
||||
debugResetStartupDependencyCheck(platformIsLinux: () => false);
|
||||
|
||||
final result = await checkStartupDependencies();
|
||||
|
||||
expect(result.issues, isEmpty);
|
||||
});
|
||||
|
||||
test('missing Linux audio runtimes are reported as recommended', () async {
|
||||
debugResetStartupDependencyCheck(
|
||||
platformIsLinux: () => true,
|
||||
libraryProbe: (candidate) => false,
|
||||
fileExists: (_) async => false,
|
||||
osReleaseProvider: () async => 'ID=ubuntu\nID_LIKE=debian\n',
|
||||
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
|
||||
currentDirectoryProvider: () => '/tmp',
|
||||
);
|
||||
|
||||
final result = await checkStartupDependencies();
|
||||
|
||||
expect(result.hasIssues, isTrue);
|
||||
final pipewire = result.issues.firstWhere(
|
||||
(issue) => issue.id == 'linux-pipewire-runtime',
|
||||
);
|
||||
final pulse = result.issues.firstWhere(
|
||||
(issue) => issue.id == 'linux-pulseaudio-runtime',
|
||||
);
|
||||
expect(pipewire.isRequired, isFalse);
|
||||
expect(pulse.isRequired, isFalse);
|
||||
expect(
|
||||
pipewire.installHints.single.command,
|
||||
'sudo apt install libpipewire-0.3-0',
|
||||
);
|
||||
expect(pulse.installHints.single.command, 'sudo apt install libpulse0');
|
||||
});
|
||||
|
||||
test('distro detection parses quoted ID_LIKE lists', () async {
|
||||
debugResetStartupDependencyCheck(
|
||||
platformIsLinux: () => true,
|
||||
libraryProbe: (candidate) => false,
|
||||
fileExists: (_) async => true,
|
||||
osReleaseProvider: () async => 'ID=rocky\nID_LIKE="fedora rhel"\n',
|
||||
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
|
||||
currentDirectoryProvider: () => '/tmp',
|
||||
);
|
||||
|
||||
final result = await checkStartupDependencies();
|
||||
|
||||
expect(result.platformLabel, 'Fedora');
|
||||
final pipewire = result.issues.singleWhere(
|
||||
(issue) => issue.id == 'linux-pipewire-runtime',
|
||||
);
|
||||
expect(
|
||||
pipewire.installHints.single.command,
|
||||
'sudo dnf install pipewire-libs',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'missing ONNX runtime is reported as recommended when Linux audio runtimes are present',
|
||||
() async {
|
||||
debugResetStartupDependencyCheck(
|
||||
platformIsLinux: () => true,
|
||||
libraryProbe: (candidate) =>
|
||||
candidate.contains('pipewire') || candidate.contains('pulse'),
|
||||
fileExists: (_) async => false,
|
||||
osReleaseProvider: () async => 'ID=fedora\n',
|
||||
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
|
||||
currentDirectoryProvider: () => '/tmp',
|
||||
currentAbiProvider: () => Abi.linuxX64,
|
||||
);
|
||||
|
||||
final result = await checkStartupDependencies();
|
||||
|
||||
expect(result.issues, hasLength(1));
|
||||
final ort = result.issues.single;
|
||||
expect(ort.id, 'linux-onnxruntime');
|
||||
expect(ort.isRequired, isFalse);
|
||||
expect(
|
||||
ort.installHints.any(
|
||||
(hint) =>
|
||||
hint.command ==
|
||||
'https://github.com/microsoft/onnxruntime/releases',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ort.installHints.any(
|
||||
(hint) => hint.command == 'onnxruntime-linux-x64-<version>.tgz',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ort.installHints.any((hint) => hint.command.contains('ORT_DYLIB_PATH')),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ort.details,
|
||||
contains(
|
||||
'This machine needs the Linux x64 CPU archive (onnxruntime-linux-x64-<version>.tgz).',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('bundle-local ONNX runtime clears the recommendation', () async {
|
||||
debugResetStartupDependencyCheck(
|
||||
platformIsLinux: () => true,
|
||||
libraryProbe: (candidate) =>
|
||||
candidate.contains('pipewire') || candidate.contains('pulse'),
|
||||
fileExists: (path) async => path == '/opt/chanora/lib/libonnxruntime.so',
|
||||
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
|
||||
currentDirectoryProvider: () => '/tmp',
|
||||
);
|
||||
|
||||
final result = await checkStartupDependencies();
|
||||
|
||||
expect(result.issues, isEmpty);
|
||||
});
|
||||
|
||||
test('logging missing startup dependencies appends a log line', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'chanora-startup-log',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
final logFile = File('${tempDir.path}/chanora.log');
|
||||
|
||||
debugResetStartupDependencyCheck(logFilePathProvider: () => logFile.path);
|
||||
|
||||
const result = StartupDependencyCheckResult(
|
||||
platformLabel: 'Fedora',
|
||||
issues: [
|
||||
StartupDependencyIssue(
|
||||
id: 'linux-pipewire-runtime',
|
||||
title: 'PipeWire runtime is missing',
|
||||
summary: 'Primary Linux audio needs PipeWire.',
|
||||
details: ['Install PipeWire and recheck.'],
|
||||
severity: StartupDependencySeverity.recommended,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await logStartupDependencyIssues(result);
|
||||
|
||||
final text = await logFile.readAsString();
|
||||
expect(text, contains('[startup_dependency_check]'));
|
||||
expect(text, contains('platform="Fedora"'));
|
||||
expect(
|
||||
text,
|
||||
contains(
|
||||
'issues=[linux-pipewire-runtime:recommended:PipeWire runtime is missing]',
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,7 @@ void main() {
|
||||
|
||||
expect(settings.host, isEmpty);
|
||||
expect(settings.nickname, isEmpty);
|
||||
expect(settings.themeMode, UiThemeMode.system);
|
||||
});
|
||||
|
||||
test('saves and loads host and nickname independently', () async {
|
||||
@@ -40,78 +41,30 @@ void main() {
|
||||
expect(await service.hasExplainedPermissions(), isTrue);
|
||||
});
|
||||
|
||||
test('stores per-user playback preferences per server host', () async {
|
||||
await service.saveClientPlaybackPreference(
|
||||
serverHost: 'Example.COM',
|
||||
userUid: 'user-a',
|
||||
volume: 0.5,
|
||||
muted: true,
|
||||
);
|
||||
await service.saveClientPlaybackPreference(
|
||||
serverHost: 'other.example',
|
||||
userUid: 'user-a',
|
||||
volume: 2.0,
|
||||
muted: false,
|
||||
);
|
||||
test('saves and loads theme mode independently', () async {
|
||||
await service.saveSettings(host: 'example.com', nickname: 'Chanora');
|
||||
await service.saveThemeMode(UiThemeMode.dark);
|
||||
|
||||
final examplePrefs = await service.loadClientPlaybackPreferencesForServer(
|
||||
'example.com',
|
||||
);
|
||||
final otherPrefs = await service.loadClientPlaybackPreferencesForServer(
|
||||
'other.example',
|
||||
);
|
||||
var settings = await service.loadSettings();
|
||||
|
||||
expect(examplePrefs['user-a']?.volume, 0.5);
|
||||
expect(examplePrefs['user-a']?.muted, isTrue);
|
||||
expect(otherPrefs['user-a']?.volume, 2.0);
|
||||
expect(otherPrefs['user-a']?.muted, isFalse);
|
||||
expect(settings.host, 'example.com');
|
||||
expect(settings.nickname, 'Chanora');
|
||||
expect(settings.themeMode, UiThemeMode.dark);
|
||||
|
||||
await service.saveThemeMode(UiThemeMode.light);
|
||||
settings = await service.loadSettings();
|
||||
|
||||
expect(settings.host, 'example.com');
|
||||
expect(settings.nickname, 'Chanora');
|
||||
expect(settings.themeMode, UiThemeMode.light);
|
||||
});
|
||||
|
||||
test(
|
||||
'drops default playback preferences instead of persisting them',
|
||||
() async {
|
||||
await service.saveClientPlaybackPreference(
|
||||
serverHost: 'example.com',
|
||||
userUid: 'user-a',
|
||||
volume: 0.2,
|
||||
muted: true,
|
||||
);
|
||||
await service.saveClientPlaybackPreference(
|
||||
serverHost: 'example.com',
|
||||
userUid: 'user-a',
|
||||
volume: 1.0,
|
||||
muted: false,
|
||||
);
|
||||
test('falls back to system theme mode for invalid stored values', () async {
|
||||
SharedPreferences.setMockInitialValues({'ui.theme_mode': 'sepia'});
|
||||
service = const UiPreferencesService();
|
||||
|
||||
final prefs = await service.loadClientPlaybackPreferencesForServer(
|
||||
'example.com',
|
||||
);
|
||||
expect(prefs, isEmpty);
|
||||
},
|
||||
);
|
||||
final settings = await service.loadSettings();
|
||||
|
||||
test('ignores corrupt playback preference JSON', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'audio.client_playback_prefs': '{not-json',
|
||||
});
|
||||
|
||||
final prefs = await service.loadClientPlaybackPreferencesForServer(
|
||||
'example.com',
|
||||
);
|
||||
|
||||
expect(prefs, isEmpty);
|
||||
});
|
||||
|
||||
test('clamps invalid playback volumes to unity', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'audio.client_playback_prefs':
|
||||
'{"example.com|user-a":{"volume":"invalid","muted":false}}',
|
||||
});
|
||||
|
||||
final prefs = await service.loadClientPlaybackPreferencesForServer(
|
||||
'example.com',
|
||||
);
|
||||
|
||||
expect(prefs['user-a']?.volume, 1.0);
|
||||
expect(settings.themeMode, UiThemeMode.system);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,12 +18,44 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
// ignore_for_file: deprecated_member_use
|
||||
|
||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||
import 'package:chanora_flutter/main.dart';
|
||||
import 'package:chanora_flutter/services/android_permissions_service.dart';
|
||||
import 'package:chanora_flutter/services/ios_permissions_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:chanora_flutter/widgets/permission_state_banner.dart';
|
||||
import 'package:chanora_flutter/widgets/voice_compact.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('theme menu emits selected mode from user action', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
var selected = ThemeMode.system;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
appBar: AppBar(
|
||||
actions: [
|
||||
ChanoraThemeModeMenu(
|
||||
themeMode: ThemeMode.system,
|
||||
onThemeModeChanged: (mode) async {
|
||||
selected = mode;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.palette_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Dark').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(selected, ThemeMode.dark);
|
||||
});
|
||||
|
||||
testWidgets('renders English banner', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
|
||||
@@ -31,14 +31,14 @@ void main() {
|
||||
expect(state.agcEnabled, isTrue);
|
||||
});
|
||||
|
||||
test('desktop VAD normalization keeps explicit WebRTC selections', () {
|
||||
test('normalizes desktop VAD backend to Silero', () {
|
||||
expect(
|
||||
normalizedVadBackend(
|
||||
rust.BridgeVadBackend.webrtcVad,
|
||||
isWindows: true,
|
||||
isLinux: false,
|
||||
),
|
||||
rust.BridgeVadBackend.webrtcVad,
|
||||
rust.BridgeVadBackend.sileroOnnx,
|
||||
);
|
||||
expect(
|
||||
normalizedVadBackend(
|
||||
@@ -46,33 +46,10 @@ void main() {
|
||||
isWindows: false,
|
||||
isLinux: true,
|
||||
),
|
||||
rust.BridgeVadBackend.webrtcVad,
|
||||
);
|
||||
expect(
|
||||
normalizedVadBackend(
|
||||
rust.BridgeVadBackend.energyDebug,
|
||||
isWindows: true,
|
||||
isLinux: false,
|
||||
),
|
||||
rust.BridgeVadBackend.sileroOnnx,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'desktop VAD normalization falls back when ONNX Runtime is unavailable',
|
||||
() {
|
||||
expect(
|
||||
normalizedVadBackend(
|
||||
rust.BridgeVadBackend.sileroOnnx,
|
||||
isWindows: false,
|
||||
isLinux: true,
|
||||
onnxRuntimeAvailable: false,
|
||||
),
|
||||
rust.BridgeVadBackend.webrtcVad,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('default config matches the current platform fallback', () {
|
||||
final fallback = defaultAudioProcessingConfig();
|
||||
final desktop = Platform.isWindows || Platform.isLinux;
|
||||
@@ -126,7 +103,6 @@ void main() {
|
||||
|
||||
test('builds Windows/Linux software WebRTC APM config consistently', () {
|
||||
final state = AudioProcessingConfigState.fromConfig(baseConfig)
|
||||
..vadBackend = rust.BridgeVadBackend.webrtcVad
|
||||
..nsEnabled = true
|
||||
..aecEnabled = false
|
||||
..agcEnabled = true;
|
||||
@@ -151,7 +127,7 @@ void main() {
|
||||
|
||||
for (final config in [windowsConfig, linuxConfig]) {
|
||||
expect(config.processingBackend, rust.BridgeAudioBackend.webrtcApm);
|
||||
expect(config.vadBackend, rust.BridgeVadBackend.webrtcVad);
|
||||
expect(config.vadBackend, rust.BridgeVadBackend.sileroOnnx);
|
||||
expect(config.aec, rust.BridgeEffectOwner.off);
|
||||
expect(config.ns, rust.BridgeEffectOwner.webrtcApm);
|
||||
expect(config.agc, rust.BridgeEffectOwner.webrtcApm);
|
||||
|
||||
@@ -36,12 +36,10 @@ void main() {
|
||||
required BigInt id,
|
||||
required String name,
|
||||
required BigInt channelId,
|
||||
String uid = '',
|
||||
bool isServerQuery = false,
|
||||
}) {
|
||||
return rust.BridgeClient(
|
||||
id: id,
|
||||
uid: uid,
|
||||
channel: channelId,
|
||||
name: name,
|
||||
inputMuted: false,
|
||||
@@ -466,6 +464,8 @@ void main() {
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: ChatPage(
|
||||
messages: [
|
||||
entry(rust.BridgeMessageTarget.poke(BigInt.from(2))),
|
||||
@@ -498,15 +498,7 @@ void main() {
|
||||
final privateTop = tester.getTopLeft(find.text('Alpha').first).dy;
|
||||
final serverTileSize = tester.getSize(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.byIcon(Icons.dns_outlined),
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is SizedBox &&
|
||||
widget.width == 92 &&
|
||||
widget.height == 92,
|
||||
),
|
||||
)
|
||||
.ancestor(of: find.text('Server').first, matching: find.byType(Ink))
|
||||
.first,
|
||||
);
|
||||
|
||||
@@ -516,63 +508,23 @@ void main() {
|
||||
expect(serverTileSize.height, 92);
|
||||
});
|
||||
|
||||
testWidgets('chat sidebar uses MD3-style selected container colors', (
|
||||
testWidgets('chat page uses a compact top rail on phone width', (
|
||||
tester,
|
||||
) async {
|
||||
final theme = ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: theme,
|
||||
home: ChatPage(
|
||||
messages: const [],
|
||||
snapshot: snapshot(channels: const [], clients: const []),
|
||||
initialTarget: const rust.BridgeMessageTarget.server(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final serverIndicator = find.ancestor(
|
||||
of: find.byIcon(Icons.dns_outlined),
|
||||
matching: find.byType(AnimatedContainer),
|
||||
);
|
||||
final channelIndicator = find.ancestor(
|
||||
of: find.byIcon(Icons.tag),
|
||||
matching: find.byType(AnimatedContainer),
|
||||
);
|
||||
|
||||
final serverDecoration =
|
||||
tester.widget<AnimatedContainer>(serverIndicator).decoration
|
||||
as BoxDecoration;
|
||||
final channelDecoration =
|
||||
tester.widget<AnimatedContainer>(channelIndicator).decoration
|
||||
as BoxDecoration;
|
||||
final serverLabel = tester.widget<Text>(find.text('Server').last);
|
||||
final channelLabel = tester.widget<Text>(find.text('Channel'));
|
||||
|
||||
expect(tester.getSize(serverIndicator), const Size(76, 76));
|
||||
expect(tester.getSize(channelIndicator), const Size(76, 76));
|
||||
expect(serverDecoration.color, theme.colorScheme.primaryContainer);
|
||||
expect(channelDecoration.color, Colors.transparent);
|
||||
expect(serverLabel.style?.color, theme.colorScheme.onPrimaryContainer);
|
||||
expect(channelLabel.style?.color, theme.colorScheme.onSurfaceVariant);
|
||||
});
|
||||
|
||||
testWidgets('long private chat labels stay within fixed rail indicator', (
|
||||
tester,
|
||||
) async {
|
||||
const longName = 'Very Long Private Chat Name That Should Not Stretch';
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: ChatPage(
|
||||
messages: [
|
||||
ChatEntry(
|
||||
senderId: BigInt.from(2),
|
||||
senderName: longName,
|
||||
senderName: 'Alpha',
|
||||
message: 'Private',
|
||||
target: rust.BridgeMessageTarget.client(BigInt.from(2)),
|
||||
),
|
||||
@@ -580,29 +532,24 @@ void main() {
|
||||
snapshot: snapshot(
|
||||
channels: const [],
|
||||
clients: [
|
||||
client(
|
||||
id: BigInt.from(2),
|
||||
name: longName,
|
||||
channelId: BigInt.zero,
|
||||
),
|
||||
client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.zero),
|
||||
],
|
||||
),
|
||||
initialTarget: rust.BridgeMessageTarget.client(BigInt.from(2)),
|
||||
initialClientName: longName,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final selectedIndicator = find.ancestor(
|
||||
of: find.text(longName).first,
|
||||
matching: find.byType(AnimatedContainer),
|
||||
);
|
||||
final privateLabel = tester.widget<Text>(find.text(longName).first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getSize(selectedIndicator), const Size(76, 76));
|
||||
expect(privateLabel.maxLines, 2);
|
||||
expect(privateLabel.overflow, TextOverflow.ellipsis);
|
||||
expect(privateLabel.textAlign, TextAlign.center);
|
||||
expect(find.byType(VerticalDivider), findsNothing);
|
||||
final serverTileSize = tester.getSize(
|
||||
find
|
||||
.ancestor(of: find.text('Server').first, matching: find.byType(Ink))
|
||||
.first,
|
||||
);
|
||||
expect(serverTileSize.width, lessThan(92));
|
||||
expect(serverTileSize.height, lessThan(92));
|
||||
expect(tester.getTopLeft(find.text('Private').first).dy, greaterThan(92));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
@@ -610,6 +557,8 @@ void main() {
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: ChatPage(
|
||||
messages: [
|
||||
ChatEntry(
|
||||
@@ -715,6 +664,8 @@ void main() {
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: ChatPage(
|
||||
messages: [
|
||||
ChatEntry(
|
||||
@@ -755,6 +706,8 @@ void main() {
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: ChatPage(
|
||||
messages: messages,
|
||||
snapshot: currentSnapshot,
|
||||
@@ -822,6 +775,8 @@ void main() {
|
||||
Ts3ServerLink? tapped;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: ChatPage(
|
||||
messages: [
|
||||
ChatEntry(
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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/client_info_sheet.dart';
|
||||
|
||||
void main() {
|
||||
rust.BridgeClientProfile profile() {
|
||||
return rust.BridgeClientProfile(
|
||||
id: BigInt.from(101),
|
||||
channel: BigInt.from(7),
|
||||
name: 'Bob',
|
||||
uniqueId: 'client-unique-id',
|
||||
databaseId: BigInt.from(55),
|
||||
countryCode: 'US',
|
||||
description: 'Operator',
|
||||
version: '3.6.2',
|
||||
platform: 'Windows',
|
||||
onlineSeconds: 3661,
|
||||
idleMilliseconds: 42000,
|
||||
pingMilliseconds: 38,
|
||||
clientAddress: '203.0.113.24',
|
||||
serverGroups: const ['Admin', 'Talk Power'],
|
||||
channelGroup: 'Guest',
|
||||
avatarPath: '/avatar_aabbcc',
|
||||
bytesDownloadedMonth: BigInt.from(2048),
|
||||
bytesUploadedMonth: BigInt.from(4096),
|
||||
bytesDownloadedTotal: BigInt.from(1048576),
|
||||
bytesUploadedTotal: BigInt.from(2097152),
|
||||
packetLossClientToServerTotal: 0.0123,
|
||||
packetLossServerToClientTotal: 0.0456,
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders loaded profile data in a modal sheet', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: Center(
|
||||
child: FilledButton(
|
||||
onPressed: () => showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (context) => FractionallySizedBox(
|
||||
heightFactor: 0.86,
|
||||
child: ClientInfoSheet(
|
||||
clientName: 'Bob',
|
||||
loadProfile: () async => profile(),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('Bob'), findsOneWidget);
|
||||
expect(find.text('Identity'), findsOneWidget);
|
||||
expect(find.text('Membership'), findsOneWidget);
|
||||
expect(find.text('Admin, Talk Power'), findsOneWidget);
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -360));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Connection'), findsOneWidget);
|
||||
expect(find.text('1h 1m 1s'), findsOneWidget);
|
||||
expect(find.text('42.00 s'), findsOneWidget);
|
||||
expect(find.text('203.0.113.24'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('loading and error states use localized copy', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('zh'),
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
height: 500,
|
||||
child: ClientInfoSheet(
|
||||
clientName: 'Bob',
|
||||
loadProfile: () => Future<rust.BridgeClientProfile>.error(
|
||||
StateError('not available'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('正在加载资料…'), findsOneWidget);
|
||||
expect(find.text('Loading profile...'), findsNothing);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('资料不可用'), findsOneWidget);
|
||||
expect(find.text('重试'), findsOneWidget);
|
||||
expect(find.text('Profile unavailable'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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/widgets/connect_widgets.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('pressing enter in password field submits connection', (
|
||||
tester,
|
||||
) async {
|
||||
var connectCount = 0;
|
||||
final hostCtl = TextEditingController(text: 'example.com');
|
||||
final nickCtl = TextEditingController(text: 'alice');
|
||||
final passwordCtl = TextEditingController();
|
||||
addTearDown(hostCtl.dispose);
|
||||
addTearDown(nickCtl.dispose);
|
||||
addTearDown(passwordCtl.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: ConnectForm(
|
||||
hostCtl: hostCtl,
|
||||
nickCtl: nickCtl,
|
||||
passwordCtl: passwordCtl,
|
||||
onConnect: () => connectCount += 1,
|
||||
onAddBookmark: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.enterText(find.byType(TextField).last, 'secret');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
|
||||
expect(connectCount, 1);
|
||||
});
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/widgets/input_dialogs.dart';
|
||||
|
||||
void main() {
|
||||
test('browser back and forward logical keys map to mouse side bindings', () {
|
||||
expect(
|
||||
pttMouseSideButtonPlatformKeyForLogicalKey(
|
||||
LogicalKeyboardKey.browserBack,
|
||||
),
|
||||
'mouse-side-button:8',
|
||||
);
|
||||
expect(
|
||||
pttMouseSideButtonPlatformKeyForLogicalKey(LogicalKeyboardKey.goBack),
|
||||
'mouse-side-button:8',
|
||||
);
|
||||
expect(
|
||||
pttMouseSideButtonPlatformKeyForLogicalKey(
|
||||
LogicalKeyboardKey.browserForward,
|
||||
),
|
||||
'mouse-side-button:16',
|
||||
);
|
||||
});
|
||||
|
||||
test('pointer button bitmasks map to mouse side bindings', () {
|
||||
expect(
|
||||
pttMouseSideButtonPlatformKeyForButtons(0x08),
|
||||
'mouse-side-button:8',
|
||||
);
|
||||
expect(
|
||||
pttMouseSideButtonPlatformKeyForButtons(0x10),
|
||||
'mouse-side-button:16',
|
||||
);
|
||||
expect(
|
||||
pttMouseSideButtonPlatformKeyForButtons(0x18),
|
||||
'mouse-side-button:8',
|
||||
);
|
||||
expect(pttMouseSideButtonPlatformKeyForButtons(0x00), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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/main.dart';
|
||||
import 'package:chanora_flutter/widgets/connect_widgets.dart';
|
||||
import 'package:chanora_flutter/widgets/input_dialogs.dart';
|
||||
import 'package:chanora_flutter/widgets/ptt_capability_badge.dart';
|
||||
|
||||
void main() {
|
||||
Widget localizedHarness(Widget child, {Locale? locale}) {
|
||||
return MaterialApp(
|
||||
locale: locale,
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(body: child),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('connect actions stack on narrow mobile widths', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
localizedHarness(
|
||||
SizedBox(
|
||||
width: 320,
|
||||
child: ConnectForm(
|
||||
hostCtl: TextEditingController(text: 'server.example.com'),
|
||||
nickCtl: TextEditingController(text: 'MobileUser'),
|
||||
passwordCtl: TextEditingController(),
|
||||
onConnect: () {},
|
||||
onAddBookmark: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final connectTop = tester.getTopLeft(find.text('Connect')).dy;
|
||||
final bookmarkTop = tester.getTopLeft(find.text('Save bookmark')).dy;
|
||||
|
||||
expect(bookmarkTop, greaterThan(connectTop + 44));
|
||||
});
|
||||
|
||||
testWidgets('connect actions stack at standard phone content widths', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
localizedHarness(
|
||||
SizedBox(
|
||||
width: 361,
|
||||
child: ConnectForm(
|
||||
hostCtl: TextEditingController(text: 'server.example.com'),
|
||||
nickCtl: TextEditingController(text: 'MobileUser'),
|
||||
passwordCtl: TextEditingController(),
|
||||
onConnect: () {},
|
||||
onAddBookmark: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final connectTop = tester.getTopLeft(find.text('Connect')).dy;
|
||||
final bookmarkTop = tester.getTopLeft(find.text('Save bookmark')).dy;
|
||||
|
||||
expect(bookmarkTop, greaterThan(connectTop + 44));
|
||||
});
|
||||
|
||||
testWidgets('disconnected mobile chrome keeps content near the safe area', (
|
||||
tester,
|
||||
) async {
|
||||
const primaryContentKey = Key('primary-content');
|
||||
|
||||
await tester.binding.setSurfaceSize(const Size(393, 852));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
await tester.pumpWidget(
|
||||
localizedHarness(
|
||||
ChanoraMobileScaffold(
|
||||
compactIdleChrome: true,
|
||||
canDisconnect: false,
|
||||
title: null,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'About',
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
onDisconnect: () {},
|
||||
compactHeader: const SizedBox(height: 44, child: Text('Chanora')),
|
||||
body: const KeyedSubtree(
|
||||
key: primaryContentKey,
|
||||
child: Text('Primary content'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AppBar), findsNothing);
|
||||
expect(tester.getTopLeft(find.byKey(primaryContentKey)).dy, lessThan(120));
|
||||
});
|
||||
|
||||
testWidgets('PTT explanation sheet is scrollable for mobile text scaling', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
localizedHarness(
|
||||
MediaQuery(
|
||||
data: const MediaQueryData(textScaler: TextScaler.linear(1.8)),
|
||||
child: const SizedBox(
|
||||
width: 320,
|
||||
child: PttCapabilityBadge(
|
||||
level: 'L0Focused',
|
||||
backendId: 'focused',
|
||||
boundInputClass: 'keyboard',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.info_outline));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(SingleChildScrollView), findsWidgets);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('PTT capture dialog wraps content for small screens', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
localizedHarness(
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return Center(
|
||||
child: FilledButton(
|
||||
onPressed: () => showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => const PttBindingCaptureDialog(),
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(SingleChildScrollView), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||
import 'package:chanora_flutter/services/ui_preferences_service.dart';
|
||||
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||
import 'package:chanora_flutter/widgets/snapshot_view.dart';
|
||||
|
||||
@@ -30,14 +29,12 @@ void main() {
|
||||
required int id,
|
||||
required int channelId,
|
||||
required String name,
|
||||
String uid = '',
|
||||
bool speaking = false,
|
||||
int talkPower = 0,
|
||||
bool talkPowerGranted = false,
|
||||
}) {
|
||||
return rust.BridgeClient(
|
||||
id: BigInt.from(id),
|
||||
uid: uid,
|
||||
channel: BigInt.from(channelId),
|
||||
name: name,
|
||||
inputMuted: false,
|
||||
@@ -52,11 +49,14 @@ void main() {
|
||||
Widget snapshotHarness({
|
||||
required List<rust.BridgeChannel> channels,
|
||||
required List<rust.BridgeClient> clients,
|
||||
String welcomeMessage = '',
|
||||
BigInt? ownClientId,
|
||||
BigInt? currentVoiceChannelId,
|
||||
rust.BridgeAudioStats? audioStats,
|
||||
Map<String, ClientPlaybackPreference> clientPlaybackPreferences = const {},
|
||||
ClientPlaybackPreferenceChanged? onClientPlaybackPreferenceChanged,
|
||||
bool enableClientLongPressMenu = false,
|
||||
ValueChanged<rust.BridgeClient>? onOpenClientInfo,
|
||||
ValueChanged<rust.BridgeClient>? onOpenClientChat,
|
||||
ValueChanged<rust.BridgeClient>? onOpenClientPoke,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
@@ -65,7 +65,7 @@ void main() {
|
||||
body: SnapshotView(
|
||||
snapshot: rust.BridgeSnapshot(
|
||||
serverName: 'Server',
|
||||
welcomeMessage: '',
|
||||
welcomeMessage: welcomeMessage,
|
||||
platform: '',
|
||||
version: '',
|
||||
channels: channels,
|
||||
@@ -81,8 +81,10 @@ void main() {
|
||||
canJoinVoiceChannel: true,
|
||||
onJoinChannel: (_) {},
|
||||
onJoinChannelWithPassword: (_) {},
|
||||
clientPlaybackPreferences: clientPlaybackPreferences,
|
||||
onClientPlaybackPreferenceChanged: onClientPlaybackPreferenceChanged,
|
||||
enableClientLongPressMenu: enableClientLongPressMenu,
|
||||
onOpenClientInfo: onOpenClientInfo,
|
||||
onOpenClientChat: onOpenClientChat,
|
||||
onOpenClientPoke: onOpenClientPoke,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -197,6 +199,27 @@ void main() {
|
||||
expect(childUserX - childX, inInclusiveRange(22, 28));
|
||||
});
|
||||
|
||||
testWidgets('server welcome starts collapsed above channel tree', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
welcomeMessage:
|
||||
'This is a long server welcome that should not push channels '
|
||||
'below the first connected screen.',
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [client(id: 100, channelId: 1, name: 'Alice')],
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Server welcome message'), findsOneWidget);
|
||||
expect(find.textContaining('long server welcome'), findsNothing);
|
||||
expect(find.text('Default Channel'), findsOneWidget);
|
||||
expect(find.text('Alice'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('password channel shows lock at row end', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
@@ -242,6 +265,219 @@ void main() {
|
||||
expect(userHighlight.decoration, isNull);
|
||||
});
|
||||
|
||||
testWidgets('long press opens client menu for other users', (tester) async {
|
||||
rust.BridgeClient? chatClient;
|
||||
rust.BridgeClient? pokeClient;
|
||||
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Alice'),
|
||||
client(id: 101, channelId: 1, name: 'Bob'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
enableClientLongPressMenu: true,
|
||||
onOpenClientChat: (client) => chatClient = client,
|
||||
onOpenClientPoke: (client) => pokeClient = client,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.text('Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Private message'), findsOneWidget);
|
||||
expect(find.text('Poke'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Private message'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(chatClient?.id, BigInt.from(101));
|
||||
expect(chatClient?.name, 'Bob');
|
||||
expect(pokeClient, isNull);
|
||||
});
|
||||
|
||||
testWidgets('long press is ignored when mobile client menus are disabled', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Alice'),
|
||||
client(id: 101, channelId: 1, name: 'Bob'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
enableClientLongPressMenu: false,
|
||||
onOpenClientChat: (_) {},
|
||||
onOpenClientPoke: (_) {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.text('Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Private message'), findsNothing);
|
||||
expect(find.text('Poke'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('secondary click opens client menu for other users', (
|
||||
tester,
|
||||
) async {
|
||||
rust.BridgeClient? chatClient;
|
||||
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Alice'),
|
||||
client(id: 101, channelId: 1, name: 'Bob'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
onOpenClientChat: (client) => chatClient = client,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.text('Bob'),
|
||||
buttons: kSecondaryMouseButton,
|
||||
kind: PointerDeviceKind.mouse,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Private message'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Private message'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(chatClient?.id, BigInt.from(101));
|
||||
});
|
||||
|
||||
testWidgets('client menu poke action targets selected other user', (
|
||||
tester,
|
||||
) async {
|
||||
rust.BridgeClient? pokeClient;
|
||||
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Alice'),
|
||||
client(id: 101, channelId: 1, name: 'Bob'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
enableClientLongPressMenu: true,
|
||||
onOpenClientPoke: (client) => pokeClient = client,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.text('Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Poke'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(pokeClient?.id, BigInt.from(101));
|
||||
expect(pokeClient?.name, 'Bob');
|
||||
expect(find.text('Poke'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('client menu info action targets selected other user', (
|
||||
tester,
|
||||
) async {
|
||||
rust.BridgeClient? infoClient;
|
||||
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Alice'),
|
||||
client(id: 101, channelId: 1, name: 'Bob'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
enableClientLongPressMenu: true,
|
||||
onOpenClientInfo: (client) => infoClient = client,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.text('Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Info'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Info'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(infoClient?.id, BigInt.from(101));
|
||||
expect(infoClient?.name, 'Bob');
|
||||
});
|
||||
|
||||
testWidgets('client menu info action is available for self', (tester) async {
|
||||
rust.BridgeClient? infoClient;
|
||||
rust.BridgeClient? chatClient;
|
||||
rust.BridgeClient? pokeClient;
|
||||
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Alice'),
|
||||
client(id: 101, channelId: 1, name: 'Bob'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
enableClientLongPressMenu: true,
|
||||
onOpenClientInfo: (client) => infoClient = client,
|
||||
onOpenClientChat: (client) => chatClient = client,
|
||||
onOpenClientPoke: (client) => pokeClient = client,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.text('Alice'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Info'), findsOneWidget);
|
||||
expect(find.text('Private message'), findsNothing);
|
||||
expect(find.text('Poke'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Info'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(infoClient?.id, BigInt.from(100));
|
||||
expect(infoClient?.name, 'Alice');
|
||||
expect(chatClient, isNull);
|
||||
expect(pokeClient, isNull);
|
||||
});
|
||||
|
||||
testWidgets('self menu is hidden when only peer actions are available', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Alice'),
|
||||
client(id: 101, channelId: 1, name: 'Bob'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
enableClientLongPressMenu: true,
|
||||
onOpenClientChat: (_) {},
|
||||
onOpenClientPoke: (_) {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.text('Alice'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Private message'), findsNothing);
|
||||
expect(find.text('Poke'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'local voice activity does not light speaking state when blocked',
|
||||
(tester) async {
|
||||
@@ -270,114 +506,6 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('remote client row does not show playback ellipsis', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
|
||||
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
clientPlaybackPreferences: const {
|
||||
'user-b': ClientPlaybackPreference(volume: 0.5, muted: false),
|
||||
},
|
||||
onClientPlaybackPreferenceChanged: (_, _) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.more_horiz), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('remote client long press opens playback menu', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
|
||||
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
onClientPlaybackPreferenceChanged: (_, _) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Mute playback'), findsOneWidget);
|
||||
expect(find.text('Volume 100%'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('remote client secondary click opens playback menu', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
|
||||
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
onClientPlaybackPreferenceChanged: (_, _) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.widgetWithText(ListTile, 'Bob'),
|
||||
buttons: kSecondaryButton,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Mute playback'), findsOneWidget);
|
||||
expect(find.text('Volume 100%'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('client playback menu forwards mute and volume changes', (
|
||||
tester,
|
||||
) async {
|
||||
final changes = <ClientPlaybackPreference>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
|
||||
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
onClientPlaybackPreferenceChanged: (_, preference) async {
|
||||
changes.add(preference);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Mute playback'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(changes, isNotEmpty);
|
||||
expect(changes.last.muted, isTrue);
|
||||
|
||||
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Volume 200%'), warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(changes.last.volume, 2.0);
|
||||
expect(changes.last.muted, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('spacer channels render as layout rows and keep channel taps', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/startup_dependency_check.dart';
|
||||
import 'package:chanora_flutter/widgets/startup_dependency_screen.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('startup gate shows install-help screen and can continue', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1200, 1800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final result = StartupDependencyCheckResult(
|
||||
platformLabel: 'Fedora',
|
||||
issues: const [
|
||||
StartupDependencyIssue(
|
||||
id: 'linux-pipewire-runtime',
|
||||
title: 'PipeWire runtime is missing',
|
||||
summary: 'Primary Linux audio needs PipeWire.',
|
||||
details: ['Install PipeWire and recheck.'],
|
||||
severity: StartupDependencySeverity.recommended,
|
||||
installHints: [
|
||||
StartupInstallHint(
|
||||
label: 'Release downloads',
|
||||
command: 'https://github.com/microsoft/onnxruntime/releases',
|
||||
),
|
||||
StartupInstallHint(
|
||||
label: 'Fedora',
|
||||
command: 'sudo dnf install pipewire-libs',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
final loggedResults = <StartupDependencyCheckResult>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: StartupDependencyGate(
|
||||
checker: () async => result,
|
||||
logger: (value) async {
|
||||
loggedResults.add(value);
|
||||
},
|
||||
child: const Scaffold(body: Text('ready')),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Finish Linux setup'), findsOneWidget);
|
||||
expect(find.text('PipeWire runtime is missing'), findsOneWidget);
|
||||
expect(find.text('Continue anyway'), findsOneWidget);
|
||||
expect(find.text('Open'), findsOneWidget);
|
||||
expect(find.text('Copy'), findsOneWidget);
|
||||
expect(loggedResults, [result]);
|
||||
|
||||
await tester.ensureVisible(find.text('Continue anyway'));
|
||||
await tester.tap(find.text('Continue anyway'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('ready'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -24,29 +24,12 @@ void main() {
|
||||
]);
|
||||
});
|
||||
|
||||
test('desktop VAD segments expose both Silero and WebRTC', () {
|
||||
test('desktop VAD segments expose Silero as the primary backend', () {
|
||||
expect(desktopVadBackendSegments.map((s) => s.value), [
|
||||
rust.BridgeVadBackend.webrtcVad,
|
||||
rust.BridgeVadBackend.sileroOnnx,
|
||||
]);
|
||||
});
|
||||
|
||||
test('desktop VAD segments disable Silero when ONNX Runtime is missing', () {
|
||||
final segments = vadBackendSegmentsForAvailability(
|
||||
desktop: true,
|
||||
onnxRuntimeAvailable: false,
|
||||
);
|
||||
|
||||
final silero = segments.singleWhere(
|
||||
(segment) => segment.value == rust.BridgeVadBackend.sileroOnnx,
|
||||
);
|
||||
final webrtc = segments.singleWhere(
|
||||
(segment) => segment.value == rust.BridgeVadBackend.webrtcVad,
|
||||
);
|
||||
expect(silero.enabled, isFalse);
|
||||
expect(webrtc.enabled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('shared segmented style applies compact visual density', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
Reference in New Issue
Block a user