fix(calls): Bildschirm teilen sichtbar, Verbindung trotz SDK-Wettlauf, Sprech-Ring; Trenner in Gruppen
Bernds Test 2026-10-08 (Streamen ging weder am PC noch am Handy, Ton kam nicht an, Trenner blieb manchmal stehen) – mit neuem Live-Anruftest (zwei Testkonten im selben Prozess) nachgestellt und behoben: - Bildschirm teilen: Die Stromzuordnung rief ein SDK-getFeeds() auf, das es nicht gibt; der Fehler wurde geschluckt → Übertragungen wurden nie angezeigt und beim Sender nie als aktiv erkannt. Jetzt typisierte Zugriffe (remote/localScreenSharingStream, 'stopped' beachtet). - Verbindungsaufbau: matrix-SDK verwirft Kandidaten, die vor der fertig verarbeiteten Einladung bzw. während des Annehmens eintreffen; unter Windows wirft getRemoteDescription zudem statt null zu liefern. Pyramid puffert alle Kandidaten der Gegenseite und reicht sie direkt an die Verbindung nach. Test: vorher nie verbunden, jetzt ~0,5 s (angerufen) bzw. ~2,7 s (anrufend), Ton beide Richtungen, Teilen beide Richtungen. - SDK-Aufrufe in eigener Zone: harmlose Plugin-Fehler (Lautsprecher unter Windows) werden protokolliert statt durchzuschlagen; Ergebnisse/Fehler per Completer über die Zonengrenze (sonst ewiges Warten). - Sprech-Ring auch bei 1:1-Anrufen (Pegel aus WebRTC-Statistiken) – zeigt, ob das eigene Mikrofon Ton liefert und ob die Gegenseite ankommt; großes Bild springt nicht mehr bei jedem Sprechwechsel. - „Neue Nachrichten“ mitten in einer Gruppe (mehrere Nachrichten desselben Absenders) blieb bis zum erneuten Öffnen stehen: Inline-Trenner jetzt nur mit Ungelesenem, blendet aus und trägt den Sprungschlüssel. Live-Test. - „Anruf läuft“-Leiste nicht schon beim Klingeln. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5485b6d449
commit
8074928505
@@ -0,0 +1,478 @@
|
||||
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);
|
||||
|
||||
// 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');
|
||||
}
|
||||
Reference in New Issue
Block a user