Files
pyramid/test/live_call_setup_test.dart
Bernd Steckmeister f8f5332d90 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>
2026-07-06 22:50:58 +02:00

178 lines
6.6 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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)));
}