diff --git a/lib/services/sync_merge.dart b/lib/services/sync_merge.dart new file mode 100644 index 0000000..bd89100 --- /dev/null +++ b/lib/services/sync_merge.dart @@ -0,0 +1,65 @@ +/// Die reinen Entscheidungsfunktionen des Abgleichs — ohne Netz, ohne +/// Datenbank, ohne Plattform-Kanäle. +/// +/// Sie liegen bewusst außerhalb von `SyncService`: was hier steht, lässt sich +/// mit einer Handvoll Mengen prüfen statt mit einem halben Server. +library; + +/// Wie viele Favoriten je Lauf höchstens gepusht werden. +/// +/// Der Rest kommt im nächsten Lauf dran. Die Pushes sind idempotent, ein +/// Teilausfall heilt sich dadurch von selbst. +const int maxFavoritenPushes = 200; + +/// Die cloudIds, die zum Server gepusht werden müssen: `lokal \ server`. +/// +/// [lokaleFavoriten] sind lokale Song-IDs, [cloudIdVon] bildet sie auf ihre +/// cloudId ab. Titel ohne cloudId kennt der Server nicht — sie tauchen in +/// keiner Richtung im Abgleich auf. +List zuPushendeFavoriten({ + required Set lokaleFavoriten, + required Map cloudIdVon, + required Set amServer, + int deckel = maxFavoritenPushes, +}) { + final offen = []; + for (final songId in lokaleFavoriten) { + final cloudId = cloudIdVon[songId]; + if (cloudId == null) continue; + if (amServer.contains(cloudId)) continue; + offen.add(cloudId); + if (offen.length >= deckel) break; + } + return offen; +} + +/// Die lokalen Song-IDs, die aus dem Server-Stand als Favorit dazukommen: +/// `server \ lokal`. +/// +/// Eine cloudId ohne lokalen Titel (Download fehlgeschlagen, noch nicht +/// geladen) wird **übersprungen, nicht gelöscht**: ein Favorit ohne Song wäre +/// über den Join unsichtbar, würde aber weiter mitgeschleppt. +List lokalZuSetzendeFavoriten({ + required Set amServer, + required Map songIdVonCloudId, + required Set lokaleFavoriten, +}) { + final offen = []; + for (final cloudId in amServer) { + final songId = songIdVonCloudId[cloudId]; + if (songId == null) continue; + if (lokaleFavoriten.contains(songId)) continue; + offen.add(songId); + } + return offen; +} + +/// Ob der „Was ist neu"-Bericht fällig ist. +/// +/// `null` heißt Neuinstallation, Abmeldung oder gelöschte App-Daten — dann ist +/// er **nicht** fällig, sonst begrüßt ein frisch eingerichtetes Gerät den +/// Nutzer mit „Willkommen zurück! 325 neue Songs". `sollAutoSync` entscheidet +/// bei `null` bewusst umgekehrt und ist hier **kein** Vorbild. +bool berichtFaellig(DateTime? letzterErfolg, DateTime jetzt) => + letzterErfolg != null && + jetzt.difference(letzterErfolg) >= const Duration(hours: 24); diff --git a/test/services/sync_merge_test.dart b/test/services/sync_merge_test.dart new file mode 100644 index 0000000..3f18805 --- /dev/null +++ b/test/services/sync_merge_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/services/sync_merge.dart'; + +void main() { + group('zuPushendeFavoriten', () { + test('leer gegen leer ergibt nichts', () { + expect( + zuPushendeFavoriten( + lokaleFavoriten: const {}, + cloudIdVon: const {}, + amServer: const {}, + ), + isEmpty, + ); + }); + + test('was der Server noch nicht hat, geht hoch', () { + expect( + zuPushendeFavoriten( + lokaleFavoriten: const {'s1', 's2'}, + cloudIdVon: const {'s1': 'c1', 's2': 'c2'}, + amServer: const {'c1'}, + ), + ['c2'], + ); + }); + + test('Titel ohne cloudId kennt der Server nicht und bleiben liegen', () { + // Lokal-only: der Titel wurde nie hochgeladen, es gibt nichts zu melden. + expect( + zuPushendeFavoriten( + lokaleFavoriten: const {'s1'}, + cloudIdVon: const {}, + amServer: const {}, + ), + isEmpty, + ); + }); + + test('was beidseitig steht, wird nicht erneut gepusht', () { + expect( + zuPushendeFavoriten( + lokaleFavoriten: const {'s1'}, + cloudIdVon: const {'s1': 'c1'}, + amServer: const {'c1'}, + ), + isEmpty, + ); + }); + + test('der Deckel begrenzt einen Lauf', () { + final viele = {for (var i = 0; i < 250; i++) 's$i'}; + final zuordnung = {for (var i = 0; i < 250; i++) 's$i': 'c$i'}; + + final offen = zuPushendeFavoriten( + lokaleFavoriten: viele, + cloudIdVon: zuordnung, + amServer: const {}, + ); + + expect(offen, hasLength(maxFavoritenPushes)); + }); + }); + + group('lokalZuSetzendeFavoriten', () { + test('disjunkte Mengen: der Server-Favorit kommt lokal dazu', () { + expect( + lokalZuSetzendeFavoriten( + amServer: const {'c9'}, + songIdVonCloudId: const {'c9': 's9'}, + lokaleFavoriten: const {}, + ), + ['s9'], + ); + }); + + test('eine lokal unauflösbare cloudId wird übersprungen, nicht gelöscht', () { + // Der Titel ist hier (noch) nicht vorhanden. Ein Favorit ohne Song wäre + // unsichtbar, würde aber ewig mitgeschleppt. + expect( + lokalZuSetzendeFavoriten( + amServer: const {'c9'}, + songIdVonCloudId: const {}, + lokaleFavoriten: const {}, + ), + isEmpty, + ); + }); + + test('was lokal schon Favorit ist, wird nicht noch einmal gesetzt', () { + expect( + lokalZuSetzendeFavoriten( + amServer: const {'c1'}, + songIdVonCloudId: const {'c1': 's1'}, + lokaleFavoriten: const {'s1'}, + ), + isEmpty, + ); + }); + }); + + group('berichtFaellig', () { + final jetzt = DateTime(2026, 8, 27, 12); + + test('ohne vorherigen Erfolg nicht fällig', () { + // Neuinstallation: „Willkommen zurück! 325 neue Songs" wäre Unsinn. + expect(berichtFaellig(null, jetzt), isFalse); + }); + + test('unter 24 Stunden nicht fällig', () { + expect(berichtFaellig(jetzt.subtract(const Duration(hours: 23)), jetzt), + isFalse); + }); + + test('ab 24 Stunden fällig', () { + expect(berichtFaellig(jetzt.subtract(const Duration(hours: 24)), jetzt), + isTrue); + expect(berichtFaellig(jetzt.subtract(const Duration(days: 3)), jetzt), + isTrue); + }); + }); +}