Erfolgs-Flag, Zeitstempel-Snapshot und Zaehler fuer den Sync-Bericht
This commit is contained in:
@@ -148,6 +148,22 @@ class UploadErgebnis {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Was seit dem letzten erfolgreichen Abgleich passiert ist — der Inhalt des
|
||||||
|
/// „Willkommen zurück"-Dialogs.
|
||||||
|
class SyncBericht {
|
||||||
|
const SyncBericht({
|
||||||
|
required this.neueSongs,
|
||||||
|
required this.geloeschte,
|
||||||
|
required this.favoriten,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int neueSongs;
|
||||||
|
final int geloeschte;
|
||||||
|
final int favoriten;
|
||||||
|
|
||||||
|
bool get istLeer => neueSongs == 0 && geloeschte == 0 && favoriten == 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// Hält Gerät und Server auf demselben Stand: lädt neue Server-Titel herunter,
|
/// Hält Gerät und Server auf demselben Stand: lädt neue Server-Titel herunter,
|
||||||
/// bringt eigene Dateien hoch, zieht Löschungen nach und meldet Favoriten und
|
/// bringt eigene Dateien hoch, zieht Löschungen nach und meldet Favoriten und
|
||||||
/// Wiedergaben. Alle Geräte am selben Konto sehen dadurch dasselbe.
|
/// Wiedergaben. Alle Geräte am selben Konto sehen dadurch dasselbe.
|
||||||
@@ -160,6 +176,7 @@ class SyncService extends ChangeNotifier {
|
|||||||
}) : _musikOrdner = musikOrdner ?? _standardMusikOrdner;
|
}) : _musikOrdner = musikOrdner ?? _standardMusikOrdner;
|
||||||
|
|
||||||
static const _letzterLaufKey = 'cloud_sync_letzter_lauf';
|
static const _letzterLaufKey = 'cloud_sync_letzter_lauf';
|
||||||
|
static const _letzterErfolgKey = 'cloud_sync_letzter_erfolg';
|
||||||
static const _verlaufStandKey = 'cloud_sync_verlauf_stand';
|
static const _verlaufStandKey = 'cloud_sync_verlauf_stand';
|
||||||
static const _uuid = Uuid();
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
@@ -174,6 +191,8 @@ class SyncService extends ChangeNotifier {
|
|||||||
String? _fehler;
|
String? _fehler;
|
||||||
String? _status;
|
String? _status;
|
||||||
DateTime? _letzterLauf;
|
DateTime? _letzterLauf;
|
||||||
|
DateTime? _letzterErfolg;
|
||||||
|
SyncBericht? _bericht;
|
||||||
bool _abbruchGewuenscht = false;
|
bool _abbruchGewuenscht = false;
|
||||||
|
|
||||||
bool get laeuft => _laeuft;
|
bool get laeuft => _laeuft;
|
||||||
@@ -183,10 +202,23 @@ class SyncService extends ChangeNotifier {
|
|||||||
String? get status => _status;
|
String? get status => _status;
|
||||||
DateTime? get letzterLauf => _letzterLauf;
|
DateTime? get letzterLauf => _letzterLauf;
|
||||||
|
|
||||||
|
/// Der fällige Bericht, oder `null`. Wird von der Oberfläche genau einmal
|
||||||
|
/// abgeholt und dann mit [berichtGesehen] quittiert.
|
||||||
|
SyncBericht? get bericht => _bericht;
|
||||||
|
|
||||||
|
void berichtGesehen() {
|
||||||
|
_bericht = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> laden() async {
|
Future<void> laden() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final ms = prefs.getInt(_letzterLaufKey);
|
final ms = prefs.getInt(_letzterLaufKey);
|
||||||
if (ms != null) _letzterLauf = DateTime.fromMillisecondsSinceEpoch(ms);
|
if (ms != null) _letzterLauf = DateTime.fromMillisecondsSinceEpoch(ms);
|
||||||
|
final erfolg = prefs.getInt(_letzterErfolgKey);
|
||||||
|
if (erfolg != null) {
|
||||||
|
_letzterErfolg = DateTime.fromMillisecondsSinceEpoch(erfolg);
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +246,14 @@ class SyncService extends ChangeNotifier {
|
|||||||
_gesamt = 0;
|
_gesamt = 0;
|
||||||
_melde('Vergleiche mit dem Server …');
|
_melde('Vergleiche mit dem Server …');
|
||||||
|
|
||||||
|
// Der Zeitstempel ist der Stand VOR dem Listen: was während des Laufs am
|
||||||
|
// Server passiert, muss beim nächsten Mal noch drankommen (v2-Lektion,
|
||||||
|
// Tombstone-Race).
|
||||||
|
final laufBeginn = DateTime.now();
|
||||||
|
var vollstaendig = true;
|
||||||
|
var neueSongs = 0;
|
||||||
|
var geloeschte = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final amServer = await cloud.liste();
|
final amServer = await cloud.liste();
|
||||||
final plan = planeSync(
|
final plan = planeSync(
|
||||||
@@ -233,18 +273,38 @@ class SyncService extends ChangeNotifier {
|
|||||||
'$bestand Titeln würden am Server gelöscht. Das sieht nach einem '
|
'$bestand Titeln würden am Server gelöscht. Das sieht nach einem '
|
||||||
'Fehler aus (z. B. Speicherkarte nicht eingehängt) — es wurde '
|
'Fehler aus (z. B. Speicherkarte nicht eingehängt) — es wurde '
|
||||||
'nichts gelöscht.';
|
'nichts gelöscht.';
|
||||||
|
vollstaendig = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jede Phase für sich: fällt eine aus, laufen die übrigen weiter, und
|
||||||
|
// der Erfolgs-Zeitstempel bleibt stehen.
|
||||||
|
geloeschte = plan.lokalLoeschen.length;
|
||||||
await _ziehLoeschungenNach(plan.lokalLoeschen);
|
await _ziehLoeschungenNach(plan.lokalLoeschen);
|
||||||
if (!bremse) await _meldeLoeschungen(plan.serverLoeschen);
|
if (!bremse && !await _meldeLoeschungen(plan.serverLoeschen)) {
|
||||||
await _ladeHerunter(plan.herunterladen);
|
vollstaendig = false;
|
||||||
await _ladeHoch(plan.hochladen);
|
}
|
||||||
await _gleicheFavoritenAb();
|
neueSongs = await _ladeHerunter(plan.herunterladen);
|
||||||
|
if (!await _ladeHoch(plan.hochladen)) vollstaendig = false;
|
||||||
|
if (!await _gleicheFavoritenAb()) vollstaendig = false;
|
||||||
await _meldeVerlauf();
|
await _meldeVerlauf();
|
||||||
|
|
||||||
_letzterLauf = DateTime.now();
|
_letzterLauf = laufBeginn;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setInt(_letzterLaufKey, _letzterLauf!.millisecondsSinceEpoch);
|
await prefs.setInt(_letzterLaufKey, laufBeginn.millisecondsSinceEpoch);
|
||||||
|
|
||||||
|
if (vollstaendig) {
|
||||||
|
// „Erfolgreich" heißt: keine Phase hat einen Fehler geschluckt.
|
||||||
|
if (berichtFaellig(_letzterErfolg, laufBeginn)) {
|
||||||
|
_bericht = SyncBericht(
|
||||||
|
neueSongs: neueSongs,
|
||||||
|
geloeschte: geloeschte,
|
||||||
|
favoriten: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_letzterErfolg = laufBeginn;
|
||||||
|
await prefs.setInt(
|
||||||
|
_letzterErfolgKey, laufBeginn.millisecondsSinceEpoch);
|
||||||
|
}
|
||||||
} on CloudException catch (e) {
|
} on CloudException catch (e) {
|
||||||
_fehler = e.message;
|
_fehler = e.message;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -363,7 +423,13 @@ class SyncService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _meldeLoeschungen(List<Song> songs) async {
|
/// Meldet die hier getombsteten Titel am Server.
|
||||||
|
///
|
||||||
|
/// Gibt `false` zurück, sobald eine Meldung geschluckt wurde: der Lauf geht
|
||||||
|
/// weiter, gilt aber nicht mehr als erfolgreich — sonst rückte der
|
||||||
|
/// 24-h-Zeitstempel des Berichts vor, obwohl eine Phase ausgefallen ist.
|
||||||
|
Future<bool> _meldeLoeschungen(List<Song> songs) async {
|
||||||
|
var vollstaendig = true;
|
||||||
for (final song in songs) {
|
for (final song in songs) {
|
||||||
_melde('Melde Löschung von „${song.title}“ …');
|
_melde('Melde Löschung von „${song.title}“ …');
|
||||||
try {
|
try {
|
||||||
@@ -371,14 +437,19 @@ class SyncService extends ChangeNotifier {
|
|||||||
} on CloudException catch (e) {
|
} on CloudException catch (e) {
|
||||||
// Eine abgelehnte Löschung darf den Lauf nicht beenden.
|
// Eine abgelehnte Löschung darf den Lauf nicht beenden.
|
||||||
debugPrint('Löschung „${song.title}“ übersprungen: ${e.message}');
|
debugPrint('Löschung „${song.title}“ übersprungen: ${e.message}');
|
||||||
|
vollstaendig = false;
|
||||||
}
|
}
|
||||||
_erledigt++;
|
_erledigt++;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
return vollstaendig;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _ladeHerunter(List<CloudSong> songs) async {
|
/// Gibt zurück, wie viele Titel wirklich neu dazugekommen sind — die Zahl
|
||||||
if (songs.isEmpty) return;
|
/// im „Was ist neu"-Bericht.
|
||||||
|
Future<int> _ladeHerunter(List<CloudSong> songs) async {
|
||||||
|
if (songs.isEmpty) return 0;
|
||||||
|
var neu = 0;
|
||||||
final ordner = await _musikOrdner();
|
final ordner = await _musikOrdner();
|
||||||
for (final cloudSong in songs) {
|
for (final cloudSong in songs) {
|
||||||
_melde('Lade „${cloudSong.titel}“ …');
|
_melde('Lade „${cloudSong.titel}“ …');
|
||||||
@@ -418,12 +489,21 @@ class SyncService extends ChangeNotifier {
|
|||||||
cloudId: Value(cloudSong.id),
|
cloudId: Value(cloudSong.id),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
neu++;
|
||||||
_erledigt++;
|
_erledigt++;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
return neu;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _ladeHoch(List<Song> songs) async {
|
/// Lädt alle Titel ohne cloudId hoch.
|
||||||
|
///
|
||||||
|
/// Gibt `false` zurück, sobald ein Upload an einem Fehler oder einer
|
||||||
|
/// Zeitüberschreitung hängenblieb. Eine **fehlende Datei** zählt bewusst
|
||||||
|
/// nicht dazu: dort ist nichts schiefgegangen, der Titel ist weg und der
|
||||||
|
/// nächste Scan tombstoned ihn.
|
||||||
|
Future<bool> _ladeHoch(List<Song> songs) async {
|
||||||
|
var vollstaendig = true;
|
||||||
for (final song in songs) {
|
for (final song in songs) {
|
||||||
final datei = File(song.path);
|
final datei = File(song.path);
|
||||||
if (!await datei.exists()) {
|
if (!await datei.exists()) {
|
||||||
@@ -436,19 +516,27 @@ class SyncService extends ChangeNotifier {
|
|||||||
datei,
|
datei,
|
||||||
dateiname: '${_sichererDateiname(song.title)}${p.extension(song.path)}',
|
dateiname: '${_sichererDateiname(song.title)}${p.extension(song.path)}',
|
||||||
);
|
);
|
||||||
if (cloudId != null) await db.setCloudId(song.id, cloudId);
|
if (cloudId != null) {
|
||||||
|
await db.setCloudId(song.id, cloudId);
|
||||||
|
} else {
|
||||||
|
// 200 ohne Server-ID: der Titel ist oben nicht angekommen.
|
||||||
|
vollstaendig = false;
|
||||||
|
}
|
||||||
} on CloudException catch (e) {
|
} on CloudException catch (e) {
|
||||||
// Eine zu große oder abgelehnte Datei darf den Lauf nicht beenden.
|
// Eine zu große oder abgelehnte Datei darf den Lauf nicht beenden.
|
||||||
debugPrint('Upload „${song.title}“ übersprungen: ${e.message}');
|
debugPrint('Upload „${song.title}“ übersprungen: ${e.message}');
|
||||||
|
vollstaendig = false;
|
||||||
} on TimeoutException {
|
} on TimeoutException {
|
||||||
// Der 120-s-Timeout (melo_cloud_service.dart:180) wirft
|
// Der 120-s-Timeout (melo_cloud_service.dart:180) wirft
|
||||||
// TimeoutException, nicht CloudException — ohne diesen Zweig riss ein
|
// TimeoutException, nicht CloudException — ohne diesen Zweig riss ein
|
||||||
// einziger hängender Upload den ganzen Lauf ab.
|
// einziger hängender Upload den ganzen Lauf ab.
|
||||||
debugPrint('Upload „${song.title}“: Zeitüberschreitung');
|
debugPrint('Upload „${song.title}“: Zeitüberschreitung');
|
||||||
|
vollstaendig = false;
|
||||||
}
|
}
|
||||||
_erledigt++;
|
_erledigt++;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
return vollstaendig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Additiver Favoriten-Abgleich: gleicht in **beide** Richtungen an,
|
/// Additiver Favoriten-Abgleich: gleicht in **beide** Richtungen an,
|
||||||
|
|||||||
@@ -634,4 +634,193 @@ void main() {
|
|||||||
expect(sync.fehler, contains('Abgleich'));
|
expect(sync.fehler, contains('Abgleich'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('Sync-Bericht', () {
|
||||||
|
test('beim allerersten Lauf gibt es keinen Bericht', () async {
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
if (anfrage.url.path.endsWith('/list')) {
|
||||||
|
return http.Response(jsonEncode({'songs': []}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.synchronisiere();
|
||||||
|
|
||||||
|
// letzterErfolg war null: „Willkommen zurück!" auf einem frisch
|
||||||
|
// eingerichteten Gerät wäre Unsinn.
|
||||||
|
expect(sync.bericht, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nach mehr als 24 Stunden kommt der Bericht mit Zählern', () async {
|
||||||
|
final vorgestern = DateTime.now().subtract(const Duration(days: 2));
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'cloud_sync_letzter_erfolg': vorgestern.millisecondsSinceEpoch,
|
||||||
|
});
|
||||||
|
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
final pfad = anfrage.url.path;
|
||||||
|
if (pfad.endsWith('/list')) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'songs': [
|
||||||
|
{'id': 'c1', 'title': 'Neu', 'artist': 'X', 'duration': 100}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pfad.contains('/download/')) {
|
||||||
|
return http.Response.bytes([1], 200,
|
||||||
|
headers: {'content-type': 'audio/mpeg'});
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/favorites')) {
|
||||||
|
return http.Response(jsonEncode({'favorites': []}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
await sync.laden();
|
||||||
|
|
||||||
|
await sync.synchronisiere();
|
||||||
|
|
||||||
|
expect(sync.bericht, isNotNull);
|
||||||
|
expect(sync.bericht!.neueSongs, 1);
|
||||||
|
sync.berichtGesehen();
|
||||||
|
expect(sync.bericht, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine ausgefallene Phase verschiebt den Erfolgs-Zeitstempel nicht',
|
||||||
|
() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
final pfad = anfrage.url.path;
|
||||||
|
if (pfad.endsWith('/list')) {
|
||||||
|
return http.Response(jsonEncode({'songs': []}), 200);
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/favorites')) {
|
||||||
|
// Fehler im 200er-Körper: die Favoriten-Phase fällt aus.
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'status': 'error', 'error': 'kaputt'}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.synchronisiere();
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(prefs.getInt('cloud_sync_letzter_erfolg'), isNull);
|
||||||
|
// Die Drossel läuft trotzdem weiter — sonst rennt der Sync bei jedem
|
||||||
|
// Tab-Wechsel neu los.
|
||||||
|
expect(sync.letzterLauf, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine gescheiterte Löschmeldung verschiebt den Erfolgs-Zeitstempel '
|
||||||
|
'nicht', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
await db.upsertSongs([
|
||||||
|
SongsCompanion.insert(
|
||||||
|
id: 'lokal-1',
|
||||||
|
path: '${tempDir.path}/weg.mp3',
|
||||||
|
title: 'Weg',
|
||||||
|
dateAddedMs: 0,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
deleted: const Value(true),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
await db.setCloudId('lokal-1', 'c5');
|
||||||
|
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
final pfad = anfrage.url.path;
|
||||||
|
if (pfad.endsWith('/list')) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'songs': [
|
||||||
|
{'id': 'c5', 'title': 'Weg'}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/delete')) {
|
||||||
|
// _meldeLoeschungen schluckt die CloudException — ohne Rückgabe
|
||||||
|
// bis zum Flag hätte der Lauf trotzdem als erfolgreich gegolten.
|
||||||
|
return http.Response(jsonEncode({'error': 'kaputt'}), 500);
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/favorites')) {
|
||||||
|
return http.Response(jsonEncode({'favorites': []}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.synchronisiere();
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(prefs.getInt('cloud_sync_letzter_erfolg'), isNull);
|
||||||
|
expect(sync.letzterLauf, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein gescheiterter Upload verschiebt den Erfolgs-Zeitstempel nicht',
|
||||||
|
() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final datei = File('${tempDir.path}/zu-gross.mp3');
|
||||||
|
await datei.writeAsBytes([1]);
|
||||||
|
await db.upsertSongs([
|
||||||
|
SongsCompanion.insert(
|
||||||
|
id: 'lokal-1',
|
||||||
|
path: datei.path,
|
||||||
|
title: 'Zu groß',
|
||||||
|
dateAddedMs: 0,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
final pfad = anfrage.url.path;
|
||||||
|
if (pfad.endsWith('/list')) {
|
||||||
|
return http.Response(jsonEncode({'songs': []}), 200);
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/upload')) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'error': 'Datei zu groß (max 50 MB)'}), 200);
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/favorites')) {
|
||||||
|
return http.Response(jsonEncode({'favorites': []}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.synchronisiere();
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(prefs.getInt('cloud_sync_letzter_erfolg'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('der Zeitstempel ist der Stand VOR dem Listen', () async {
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
if (anfrage.url.path.endsWith('/list')) {
|
||||||
|
// Während des Laufs vergeht Zeit — der Zeitstempel darf nicht
|
||||||
|
// danach genommen werden, sonst fallen zwischenzeitliche
|
||||||
|
// Änderungen durchs Raster (Tombstone-Race, v2-Lektion).
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
return http.Response(jsonEncode({'songs': []}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final vorher = DateTime.now();
|
||||||
|
await sync.synchronisiere();
|
||||||
|
final nachher = DateTime.now();
|
||||||
|
|
||||||
|
expect(sync.letzterLauf!.isBefore(nachher), isTrue);
|
||||||
|
expect(
|
||||||
|
sync.letzterLauf!
|
||||||
|
.isAfter(vorher.subtract(const Duration(milliseconds: 1))),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
nachher.difference(sync.letzterLauf!) >=
|
||||||
|
const Duration(milliseconds: 50),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user