Sync-Ausbau: Favoriten-Fix, Auswahl-Upload, Einzel-Offline, Playlist-Sicherung #5
@@ -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<String> zuPushendeFavoriten({
|
||||||
|
required Set<String> lokaleFavoriten,
|
||||||
|
required Map<String, String> cloudIdVon,
|
||||||
|
required Set<String> amServer,
|
||||||
|
int deckel = maxFavoritenPushes,
|
||||||
|
}) {
|
||||||
|
final offen = <String>[];
|
||||||
|
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<String> lokalZuSetzendeFavoriten({
|
||||||
|
required Set<String> amServer,
|
||||||
|
required Map<String, String> songIdVonCloudId,
|
||||||
|
required Set<String> lokaleFavoriten,
|
||||||
|
}) {
|
||||||
|
final offen = <String>[];
|
||||||
|
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);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user