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]>
295 lines
12 KiB
Dart
295 lines
12 KiB
Dart
import 'dart:io';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||
import 'package:integration_test/integration_test.dart';
|
||
import 'package:matrix/matrix.dart';
|
||
import 'package:pyramid/core/matrix_client.dart';
|
||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||
import 'package:pyramid/main.dart' as app;
|
||
import 'package:webrtc_interface/webrtc_interface.dart';
|
||
|
||
import 'support/test_partner.dart';
|
||
|
||
/// Echter 1:1-Anruf zwischen der App (pyramidtest1, Testprofil) und einem
|
||
/// zweiten Matrix-Client im selben Prozess (pyramidtest2, kurzlebiges Gerät,
|
||
/// wird am Ende abgemeldet). Bernds Fehlerbericht 2026-10-08: Ton kam nicht
|
||
/// an, Bildschirm teilen ging weder am PC noch am Handy.
|
||
///
|
||
/// Geprüft wird: Verbindung steht, Ton fließt in BEIDE Richtungen (Bytes),
|
||
/// Bildschirm der Gegenseite wird in der App angezeigt, und die App kann
|
||
/// über den echten Quellen-Auswähler ihren Bildschirm teilen, der bei der
|
||
/// Gegenseite ankommt. (Ob das Mikrofon hörbaren Ton liefert, kann ein
|
||
/// Automat nicht prüfen – dafür gibt es den Mikrofontest und den Sprech-Ring.)
|
||
///
|
||
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||
/// flutter test integration_test/live_call_test.dart -d windows
|
||
void main() {
|
||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||
final accounts = loadTestAccounts();
|
||
|
||
testWidgets(
|
||
'1:1-Anruf: Ton beide Richtungen, Bildschirm teilen beide Richtungen',
|
||
(tester) async {
|
||
Logs().level = Level.verbose;
|
||
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
|
||
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
|
||
final testOnError = FlutterError.onError;
|
||
app.main();
|
||
await pumpUntil(tester, () => find.byType(MaterialApp).evaluate().isNotEmpty);
|
||
FlutterError.onError = testOnError;
|
||
final container = ProviderScope.containerOf(
|
||
tester.element(find.byType(MaterialApp).first),
|
||
);
|
||
final me = (await tester.runAsync(
|
||
() => container.read(matrixClientProvider.future),
|
||
))!;
|
||
expect(me.userID, '@pyramidtest1:steggi-matrix.work');
|
||
|
||
final partner = (await tester.runAsync(
|
||
() => TestPartner.start(accounts!['user2']!, accounts['pass2']!),
|
||
))!;
|
||
String? roomId;
|
||
addTearDown(() => tester.runAsync(() async {
|
||
await partner.dispose(roomId);
|
||
final r = roomId == null ? null : me.getRoomById(roomId);
|
||
await r?.leave().catchError((_) {});
|
||
await r?.forget().catchError((_) {});
|
||
}));
|
||
|
||
// Eigener, unverschlüsselter Testraum (nur für diesen Lauf).
|
||
roomId = await tester.runAsync(() => partner.client.createRoom(
|
||
invite: [me.userID!],
|
||
preset: CreateRoomPreset.privateChat,
|
||
name: 'Anruftest (automatisch)',
|
||
));
|
||
logStep('Raum angelegt: $roomId');
|
||
await pumpUntil(
|
||
tester,
|
||
() => me.getRoomById(roomId!)?.membership == Membership.invite,
|
||
what: 'Einladung kommt bei der App an',
|
||
);
|
||
await tester.runAsync(() => me.getRoomById(roomId!)!.join());
|
||
logStep('App ist beigetreten');
|
||
// Die Gegenseite ist Ersteller und damit im Raum; ihr Mitglieder-Cache
|
||
// wird vom SDK nur bei Bedarf geladen – kurz warten genügt.
|
||
await pumpFor(tester, const Duration(seconds: 3));
|
||
|
||
final voip = container.read(callSignalingProvider);
|
||
|
||
// ═══ Teil 1: Gegenseite ruft an, App nimmt über den echten Knopf an ═══
|
||
// (prüft die Kandidaten-Pufferung der App mit einem ungepatchten
|
||
// Anrufer – genau Bernds Fall „lange/kein Verbindungsaufbau“)
|
||
logStep('Teil 1: Gegenseite ruft an');
|
||
await tester.runAsync(() => partner.run(() => partner.voip.inviteToCall(
|
||
partner.client.getRoomById(roomId!)!,
|
||
CallType.kVoice,
|
||
)));
|
||
final answerBtn = find.byWidgetPredicate(
|
||
(w) => w is FloatingActionButton && w.heroTag == 'answer_call',
|
||
);
|
||
await pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty,
|
||
what: 'Annehmen-Knopf erscheint');
|
||
await pumpFor(tester, const Duration(seconds: 2)); // klingeln lassen
|
||
await tester.tap(answerBtn.first);
|
||
await _waitConnected(tester, voip, partner);
|
||
|
||
await _checkAudio(tester, voip, partner);
|
||
|
||
// Stumm über den echten Knopf → Gegenseite sieht „stumm“, Spur ist aus.
|
||
bool partnerSeesMuted() =>
|
||
partner.delegate.call?.remoteUserMediaStream?.audioMuted == true;
|
||
await tester.tap(_ctrlBtn(Icons.mic_rounded));
|
||
await pumpUntil(tester, partnerSeesMuted,
|
||
timeout: const Duration(seconds: 15),
|
||
what: 'Gegenseite sieht: App ist stumm');
|
||
expect(voip.isMicMuted, isTrue);
|
||
expect(
|
||
voip.currentCall!.localUserMediaStream!.stream!.getAudioTracks().first.enabled,
|
||
isFalse,
|
||
reason: 'Mikrofonspur ist trotz Stumm noch an',
|
||
);
|
||
await tester.tap(_ctrlBtn(Icons.mic_off_rounded));
|
||
await pumpUntil(tester, () => !partnerSeesMuted() && !voip.isMicMuted,
|
||
timeout: const Duration(seconds: 15),
|
||
what: 'Stumm wieder aufgehoben');
|
||
logStep('Stumm/Entstummen über den Knopf: ok');
|
||
|
||
// Taub → Gegenseite nicht mehr hören UND selbst stumm; danach zurück.
|
||
await tester.tap(_ctrlBtn(Icons.headphones_rounded));
|
||
await pumpUntil(
|
||
tester,
|
||
() => voip.isDeafened && partnerSeesMuted(),
|
||
timeout: const Duration(seconds: 15),
|
||
what: 'Taub: selbst stumm',
|
||
);
|
||
final remoteAudio = voip.currentCall!.getRemoteStreams
|
||
.expand((w) => w.stream?.getAudioTracks() ?? const <MediaStreamTrack>[]);
|
||
expect(remoteAudio, isNotEmpty);
|
||
expect(remoteAudio.every((t) => !t.enabled), isTrue,
|
||
reason: 'Ton der Gegenseite ist trotz Taub noch an');
|
||
await tester.tap(_ctrlBtn(Icons.headset_off_rounded));
|
||
await pumpUntil(
|
||
tester,
|
||
() => !voip.isDeafened && !partnerSeesMuted() && !voip.isMicMuted,
|
||
timeout: const Duration(seconds: 15),
|
||
what: 'Taub aufgehoben, Mikro wieder an',
|
||
);
|
||
expect(
|
||
voip.currentCall!.getRemoteStreams
|
||
.expand((w) => w.stream?.getAudioTracks() ?? const <MediaStreamTrack>[])
|
||
.every((t) => t.enabled),
|
||
isTrue,
|
||
);
|
||
logStep('Taub/Hören über den Knopf: ok');
|
||
|
||
// Gegenseite teilt Bildschirm → App zeigt ihn
|
||
await tester.runAsync(
|
||
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(true)),
|
||
);
|
||
await pumpUntil(
|
||
tester,
|
||
() =>
|
||
voip.isRemoteScreensharing &&
|
||
(voip.remoteRenderer.srcObject?.getVideoTracks().isNotEmpty ?? false),
|
||
timeout: const Duration(seconds: 30),
|
||
what: 'Bildschirm der Gegenseite erscheint in der App',
|
||
);
|
||
await pumpFor(tester, const Duration(seconds: 3));
|
||
final appVideoIn = (await tester.runAsync(
|
||
() => _rtp(voip.currentCall!.pc!, 'video'),
|
||
))!;
|
||
logStep('BILD bei der App (von der Gegenseite): $appVideoIn');
|
||
expect(appVideoIn.received, greaterThan(0),
|
||
reason: 'Bildschirm der Gegenseite kommt nicht an');
|
||
await tester.runAsync(
|
||
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(false)),
|
||
);
|
||
await pumpUntil(tester, () => !voip.isRemoteScreensharing,
|
||
what: 'Bildschirm der Gegenseite verschwindet wieder');
|
||
|
||
// App teilt ihren Bildschirm über den echten Auswähler
|
||
await pumpUntil(
|
||
tester,
|
||
() => find.byIcon(Icons.screen_share_rounded).evaluate().isNotEmpty,
|
||
what: 'Teilen-Knopf in der Anrufleiste',
|
||
);
|
||
await tester.tap(find.byIcon(Icons.screen_share_rounded).first);
|
||
final tile = find.byWidgetPredicate(
|
||
(w) => w.runtimeType.toString() == '_SourceTile',
|
||
);
|
||
await pumpUntil(tester, () => tile.evaluate().isNotEmpty,
|
||
timeout: const Duration(seconds: 20), what: 'Quellen-Auswähler');
|
||
await tester.tap(tile.first);
|
||
await pumpUntil(
|
||
tester,
|
||
() {
|
||
final s = partner.delegate.call?.remoteScreenSharingStream?.stream;
|
||
return voip.isScreensharing &&
|
||
s != null &&
|
||
s.getVideoTracks().isNotEmpty;
|
||
},
|
||
timeout: const Duration(seconds: 30),
|
||
what: 'Bildschirm der App kommt bei der Gegenseite an',
|
||
);
|
||
await pumpFor(tester, const Duration(seconds: 3));
|
||
final partnerVideoIn = (await tester.runAsync(
|
||
() => _rtp(partner.delegate.call!.pc!, 'video'),
|
||
))!;
|
||
logStep('BILD bei der Gegenseite (von der App): $partnerVideoIn');
|
||
expect(partnerVideoIn.received, greaterThan(0),
|
||
reason: 'Bildschirm der App kommt nicht an');
|
||
|
||
await tester.runAsync(() => voip.hangup());
|
||
await pumpUntil(tester, () => voip.currentCall == null,
|
||
what: 'aufgelegt (Teil 1)');
|
||
await pumpFor(tester, const Duration(seconds: 3));
|
||
|
||
// ═══ Teil 2: App ruft an, Gegenseite nimmt an ═══
|
||
logStep('Teil 2: App ruft an');
|
||
partner.fixCandidates = true; // Gegenseite wie eine gepatchte Pyramid
|
||
await tester.runAsync(() => voip.startCall(roomId!, video: false));
|
||
await _waitConnected(tester, voip, partner);
|
||
await _checkAudio(tester, voip, partner);
|
||
await tester.runAsync(() => voip.hangup());
|
||
await pumpUntil(tester, () => voip.currentCall == null,
|
||
what: 'aufgelegt (Teil 2)');
|
||
},
|
||
skip: profile == null || accounts == null,
|
||
timeout: const Timeout(Duration(minutes: 5)),
|
||
);
|
||
}
|
||
|
||
Future<void> _waitConnected(
|
||
WidgetTester tester,
|
||
CallSignalingService voip,
|
||
TestPartner partner,
|
||
) async {
|
||
final t0 = DateTime.now();
|
||
await pumpUntil(
|
||
tester,
|
||
() =>
|
||
voip.currentCall?.state == CallState.kConnected &&
|
||
partner.delegate.call?.state == CallState.kConnected,
|
||
timeout: const Duration(seconds: 60),
|
||
what: 'Anruf verbunden',
|
||
every5s: () {
|
||
final a = voip.currentCall;
|
||
final b = partner.delegate.call;
|
||
logStep('App: ${a?.state} ice=${a?.pc?.iceConnectionState} | '
|
||
'Gegenseite: ${b?.state} ice=${b?.pc?.iceConnectionState}');
|
||
},
|
||
);
|
||
logStep('verbunden nach ${DateTime.now().difference(t0).inMilliseconds} ms');
|
||
}
|
||
|
||
Future<void> _checkAudio(
|
||
WidgetTester tester,
|
||
CallSignalingService voip,
|
||
TestPartner partner,
|
||
) async {
|
||
await pumpFor(tester, const Duration(seconds: 4));
|
||
final appAudio = (await tester.runAsync(
|
||
() => _rtp(voip.currentCall!.pc!, 'audio'),
|
||
))!;
|
||
final partnerAudio = (await tester.runAsync(
|
||
() => _rtp(partner.delegate.call!.pc!, 'audio'),
|
||
))!;
|
||
logStep('TON App: $appAudio | Gegenseite: $partnerAudio');
|
||
expect(appAudio.sent, greaterThan(0), reason: 'App sendet keinen Ton');
|
||
expect(appAudio.received, greaterThan(0), reason: 'App empfängt keinen Ton');
|
||
expect(partnerAudio.received, greaterThan(0),
|
||
reason: 'Gegenseite empfängt keinen Ton der App');
|
||
}
|
||
|
||
class _Rtp {
|
||
_Rtp(this.sent, this.received);
|
||
final num sent;
|
||
final num received;
|
||
@override
|
||
String toString() => 'gesendet=$sent B, empfangen=$received B';
|
||
}
|
||
|
||
Future<_Rtp> _rtp(rtc.RTCPeerConnection pc, String kind) async {
|
||
num sent = 0;
|
||
num received = 0;
|
||
for (final r in await pc.getStats()) {
|
||
if (r.values['kind'] != kind) continue;
|
||
if (r.type == 'outbound-rtp') sent += (r.values['bytesSent'] as num?) ?? 0;
|
||
if (r.type == 'inbound-rtp') received += (r.values['bytesReceived'] as num?) ?? 0;
|
||
}
|
||
return _Rtp(sent, received);
|
||
}
|
||
|
||
/// Knopf der Anrufleiste mit diesem Symbol (nicht die Stumm-ANZEIGE in der
|
||
/// Teilnehmer-Kachel, die dasselbe Symbol trägt).
|
||
Finder _ctrlBtn(IconData icon) => find.descendant(
|
||
of: find.byWidgetPredicate((w) =>
|
||
w.runtimeType.toString() == '_VoiceCtrlBtn' &&
|
||
(w as dynamic).icon == icon),
|
||
matching: find.byIcon(icon),
|
||
).first;
|