Bernds Test 2026-10-08: - Taub-Knopf setzte bei 1:1-Anrufen nur ein Häkchen. Jetzt wie Discord: Ton der Gegenseite aus (Empfangsspuren deaktiviert, auch nach Neuverhandlung) und selbst stumm; beim Aufheben Mikro-Zustand von vorher. - Stumm: schlug nur das Senden der Status-Info fehl, schaltete Pyramid den Knopf blind zurück, obwohl das Mikro aus war – danach zeigte der Knopf das Gegenteil. Jetzt wird der echte SDK-Zustand übernommen. - Mini-Anrufleiste erschien schon beim Klingeln (vor dem Annehmen). - Bildschirm-Auswähler: deutsch, Hinweis zur bekannten WebRTC-Grenze bei HDR unter Windows (blasses Bild; Fenster teilen oder Win+Alt+B). - Live-Anruftest drückt Stumm und Taub über die echten Knöpfe und prüft, was bei der Gegenseite ankommt. Co-Authored-By: Claude Opus 5.5 <[email protected]>
526 lines
19 KiB
Dart
526 lines
19 KiB
Dart
import 'dart:async';
|
||
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:sqflite_common_ffi/sqflite_ffi.dart';
|
||
import 'package:webrtc_interface/webrtc_interface.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 = _loadAccounts();
|
||
|
||
testWidgets(
|
||
'1:1-Anruf: Ton beide Richtungen, Bildschirm teilen beide Richtungen',
|
||
(tester) async {
|
||
Logs().level = Level.verbose;
|
||
app.main();
|
||
await _pumpUntil(tester, () => find.byType(MaterialApp).evaluate().isNotEmpty);
|
||
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(
|
||
() => _Partner.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)',
|
||
));
|
||
_log('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());
|
||
_log('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“)
|
||
_log('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(find.byIcon(Icons.mic_rounded).first);
|
||
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(find.byIcon(Icons.mic_off_rounded).first);
|
||
await _pumpUntil(tester, () => !partnerSeesMuted() && !voip.isMicMuted,
|
||
timeout: const Duration(seconds: 15),
|
||
what: 'Stumm wieder aufgehoben');
|
||
_log('Stumm/Entstummen über den Knopf: ok');
|
||
|
||
// Taub → Gegenseite nicht mehr hören UND selbst stumm; danach zurück.
|
||
await tester.tap(find.byIcon(Icons.headphones_rounded).first);
|
||
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(find.byIcon(Icons.headset_off_rounded).first);
|
||
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,
|
||
);
|
||
_log('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'),
|
||
))!;
|
||
_log('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'),
|
||
))!;
|
||
_log('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 ═══
|
||
_log('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,
|
||
_Partner 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;
|
||
_log('App: ${a?.state} ice=${a?.pc?.iceConnectionState} | '
|
||
'Gegenseite: ${b?.state} ice=${b?.pc?.iceConnectionState}');
|
||
},
|
||
);
|
||
_log('verbunden nach ${DateTime.now().difference(t0).inMilliseconds} ms');
|
||
}
|
||
|
||
Future<void> _checkAudio(
|
||
WidgetTester tester,
|
||
CallSignalingService voip,
|
||
_Partner 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'),
|
||
))!;
|
||
_log('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);
|
||
}
|
||
|
||
/// Zweiter Teilnehmer: eigener Matrix-Client + VoIP im selben Prozess.
|
||
class _Partner {
|
||
_Partner(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 _PartnerDelegate 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<_Partner> 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 = _PartnerDelegate();
|
||
// 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) =>
|
||
_log('Gegenseite (abgefangen): ${e.toString().split('\n').first}'),
|
||
),
|
||
);
|
||
final voip = zone.run(() => VoIP(client, delegate));
|
||
return _Partner(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 _PartnerDelegate implements WebRTCDelegate {
|
||
CallSession? call;
|
||
|
||
@override
|
||
MediaDevices get mediaDevices => _PartnerMedia(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) {
|
||
_log('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 _PartnerMedia extends MediaDevices {
|
||
_PartnerMedia(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>? _loadAccounts() {
|
||
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 _log(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));
|
||
}
|
||
}
|
||
_log('ZEITÜBERSCHREITUNG: $what');
|
||
onTimeout?.call();
|
||
fail('Zeitüberschreitung: $what');
|
||
}
|