- cloud_screen.dart: Sync-Popup mit Live-Fortschritt („12/325 synchronisiert"), „Im Hintergrund fortsetzen"-Button, Ergebnis-Snackbar nach Abschluss - sync_service.dart: Notification-Kanal auf HIGH + Visibility.public, Foreground-Service starten/stoppen während Sync (Display-aus-Garantie), statische syncNotificationDetails() für Tests - main.dart: Sync-Channel-Importance auf HIGH (überschreibt alte low-Einstellung) - Tests: +7 (syncNotificationDetails HIGH/public, Lifecycle-Unabhängigkeit)
256 lines
9.0 KiB
Dart
256 lines
9.0 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||
import 'package:melo_app/services/sync_service.dart';
|
||
import 'package:melo_app/services/realtime_sync_service.dart';
|
||
|
||
/// SyncService-Tests (Sprint E, MED-3-Review-Fix, v2.55.2).
|
||
///
|
||
/// Abgedeckt:
|
||
/// - `tombstoneAnwenden`-Matrix (null-letzterSync / null-deletedAt /
|
||
/// älter → false / neuer → true / Gleichstand / ungültiger Timestamp)
|
||
/// - `SyncBericht` (hatAenderungen, zusammenfassung, details)
|
||
/// - `songAusServerMap` (MED-2: Server-Listeneintrag → lokaler Song-DB-
|
||
/// Eintrag mit cloud_id + Titeldaten)
|
||
/// - v2.55.2: `syncNotificationDetails` (Importance.high,
|
||
/// Priority.high, Visibility.public für Sperrbildschirm)
|
||
/// - v2.55.2: Sync-Service von Lifecycle unabhängig
|
||
/// (RealtimeSyncService.pausiere() ≠ SyncService stoppen)
|
||
void main() {
|
||
TestWidgetsFlutterBinding.ensureInitialized();
|
||
|
||
final letzterSync = DateTime(2026, 8, 4, 12, 0, 0);
|
||
|
||
group('tombstoneAnwenden (Tombstone-Race-Heuristik, MED-1)', () {
|
||
test('letzterSync null (Erst-Sync) → anwenden', () {
|
||
expect(
|
||
SyncService.tombstoneAnwenden(
|
||
letzterSync: null, deletedAt: '2026-08-05T10:00:00'),
|
||
isTrue,
|
||
);
|
||
});
|
||
|
||
test('deletedAt null (Alt-Tombstone ohne Zeitstempel) → anwenden', () {
|
||
expect(
|
||
SyncService.tombstoneAnwenden(
|
||
letzterSync: letzterSync, deletedAt: null),
|
||
isTrue,
|
||
);
|
||
});
|
||
|
||
test('deletedAt älter als letzterSync → NICHT anwenden', () {
|
||
expect(
|
||
SyncService.tombstoneAnwenden(
|
||
letzterSync: letzterSync, deletedAt: '2026-08-03T10:00:00'),
|
||
isFalse,
|
||
);
|
||
});
|
||
|
||
test('deletedAt neuer als letzterSync → anwenden', () {
|
||
expect(
|
||
SyncService.tombstoneAnwenden(
|
||
letzterSync: letzterSync, deletedAt: '2026-08-05T10:00:00'),
|
||
isTrue,
|
||
);
|
||
});
|
||
|
||
test('deletedAt == letzterSync (Gleichstand) → NICHT anwenden', () {
|
||
expect(
|
||
SyncService.tombstoneAnwenden(
|
||
letzterSync: letzterSync,
|
||
deletedAt: '2026-08-04T12:00:00'),
|
||
isFalse,
|
||
);
|
||
});
|
||
|
||
test('ungültiger deletedAt-String → anwenden (d == null → sicher)', () {
|
||
expect(
|
||
SyncService.tombstoneAnwenden(
|
||
letzterSync: letzterSync, deletedAt: 'kein-timestamp'),
|
||
isTrue,
|
||
);
|
||
});
|
||
});
|
||
|
||
group('SyncBericht (Konflikt-Report, Sprint E)', () {
|
||
test('hatAenderungen: false bei leerem Bericht', () {
|
||
const bericht = SyncBericht(
|
||
neueSongs: 0, geloeschteSongs: 0, favoritenGeaendert: 0);
|
||
expect(bericht.hatAenderungen, isFalse);
|
||
});
|
||
|
||
test('hatAenderungen: true sobald eine Kategorie > 0', () {
|
||
const bericht = SyncBericht(
|
||
neueSongs: 1, geloeschteSongs: 0, favoritenGeaendert: 0);
|
||
expect(bericht.hatAenderungen, isTrue);
|
||
});
|
||
|
||
test('zusammenfassung kombiniert alle Kategorien', () {
|
||
const bericht = SyncBericht(
|
||
neueSongs: 8, geloeschteSongs: 3, favoritenGeaendert: 5);
|
||
expect(bericht.zusammenfassung,
|
||
'+8 neue Songs · −3 gelöscht · ⭐5 Favoriten geändert');
|
||
});
|
||
|
||
test('zusammenfassung leer bei 0 Änderungen', () {
|
||
const bericht = SyncBericht(
|
||
neueSongs: 0, geloeschteSongs: 0, favoritenGeaendert: 0);
|
||
expect(bericht.zusammenfassung, isEmpty);
|
||
});
|
||
|
||
test('details werden durchgereicht', () {
|
||
const bericht = SyncBericht(
|
||
neueSongs: 1,
|
||
geloeschteSongs: 1,
|
||
favoritenGeaendert: 0,
|
||
details: ['+ Neuer Song: A', '− Gelöscht: B'],
|
||
);
|
||
expect(bericht.details, hasLength(2));
|
||
expect(bericht.details.first, '+ Neuer Song: A');
|
||
});
|
||
|
||
test('nachLangerPause: letzterSyncVorher null (Erst-Sync) → true', () {
|
||
const bericht = SyncBericht(
|
||
neueSongs: 1, geloeschteSongs: 0, favoritenGeaendert: 0);
|
||
expect(bericht.nachLangerPause(jetzt: DateTime(2026, 8, 5, 12)), isTrue);
|
||
});
|
||
|
||
test('nachLangerPause: < 24h seit letztem Sync → false', () {
|
||
final bericht = SyncBericht(
|
||
neueSongs: 1,
|
||
geloeschteSongs: 0,
|
||
favoritenGeaendert: 0,
|
||
letzterSyncVorher: DateTime(2026, 8, 5, 11, 0),
|
||
);
|
||
expect(bericht.nachLangerPause(jetzt: DateTime(2026, 8, 5, 12)), isFalse);
|
||
});
|
||
|
||
test('nachLangerPause: > 24h seit letztem Sync → true', () {
|
||
final bericht = SyncBericht(
|
||
neueSongs: 1,
|
||
geloeschteSongs: 0,
|
||
favoritenGeaendert: 0,
|
||
letzterSyncVorher: DateTime(2026, 8, 3, 10, 0),
|
||
);
|
||
expect(bericht.nachLangerPause(jetzt: DateTime(2026, 8, 5, 12)), isTrue);
|
||
});
|
||
});
|
||
|
||
group('songAusServerMap (MED-2: Server-Listeneintrag → lokaler Song)', () {
|
||
test('mappt cloud_id + Titeldaten + Flags', () {
|
||
final song = SyncService.songAusServerMap({
|
||
'id': 'srv-42',
|
||
'title': 'Testtitel',
|
||
'artist': ' Künstler ',
|
||
'duration': 217,
|
||
'size': 5242880,
|
||
'istKorrupt': true,
|
||
}, '/data/music/Testtitel');
|
||
expect(song.cloudId, 'srv-42');
|
||
expect(song.titel, 'Testtitel');
|
||
expect(song.kuenstler, 'Künstler'); // getrimmt
|
||
expect(song.dauerSekunden, 217);
|
||
expect(song.groesseBytes, 5242880);
|
||
expect(song.dateiPfad, '/data/music/Testtitel');
|
||
expect(song.downloadQuelle, 'cloud');
|
||
expect(song.istHeruntergeladen, isTrue);
|
||
expect(song.istKorrupt, isTrue);
|
||
expect(song.ytUrl, isNull); // v2.52-F2: Server bleibt YT-Link-Quelle
|
||
});
|
||
|
||
test('fehlende Felder → Defaults (kein Crash)', () {
|
||
final song = SyncService.songAusServerMap({'id': 'srv-1'}, '/x');
|
||
expect(song.titel, 'unknown');
|
||
expect(song.kuenstler, '');
|
||
expect(song.dauerSekunden, 0);
|
||
expect(song.groesseBytes, 0);
|
||
expect(song.istKorrupt, isFalse);
|
||
expect(song.cloudId, 'srv-1');
|
||
});
|
||
|
||
test('duration/size als String → robust geparst', () {
|
||
final song = SyncService.songAusServerMap({
|
||
'id': 'srv-2',
|
||
'title': 'A',
|
||
'duration': '180',
|
||
'size': '1024',
|
||
}, '/x');
|
||
expect(song.dauerSekunden, 180);
|
||
expect(song.groesseBytes, 1024);
|
||
});
|
||
});
|
||
|
||
// ─── v2.55.2: Notification-Konfiguration ───
|
||
|
||
group('syncNotificationDetails (v2.55.2: HIGH + public)', () {
|
||
test('ongoing=true → Importance.high, Priority.high, showProgress', () {
|
||
final details =
|
||
SyncService.syncNotificationDetails(ongoing: true, progress: 5, maxProgress: 25);
|
||
|
||
expect(details.importance, Importance.high);
|
||
expect(details.priority, Priority.high);
|
||
expect(details.visibility, NotificationVisibility.public);
|
||
expect(details.showProgress, isTrue);
|
||
expect(details.ongoing, isTrue);
|
||
expect(details.autoCancel, isFalse);
|
||
expect(details.progress, 5);
|
||
expect(details.maxProgress, 25);
|
||
expect(details.onlyAlertOnce, isTrue);
|
||
});
|
||
|
||
test('ongoing=false → autoCancel, kein Progress', () {
|
||
final details = SyncService.syncNotificationDetails(ongoing: false);
|
||
|
||
expect(details.importance, Importance.high);
|
||
expect(details.priority, Priority.high);
|
||
expect(details.visibility, NotificationVisibility.public);
|
||
expect(details.showProgress, isFalse);
|
||
expect(details.ongoing, isFalse);
|
||
expect(details.autoCancel, isTrue);
|
||
expect(details.progress, 0);
|
||
expect(details.maxProgress, 0);
|
||
});
|
||
|
||
test('maxProgress=0 bei ongoing=true → default 1 (Division durch 0)', () {
|
||
final details =
|
||
SyncService.syncNotificationDetails(ongoing: true, progress: 0, maxProgress: 0);
|
||
|
||
// maxProgress muss > 0 sein, sonst crasht die Notification-API
|
||
expect(details.maxProgress, 1);
|
||
});
|
||
|
||
test('channelId ist de.baka.melo.sync', () {
|
||
final details = SyncService.syncNotificationDetails(ongoing: false);
|
||
expect(details.channelId, 'de.baka.melo.sync');
|
||
});
|
||
});
|
||
|
||
// ─── v2.55.2: Lifecycle-Unabhängigkeit ───
|
||
|
||
group('SyncService unabhängig von RealtimeSyncService-Lifecycle', () {
|
||
test('laeuftGlobal wird NICHT durch pausiere() zurückgesetzt', () {
|
||
// SyncService.laeuftGlobal startet als false.
|
||
// Der Lifecycle (RealtimeSyncService.pausiere) darf diesen Zustand
|
||
// NICHT beeinflussen — es sind getrennte Systeme.
|
||
expect(SyncService.laeuftGlobal, isFalse);
|
||
|
||
// Lifecycle: App geht in den Hintergrund
|
||
RealtimeSyncService().pausiere();
|
||
|
||
// SyncService muss unabhängig bleiben
|
||
expect(SyncService.laeuftGlobal, isFalse);
|
||
});
|
||
|
||
test('laeuftNotifier wird NICHT durch pausiere() verändert', () {
|
||
final vorher = SyncService.laeuftNotifier.value;
|
||
RealtimeSyncService().pausiere();
|
||
expect(SyncService.laeuftNotifier.value, vorher);
|
||
});
|
||
|
||
test('syncBenachrichtigungGetippt wird NICHT durch pausiere() verändert', () {
|
||
final vorher = SyncService.syncBenachrichtigungGetippt.value;
|
||
RealtimeSyncService().pausiere();
|
||
expect(SyncService.syncBenachrichtigungGetippt.value, vorher);
|
||
});
|
||
});
|
||
}
|