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); }); }); }