feat: Call-Robustheit + Standalone-Voice-Channel per Klick beitretbar (M3)
Mit zwei echten Test-Accounts (zwei GUI-Instanzen via PYRAMID_PROFILE_DIR) durchgeklickt: Beitreten, Teilnehmerliste, bidirektionaler Screenshare, Live-Qualitaetswechsel (Full HD 60fps), sauberes Verlassen. - rooms_list.dart: Voice-Channel in der normalen Raumliste war per Klick nicht beitretbar (oeffnete nur Chat) -> _toggleVoiceChannel wie Space-Liste. - livekit_call_manager.dart: _callGeneration entwertet laufenden connect bei Auflegen/Wechsel (keine Geisterverbindung mit offenem Mikro); Fehlerpfade setzen error sichtbar statt still zu verschwinden. - rooms_panel_core.dart: raeumt activeVoiceRoomIdProvider auf, wenn der Voice-Service inaktiv wird, + Fehler-SnackBar. - test/live_call_setup_test.dart: gegateter Setup-Helfer fuer den Testraum. UNGETESTET: echtes 2-Peer-Audio, Netz-Abbruch mitten im Call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
@TestOn('windows')
|
||||
library;
|
||||
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:sqlite3/open.dart' as sqlite_open;
|
||||
|
||||
/// Setup-Helfer für die Call-Tests mit den Autopilot-Test-Accounts (gegated
|
||||
/// wie `live_encryption_e2e_test.dart`, läuft NIE im normalen `flutter test`):
|
||||
/// stellt sicher, dass ein Voice-Channel-Raum („Pyramid Autopilot Voice",
|
||||
/// `io.pyramid.voice_channel` + Presence-PowerLevel 0 wie im
|
||||
/// create_join_dialog) existiert und BEIDE Test-Nutzer Mitglied sind.
|
||||
/// Danach können zwei GUI-Instanzen (PYRAMID_PROFILE_DIR) den Kanal joinen.
|
||||
///
|
||||
/// Kein Logout am Ende nötig/gewollt für die GUI-Sessions – die Skript-Geräte
|
||||
/// melden sich selbst ab, die GUI-Tokens bleiben unberührt.
|
||||
|
||||
const _kVoiceRoomName = 'Pyramid Autopilot Voice';
|
||||
|
||||
Map<String, String>? _loadAccounts() {
|
||||
final profile = Platform.environment['USERPROFILE'] ?? '';
|
||||
final file = File('$profile\\.pyramid-autopilot\\test-accounts.txt');
|
||||
if (!file.existsSync()) return null;
|
||||
final map = <String, String>{};
|
||||
for (final line in file.readAsLinesSync()) {
|
||||
final t = line.trim();
|
||||
if (t.isEmpty || t.startsWith('#')) continue;
|
||||
final i = t.indexOf('=');
|
||||
if (i > 0) map[t.substring(0, i)] = t.substring(i + 1);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
String? _findSqliteDll() {
|
||||
for (final config in ['Debug', 'Profile', 'Release']) {
|
||||
final file = File('build/windows/x64/runner/$config/sqlite3.dll');
|
||||
if (file.existsSync()) return file.absolute.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Client> _login(String name, String user, String pass, Uri hs) async {
|
||||
// Wegwerf-Client: In-Memory-DB reicht (die DLL aus dem App-Build laden,
|
||||
// der flutter_tester bringt selbst keine sqlite3.dll mit).
|
||||
final dll = _findSqliteDll()!;
|
||||
final factory = createDatabaseFactoryFfi(
|
||||
ffiInit: () {
|
||||
sqlite_open.open.overrideFor(
|
||||
sqlite_open.OperatingSystem.windows,
|
||||
() => DynamicLibrary.open(dll),
|
||||
);
|
||||
},
|
||||
noIsolate: true,
|
||||
);
|
||||
final db = await factory.openDatabase(inMemoryDatabasePath);
|
||||
final sdkDb = await MatrixSdkDatabase.init(name, database: db);
|
||||
final client = Client(name, database: sdkDb);
|
||||
await client.init(waitForFirstSync: false, waitUntilLoadCompletedLoaded: false);
|
||||
await client.checkHomeserver(hs);
|
||||
await client.login(
|
||||
LoginType.mLoginPassword,
|
||||
identifier: AuthenticationUserIdentifier(user: user),
|
||||
password: pass,
|
||||
initialDeviceDisplayName: name,
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
void main() {
|
||||
final acc = _loadAccounts();
|
||||
final skip = Platform.environment['PYRAMID_LIVE_TEST'] != '1'
|
||||
? 'Live-Setup nur mit PYRAMID_LIVE_TEST=1'
|
||||
: acc == null
|
||||
? 'Test-Account-Datei fehlt'
|
||||
: null;
|
||||
|
||||
test('Voice-Channel-Testraum existiert und beide Test-Nutzer sind drin',
|
||||
() async {
|
||||
final hs = Uri.parse(acc!['homeserver']!);
|
||||
final c1 = await _login('AutopilotCallSetup1', acc['user1']!, acc['pass1']!, hs);
|
||||
final c2 = await _login('AutopilotCallSetup2', acc['user2']!, acc['pass2']!, hs);
|
||||
try {
|
||||
// ignore: avoid_print
|
||||
print('[setup] beide Logins ok, erster Sync…');
|
||||
await c1.oneShotSync().timeout(const Duration(seconds: 60));
|
||||
// ignore: avoid_print
|
||||
print('[setup] c1 sync ok, suche Raum…');
|
||||
|
||||
Room? voiceRoom;
|
||||
for (final r in c1.rooms) {
|
||||
if (r.membership == Membership.join &&
|
||||
r.name == _kVoiceRoomName &&
|
||||
r.getState(EventTypes.RoomCreate)?.content['type'] ==
|
||||
'io.pyramid.voice_channel') {
|
||||
voiceRoom = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (voiceRoom == null) {
|
||||
// ignore: avoid_print
|
||||
print('[setup] Raum fehlt, lege an…');
|
||||
final roomId = await c1.createRoom(
|
||||
name: _kVoiceRoomName,
|
||||
visibility: Visibility.private,
|
||||
preset: CreateRoomPreset.privateChat,
|
||||
creationContent: {'type': 'io.pyramid.voice_channel'},
|
||||
);
|
||||
// Presence muss für normale Mitglieder schreibbar sein – exakt wie
|
||||
// in create_join_dialog.dart (Continuwuity ignoriert
|
||||
// power_level_content_override beim createRoom).
|
||||
final plRaw =
|
||||
await c1.getRoomStateWithKey(roomId, EventTypes.RoomPowerLevels, '');
|
||||
final pl = Map<String, dynamic>.from(plRaw as Map);
|
||||
final events = Map<String, dynamic>.from(pl['events'] as Map? ?? {});
|
||||
events['io.pyramid.voice.presence'] = 0;
|
||||
pl['events'] = events;
|
||||
await c1.setRoomStateWithKey(roomId, EventTypes.RoomPowerLevels, '', pl);
|
||||
// ignore: avoid_print
|
||||
print('[setup] Raum $roomId angelegt, warte auf Sync…');
|
||||
await c1.waitForRoomInSync(roomId, join: true)
|
||||
.timeout(const Duration(seconds: 60));
|
||||
voiceRoom = c1.getRoomById(roomId);
|
||||
}
|
||||
expect(voiceRoom, isNotNull);
|
||||
// ignore: avoid_print
|
||||
print('[setup] Raum da: ${voiceRoom!.id}, hole user2 rein…');
|
||||
|
||||
// user2 einladen (falls noch nicht Mitglied) und beitreten lassen.
|
||||
final u2 = c2.userID!;
|
||||
await c2.oneShotSync().timeout(const Duration(seconds: 60));
|
||||
// ignore: avoid_print
|
||||
print('[setup] c2 sync ok…');
|
||||
final r2 = c2.getRoomById(voiceRoom.id);
|
||||
if (r2 == null || r2.membership != Membership.join) {
|
||||
if (r2 == null || r2.membership != Membership.invite) {
|
||||
try {
|
||||
await voiceRoom.invite(u2);
|
||||
} on MatrixException catch (e) {
|
||||
// "already in the room" o. ä. ist ok.
|
||||
if (e.error != MatrixError.M_FORBIDDEN) rethrow;
|
||||
}
|
||||
}
|
||||
await c2.joinRoom(voiceRoom.id);
|
||||
await c2.waitForRoomInSync(voiceRoom.id, join: true)
|
||||
.timeout(const Duration(seconds: 60));
|
||||
}
|
||||
|
||||
final members = await voiceRoom.requestParticipants();
|
||||
final joined = members
|
||||
.where((m) => m.membership == Membership.join)
|
||||
.map((m) => m.id.toLowerCase())
|
||||
.toSet();
|
||||
expect(joined.contains(acc['user1']!.toLowerCase()), isTrue);
|
||||
expect(joined.contains(u2.toLowerCase()), isTrue,
|
||||
reason: 'user2 ist dem Voice-Raum nicht beigetreten');
|
||||
// ignore: avoid_print
|
||||
print('Voice-Testraum bereit: ${voiceRoom.id}');
|
||||
} finally {
|
||||
// Nur die Skript-Geräte abmelden – GUI-Sessions bleiben unberührt.
|
||||
try {
|
||||
await c1.logout();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await c2.logout();
|
||||
} catch (_) {}
|
||||
await c1.dispose(closeDatabase: false);
|
||||
await c2.dispose(closeDatabase: false);
|
||||
}
|
||||
},
|
||||
skip: skip,
|
||||
timeout: const Timeout(Duration(minutes: 5)));
|
||||
}
|
||||
Reference in New Issue
Block a user