Bernds Wunsch 2026-10-08: UI grundsätzlich prüfen (Navigation, Sackgassen, Anruf-Logik). Audit + Konzept in docs/UI_AUDIT.md, belegt mit neuer Screenshot-Tour integration_test/ui_audit_tour_test.dart (35 Zustände, Handy + PC, zwei Testkonten, Test-Space mit Sprachkanal). Sofort behoben: - S1/S2: Gegenseite legt auf / Anruf scheitert / Sprachkanal bricht ab → automatisch zurück zum Chat mit Hinweis (Fehler sichtbar statt still); leere Anrufansicht deutsch mit Zurück-Knopf (vorher englische Sackgasse ohne Zurück am Handy). - S4: toter „Threads“-Knopf entfernt. S5: Space betreten (PC) öffnet letzten bzw. ersten Textkanal. S6: keine doppelte Anrufsteuerung am PC. - Annehmen-Fenster mit Anrufer-Avatar und Anrufart; eigene Kachel mit eigenem Initial statt „D“; Mitgliederliste rund mit Profilbild und „(Du)“; Tastaturkürzel nur am PC; englische Texte in Anruf/Leerzuständen deutsch. - Tests: Fehlerempfänger des Testrahmens nach app.main() zurück (vorher verschwanden Fehlermeldungen), Lesebestätigungs-Test im Wegwerf-Raum statt in der DM, robustere Schritte; test.ps1 räumt liegengebliebene Test-Prozesse weg; gemeinsame Hilfen integration_test/support/. Co-Authored-By: Claude Opus 5.5 <[email protected]>
257 lines
8.2 KiB
Dart
257 lines
8.2 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
|
import 'package:matrix/matrix.dart';
|
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
|
import 'package:webrtc_interface/webrtc_interface.dart';
|
|
|
|
// Gemeinsame Hilfen für Live-Tests mit zwei Testkonten (Anruftest,
|
|
// UI-Tour): zweiter Matrix-Client im selben Prozess, Zugangsdaten,
|
|
// Warte-Helfer. Kein eigenständiger Test.
|
|
|
|
/// Zweiter Teilnehmer: eigener Matrix-Client + VoIP im selben Prozess.
|
|
class TestPartner {
|
|
TestPartner(this.client, this.delegate, this.voip, this.zone) {
|
|
_sub = client.onCallEvents.stream.listen((events) {
|
|
for (final e in events) {
|
|
if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) {
|
|
continue;
|
|
}
|
|
final list = e.content.tryGetList<Map<String, dynamic>>('candidates');
|
|
if (list != null) _buffer.addAll(list);
|
|
}
|
|
});
|
|
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
|
final pc = delegate.call?.pc;
|
|
if (!fixCandidates || pc == null) return;
|
|
try {
|
|
if (await pc.getRemoteDescription() == null) return;
|
|
for (final c in List.of(_buffer)) {
|
|
final cand = c['candidate'] as String?;
|
|
if (cand == null || !_applied.add(cand)) continue;
|
|
await pc.addCandidate(RTCIceCandidate(
|
|
cand,
|
|
c['sdpMid']?.toString() ?? '',
|
|
(c['sdpMLineIndex'] as num?)?.round() ?? 0,
|
|
));
|
|
}
|
|
} catch (_) {}
|
|
});
|
|
}
|
|
final Client client;
|
|
final TestPartnerDelegate delegate;
|
|
final VoIP voip;
|
|
final Zone zone;
|
|
|
|
/// Aktion der Gegenseite in ihrer Zone ausführen (Fehler über Completer).
|
|
Future<T> run<T>(Future<T> Function() f) {
|
|
final done = Completer<T>();
|
|
zone.run(() async {
|
|
try {
|
|
done.complete(await f());
|
|
} catch (e, s) {
|
|
done.completeError(e, s);
|
|
}
|
|
});
|
|
return done.future;
|
|
}
|
|
|
|
/// Gegenseite reicht Kandidaten nach wie die gepatchte App.
|
|
bool fixCandidates = false;
|
|
final _buffer = <Map<String, dynamic>>[];
|
|
final _applied = <String>{};
|
|
StreamSubscription? _sub;
|
|
Timer? _timer;
|
|
|
|
static Future<TestPartner> start(String user, String pass) async {
|
|
final db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath);
|
|
final client = Client(
|
|
'pyramid-call-partner',
|
|
database: await MatrixSdkDatabase.init('pyramid-call-partner', database: db),
|
|
);
|
|
await client.init(waitForFirstSync: false, waitUntilLoadCompletedLoaded: false);
|
|
await client.checkHomeserver(Uri.parse('https://steggi-matrix.work'));
|
|
await client.login(
|
|
LoginType.mLoginPassword,
|
|
identifier: AuthenticationUserIdentifier(user: user),
|
|
password: pass,
|
|
initialDeviceDisplayName: 'Pyramid-Anruftest (kurzlebig)',
|
|
);
|
|
final delegate = TestPartnerDelegate();
|
|
// Eigene Zone: Fehler der Gegenseite sichtbar protokollieren statt sie
|
|
// im Testrahmen zu verlieren.
|
|
final zone = Zone.current.fork(
|
|
specification: ZoneSpecification(
|
|
handleUncaughtError: (self, parent, z, e, s) =>
|
|
logStep('Gegenseite (abgefangen): ${e.toString().split('\n').first}'),
|
|
),
|
|
);
|
|
final voip = zone.run(() => VoIP(client, delegate));
|
|
return TestPartner(client, delegate, voip, zone);
|
|
}
|
|
|
|
Future<void> dispose(String? roomId) async {
|
|
_timer?.cancel();
|
|
await _sub?.cancel();
|
|
try {
|
|
await delegate.call?.hangup(reason: CallErrorCode.userHangup);
|
|
} catch (_) {}
|
|
if (roomId != null) {
|
|
try {
|
|
await client.leaveRoom(roomId);
|
|
await client.forgetRoom(roomId);
|
|
} catch (_) {}
|
|
}
|
|
try {
|
|
client.backgroundSync = false;
|
|
await client.logout();
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
class TestPartnerDelegate implements WebRTCDelegate {
|
|
CallSession? call;
|
|
|
|
@override
|
|
MediaDevices get mediaDevices => TestPartnerMedia(rtc.navigator.mediaDevices);
|
|
|
|
@override
|
|
Future<RTCPeerConnection> createPeerConnection(
|
|
Map<String, dynamic> configuration, [
|
|
Map<String, dynamic> constraints = const {},
|
|
]) =>
|
|
rtc.createPeerConnection(configuration, constraints);
|
|
|
|
@override
|
|
Future<void> handleNewCall(CallSession session) async {
|
|
call = session;
|
|
if (session.direction == CallDirection.kIncoming) {
|
|
// Wie ein Mensch nach kurzem Klingeln annehmen. Sofortiges Annehmen
|
|
// trifft eine Lücke im SDK: Kandidaten, die WÄHREND answer()
|
|
// eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet
|
|
// wird (CallSession.onCandidatesReceived vs. answer()).
|
|
unawaited(Future.delayed(const Duration(seconds: 2), () async {
|
|
try {
|
|
await session.answer();
|
|
} catch (e) {
|
|
logStep('Gegenseite: Annehmen fehlgeschlagen: $e');
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> handleCallEnded(CallSession session) async {}
|
|
@override
|
|
Future<void> handleMissedCall(CallSession session) async {}
|
|
@override
|
|
Future<void> handleNewGroupCall(GroupCallSession groupCall) async {}
|
|
@override
|
|
Future<void> handleGroupCallEnded(GroupCallSession groupCall) async {}
|
|
@override
|
|
Future<void> playRingtone() async {}
|
|
@override
|
|
Future<void> stopRingtone() async {}
|
|
@override
|
|
Future<void> registerListeners(CallSession session) async {}
|
|
@override
|
|
bool get isWeb => false;
|
|
@override
|
|
bool get canHandleNewCall => true;
|
|
@override
|
|
EncryptionKeyProvider? get keyProvider => null;
|
|
}
|
|
|
|
/// Wie die App am PC: Bildschirm über eine Desktop-Quelle (erster Monitor).
|
|
class TestPartnerMedia extends MediaDevices {
|
|
TestPartnerMedia(this._d);
|
|
final MediaDevices _d;
|
|
|
|
@override
|
|
Future<MediaStream> getUserMedia(Map<String, dynamic> c) => _d.getUserMedia(c);
|
|
|
|
@override
|
|
Future<MediaStream> getDisplayMedia(Map<String, dynamic> c) async {
|
|
final sources = await rtc.desktopCapturer
|
|
.getSources(types: [rtc.SourceType.Screen]);
|
|
return _d.getDisplayMedia({
|
|
'video': {
|
|
'mandatory': {
|
|
'chromeMediaSource': 'desktop',
|
|
'chromeMediaSourceId': sources.first.id,
|
|
},
|
|
'optional': [],
|
|
},
|
|
'audio': false,
|
|
});
|
|
}
|
|
|
|
@override
|
|
Future<List<MediaDeviceInfo>> enumerateDevices() => _d.enumerateDevices();
|
|
@override
|
|
MediaTrackSupportedConstraints getSupportedConstraints() =>
|
|
_d.getSupportedConstraints();
|
|
@override
|
|
set ondevicechange(Function(dynamic event)? callback) =>
|
|
_d.ondevicechange = callback;
|
|
@override
|
|
Function(dynamic event)? get ondevicechange => _d.ondevicechange;
|
|
@override
|
|
// ignore: deprecated_member_use
|
|
Future<List<dynamic>> getSources() => _d.getSources();
|
|
@override
|
|
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) =>
|
|
_d.selectAudioOutput(options);
|
|
}
|
|
|
|
Map<String, String>? loadTestAccounts() {
|
|
final home = Platform.environment['USERPROFILE'] ?? '';
|
|
final file = File('$home\\.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;
|
|
}
|
|
|
|
Future<void> pumpFor(WidgetTester tester, Duration d) async {
|
|
final end = DateTime.now().add(d);
|
|
while (DateTime.now().isBefore(end)) {
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
}
|
|
}
|
|
|
|
void logStep(String m) {
|
|
// ignore: avoid_print
|
|
print('SCHRITT ${DateTime.now().toIso8601String().substring(11, 19)} $m');
|
|
}
|
|
|
|
Future<void> pumpUntil(
|
|
WidgetTester tester,
|
|
bool Function() condition, {
|
|
Duration timeout = const Duration(seconds: 60),
|
|
String what = '',
|
|
void Function()? onTimeout,
|
|
void Function()? every5s,
|
|
}) async {
|
|
final end = DateTime.now().add(timeout);
|
|
var next = DateTime.now().add(const Duration(seconds: 5));
|
|
while (DateTime.now().isBefore(end)) {
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
if (condition()) return;
|
|
if (every5s != null && DateTime.now().isAfter(next)) {
|
|
every5s();
|
|
next = DateTime.now().add(const Duration(seconds: 5));
|
|
}
|
|
}
|
|
logStep('ZEITÜBERSCHREITUNG: $what');
|
|
onTimeout?.call();
|
|
fail('Zeitüberschreitung: $what');
|
|
}
|