diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 2248e2e..0b0c484 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -877,7 +877,12 @@ class EstadoRadio extends ChangeNotifier { final favRaw = data['favoritos'] as List? ?? []; for (final raw in favRaw) { final emisora = Emisora.fromMap(Map.from(raw as Map)); - await favoritos.agregar(emisora); + // `restaurarFavorito`, NO `agregar`: `agregar` es la primitiva de + // «marcar como favorita» y fuerza `sin_asignar` + un `orden` al final, + // que es justo lo que la copia trae y hay que conservar. Con `agregar` + // los grupos restaurados arriba volvían como cascarones vacíos y todas + // las emisoras aterrizaban en «Sin asignar». + await favoritos.restaurarFavorito(emisora); } // ── Emisoras custom ─────────────────────────────────────────────────── diff --git a/lib/main.dart b/lib/main.dart index 17e725a..8b88469 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; import 'estado/estado_entitlement.dart'; import 'servicios/arranque_audio.dart'; +import 'servicios/contexto_reproduccion.dart'; import 'servicios/musica_local_auto.dart'; import 'servicios/navegacion_auto.dart'; import 'servicios/servicio_audio.dart'; @@ -196,6 +197,13 @@ Future main() async { handler, leerEqActivoPersistido: ecualizador.leerActivo, guardarEqActivoPersistido: ecualizador.guardarActivo, + // Skip context («in which list am I»). Bound here, on the audio + // bootstrap path of EVERY engine, precisely because the headless + // Android Auto engine builds no widget tree and therefore no + // `EstadoRadio`: a context only the phone UI could write would be a + // context the car could never have. + leerContextoSalto: contextoSaltoPersistido, + guardarContextoSalto: guardarContextoSalto, ); // The handler is the only thing this app ever tears down // (`onTaskRemoved`), so the asyncError subscription's `cancel` travels diff --git a/lib/servicios/contexto_reproduccion.dart b/lib/servicios/contexto_reproduccion.dart new file mode 100644 index 0000000..9c7bce4 --- /dev/null +++ b/lib/servicios/contexto_reproduccion.dart @@ -0,0 +1,278 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../modelos/emisora.dart'; +import '../modelos/grupo_favoritos.dart'; + +/// The skip context's persistence key. +/// +/// Same headless-safe shape as `emisoras_destacadas.dart`: this file imports +/// nothing but `shared_preferences` and the models, never `EstadoRadio` nor +/// anything that drags a `ChangeNotifier` graph in. Android Auto starts the +/// engine WITHOUT an Activity, so there is no widget tree and `EstadoRadio` is +/// never constructed there — a context only that class could write would be a +/// context the car can never have. +/// +/// `contexto_reproduccion_test.dart` pins the literal so a rename fails loudly +/// instead of silently leaving every driver context-less after an update. +const claveContextoSalto = 'contexto_salto_v1'; + +/// Which LIST the driver is walking with the car's previous/next buttons. +/// +/// The type is the durable part; the members are not. A group's contents +/// change between sessions (the phone renames it, empties it, deletes it), so +/// remembering the members would be remembering something that expires — +/// [resolverListaContexto] re-resolves against the LIVE lists every time. +enum TipoContextoSalto { + /// One favourites group. The only type that carries [ContextoSalto.grupoFavoritosId]. + grupoFavoritos, + favoritos, + misEmisoras, + + /// The `populares` catalogue snapshot. + todas, + + /// The free tier's curated set (`emisorasDestacadas`). The ONLY type that + /// carries [ContextoSalto.uuidsOrdenados] — see that field. + destacadas, +} + +/// The remembered playback context: the smallest thing that still identifies +/// the list on the other side of a process restart. +class ContextoSalto { + /// One favourites group, named by its stable id. + const ContextoSalto.grupo(String grupoId) + : tipo = TipoContextoSalto.grupoFavoritos, + grupoFavoritosId = grupoId, + uuidsOrdenados = const []; + + const ContextoSalto.favoritos() + : tipo = TipoContextoSalto.favoritos, + grupoFavoritosId = null, + uuidsOrdenados = const []; + + const ContextoSalto.misEmisoras() + : tipo = TipoContextoSalto.misEmisoras, + grupoFavoritosId = null, + uuidsOrdenados = const []; + + const ContextoSalto.todas() + : tipo = TipoContextoSalto.todas, + grupoFavoritosId = null, + uuidsOrdenados = const []; + + /// The free tier's set, FROZEN in [uuids] order. + const ContextoSalto.destacadas(List uuids) + : tipo = TipoContextoSalto.destacadas, + grupoFavoritosId = null, + uuidsOrdenados = uuids; + + final TipoContextoSalto tipo; + + /// Set only for [TipoContextoSalto.grupoFavoritos]. + final String? grupoFavoritosId; + + /// The frozen order, set only for [TipoContextoSalto.destacadas]. + /// + /// Every other type resolves against a list that HAS a stable, user-owned + /// order (the favourites' `orden` column, the custom-stations file, the + /// catalogue snapshot), so freezing it would only mean ignoring a reorder + /// the user just made on the phone. The free set is the exception: + /// `resolverEmisorasDestacadas` rebuilds it as `[última reproducida, + /// ...curadas]`, so it REORDERS ITSELF as the driver skips, and `previous` + /// stops being the inverse of `next`. Freezing that order is the fix. + final List uuidsOrdenados; + + Map aMapa() => { + 'tipo': tipo.name, + if (grupoFavoritosId != null) 'grupoId': grupoFavoritosId, + if (uuidsOrdenados.isNotEmpty) 'uuids': uuidsOrdenados, + }; + + /// Parses a persisted map, or `null` when it is unusable. + /// + /// Tolerant on purpose: this payload survives app updates, backups and + /// hand-edited preference files, and it is read from a steering-wheel + /// button. An unreadable context must mean "derive it again", never a + /// crash. + static ContextoSalto? desdeMapa(Map mapa) { + final tipoRaw = mapa['tipo']; + if (tipoRaw is! String) return null; + final tipo = TipoContextoSalto.values + .where((t) => t.name == tipoRaw) + .firstOrNull; + if (tipo == null) return null; + switch (tipo) { + case TipoContextoSalto.grupoFavoritos: + final grupoId = mapa['grupoId']; + // A group context with no group is not a context. + if (grupoId is! String || grupoId.isEmpty) return null; + return ContextoSalto.grupo(grupoId); + case TipoContextoSalto.favoritos: + return const ContextoSalto.favoritos(); + case TipoContextoSalto.misEmisoras: + return const ContextoSalto.misEmisoras(); + case TipoContextoSalto.todas: + return const ContextoSalto.todas(); + case TipoContextoSalto.destacadas: + final uuids = mapa['uuids']; + if (uuids is! List) return null; + return ContextoSalto.destacadas(uuids.whereType().toList()); + } + } + + @override + bool operator ==(Object other) => + other is ContextoSalto && + other.tipo == tipo && + other.grupoFavoritosId == grupoFavoritosId && + _mismosUuids(other.uuidsOrdenados, uuidsOrdenados); + + @override + int get hashCode => Object.hash( + tipo, + grupoFavoritosId, + Object.hashAll(uuidsOrdenados), + ); + + @override + String toString() => + 'ContextoSalto(${tipo.name}, grupo=$grupoFavoritosId, ' + 'uuids=${uuidsOrdenados.length})'; + + static bool _mismosUuids(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} + +/// Persists [contexto]. Never throws — a failed write costs the driver a +/// re-derivation, an exception would cost them the station change. +Future guardarContextoSalto( + ContextoSalto contexto, { + SharedPreferences? prefs, +}) async { + try { + final resueltas = prefs ?? await SharedPreferences.getInstance(); + await resueltas.setString(claveContextoSalto, jsonEncode(contexto.aMapa())); + } catch (_) { + // Deliberately swallowed — see the doc above. + } +} + +/// Reads the persisted context, or `null` when there is none, the payload is +/// unreadable, or prefs themselves fail. +/// +/// Follows the same inject-or-`getInstance()` convention as +/// `esPremiumPersistido` and `resolverEmisorasDestacadas`, so a test pins +/// prefs without a platform channel. +Future contextoSaltoPersistido({ + SharedPreferences? prefs, +}) async { + try { + final resueltas = prefs ?? await SharedPreferences.getInstance(); + final raw = resueltas.getString(claveContextoSalto); + if (raw == null) return null; + final decodificado = jsonDecode(raw); + if (decodificado is! Map) return null; + return ContextoSalto.desdeMapa(Map.from(decodificado)); + } catch (_) { + return null; + } +} + +/// The LIVE, ordered list a remembered [contexto] resolves to right now, or an +/// empty list when it no longer resolves at all. +/// +/// Pure — no handler, no prefs — so every degradation rule below is testable +/// on its own. An empty result means "this memory has expired": the caller +/// derives a fresh context instead (and, failing that, leaves playback alone — +/// never jumps somewhere arbitrary mid-drive). +/// +/// Degradation rules, all of them deliberate. The group chain is the owner's, +/// decided from real use in the car: +/// * remembered group ALIVE -> it is walked, even when the playing station +/// has LEFT it (the caller then takes the group's first station) and even +/// when it is down to a single member (skipping there simply leaves the +/// driver where they are — a one-station group is still a group). +/// * remembered group DELETED, or alive but EMPTY -> widen to all +/// favourites, whether or not the playing station is still one of them: +/// "if the whole group is gone, pick a station from the favourites". +/// * no favourites left -> empty, i.e. the no-stations behaviour. +/// * every OTHER context type still expires when the playing station left +/// its list (unfavourited, removed from the catalogue snapshot) — the +/// owner's decision was about the group chain only. +/// * [TipoContextoSalto.destacadas] alone honours +/// [ContextoSalto.uuidsOrdenados] — see that field for why. +List resolverListaContexto({ + required ContextoSalto contexto, + required Emisora actual, + required List favoritos, + required List misEmisoras, + required List todas, + required List destacadas, + required List grupos, +}) { + bool contiene(List lista) => + lista.any((e) => e.uuid == actual.uuid); + + switch (contexto.tipo) { + case TipoContextoSalto.grupoFavoritos: + final grupoId = contexto.grupoFavoritosId; + if (grupoId == null || grupoId == GrupoFavoritos.sinAsignarId) { + return const []; + } + final existe = grupos.any((g) => g.id == grupoId); + final miembros = + favoritos.where((e) => e.grupoFavoritosId == grupoId).toList(); + if (existe && miembros.isNotEmpty) { + // A surviving group is honoured as-is. The station does NOT have to + // still be in it — the caller takes the group's first station rather + // than wandering off to another list. + return miembros; + } + // Group deleted (or alive but empty, which offers no station to take): + // widen to all favourites. Unlike the other context types this does not + // require the station to still BE a favourite — the caller takes the + // first one. + return favoritos; + case TipoContextoSalto.favoritos: + return contiene(favoritos) ? favoritos : const []; + case TipoContextoSalto.misEmisoras: + return contiene(misEmisoras) ? misEmisoras : const []; + case TipoContextoSalto.todas: + return contiene(todas) ? todas : const []; + case TipoContextoSalto.destacadas: + // The frozen order is authoritative. `actual` is resolvable from itself + // so a station frozen into the walk from a previous session still + // resolves even when it never belonged to the curated set. + final porUuid = { + for (final e in destacadas) e.uuid: e, + actual.uuid: actual, + }; + final lista = [ + for (final uuid in contexto.uuidsOrdenados) + if (porUuid[uuid] != null) porUuid[uuid]!, + ]; + return lista.any((e) => e.uuid == actual.uuid) ? lista : const []; + } +} + +/// The free tier's frozen walk order: the curated set in its compiled-in +/// order, with [actual] prepended when it does not belong to it. +/// +/// Prepending rather than dropping keeps both buttons alive for a station left +/// over from a premium session (or from `ultima_emisora_v1`): a walk the +/// playing station is not part of would make `emisoraVecina` return `null` and +/// both buttons would be dead. +List uuidsCongeladosDestacadas({ + required Emisora actual, + required List destacadas, +}) => [ + if (!destacadas.any((e) => e.uuid == actual.uuid)) actual.uuid, + ...destacadas.map((e) => e.uuid), +]; diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 71ec13f..2888ca5 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -10,6 +10,7 @@ import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.dart'; import '../modelos/pista_local.dart'; import '../modelos/preset_ecualizador.dart'; +import 'contexto_reproduccion.dart'; import 'emisoras_destacadas.dart'; import 'musica_local_auto.dart'; import 'persistencia_tolerante.dart'; @@ -1246,6 +1247,52 @@ List listaParaSaltoEmisora({ required List favoritos, required List misEmisoras, required List todas, +}) { + final contexto = contextoParaSaltoEmisora( + actual: actual, + favoritos: favoritos, + misEmisoras: misEmisoras, + todas: todas, + ); + if (contexto == null) return const []; + switch (contexto.tipo) { + case TipoContextoSalto.grupoFavoritos: + return favoritos + .where((e) => e.grupoFavoritosId == contexto.grupoFavoritosId) + .toList(); + case TipoContextoSalto.favoritos: + return favoritos; + case TipoContextoSalto.misEmisoras: + return misEmisoras; + case TipoContextoSalto.todas: + return todas; + case TipoContextoSalto.destacadas: + // Never produced by [contextoParaSaltoEmisora] — the free set is + // resolved by the handler, which owns the entitlement read. + return const []; + } +} + +/// The same decision as [listaParaSaltoEmisora], NAMED instead of materialised +/// — so it can be remembered across a process restart. +/// +/// The car kills and restarts the engine on every reconnect, and a list of +/// stations is not something that survives that: its members change while the +/// app is dead. The NAME of the list does survive, which is what +/// [ContextoSalto] persists and [resolverListaContexto] re-resolves against +/// whatever the lists hold next time. +/// +/// [listaParaSaltoEmisora] is implemented on top of this so the walked list +/// and the remembered context can never disagree (pinned by a test that runs +/// both over the same scenarios). +/// +/// Returns `null` when [actual] belongs to none of the three lists — the +/// caller then has no context to remember and leaves playback alone. +ContextoSalto? contextoParaSaltoEmisora({ + required Emisora actual, + required List favoritos, + required List misEmisoras, + required List todas, }) { Emisora? enLista(List lista) { for (final e in lista) { @@ -1263,13 +1310,13 @@ List listaParaSaltoEmisora({ if (grupo != GrupoFavoritos.sinAsignarId) { final delGrupo = favoritos.where((e) => e.grupoFavoritosId == grupo).toList(); - if (delGrupo.length > 1) return delGrupo; + if (delGrupo.length > 1) return ContextoSalto.grupo(grupo); } - return favoritos; + return const ContextoSalto.favoritos(); } - if (enLista(misEmisoras) != null) return misEmisoras; - if (enLista(todas) != null) return todas; - return const []; + if (enLista(misEmisoras) != null) return const ContextoSalto.misEmisoras(); + if (enLista(todas) != null) return const ContextoSalto.todas(); + return null; } /// The station before or after [actual] in [lista], wrapping around at both diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index efa8724..2f1906f 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -10,9 +10,11 @@ import '../estado/estado_entitlement.dart' show esPremiumPersistido; import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; +import '../modelos/grupo_favoritos.dart'; import '../modelos/pista_local.dart'; import '../modelos/preset_ecualizador.dart'; import 'cola_local.dart'; +import 'contexto_reproduccion.dart'; import 'controlador_reconexion.dart'; import 'emisoras_destacadas.dart'; import 'musica_local_auto.dart'; @@ -48,6 +50,16 @@ typedef LeerEqActivoPersistido = Future Function(); /// `ServicioEcualizador.guardarActivo`. typedef GuardarEqActivoPersistido = Future Function(bool activo); +/// Read port for the persisted skip context — «which list is the driver +/// walking» (see [ContextoSalto]). In production `main.dart` binds it to +/// `contextoSaltoPersistido`; `null` for any caller with no disk (widget +/// tests, fakes), which simply falls back to deriving the context on the spot. +typedef LeerContextoSaltoPersistido = Future Function(); + +/// Write port for the same context. Bound to `guardarContextoSalto`. +typedef GuardarContextoSaltoPersistido = + Future Function(ContextoSalto contexto); + /// Last value read from disk for the equalizer on/off flag, or `null` while /// nothing has been read yet. /// @@ -111,11 +123,22 @@ void registrarHandler( PluriWaveAudioHandler handler, { LeerEqActivoPersistido? leerEqActivoPersistido, GuardarEqActivoPersistido? guardarEqActivoPersistido, + LeerContextoSaltoPersistido? leerContextoSalto, + GuardarContextoSaltoPersistido? guardarContextoSalto, }) { _handlerGlobal = handler; // Registered BEFORE the seeding below is awaited so that a toggle arriving // during the disk read is still persisted. handler.registrarPersistenciaEq(guardarEqActivoPersistido); + // Same seam shape for the skip context (`contexto_reproduccion.dart`). + // NOT seeded eagerly like the equalizer flag: the equalizer has to be right + // before the first sample plays, whereas the context is only ever needed + // when a skip button is pressed — so it is read LAZILY, at most once per + // handler, and a driver who never presses skip pays no disk read at all. + handler.registrarPersistenciaContextoSalto( + leer: leerContextoSalto, + guardar: guardarContextoSalto, + ); if (leerEqActivoPersistido != null) { unawaited(_sembrarEcualizadorDesdeDisco(handler, leerEqActivoPersistido)); } @@ -1223,6 +1246,148 @@ class PluriWaveAudioHandler extends BaseAudioHandler _persistirEqActivo = guardar; } + /// The remembered skip context — «which list am I walking» (see + /// [ContextoSalto]). `null` until something derives or reads one. + /// + /// FROZEN across skips on purpose. Before it, `_saltarEmisora` re-derived + /// the list from `emisoraActual` on EVERY button press, so the walk drifted + /// under the driver whenever the underlying list changed between two + /// presses — and on the free tier it always does, because + /// `resolverEmisorasDestacadas` rebuilds itself as `[última reproducida, + /// ...curadas]` and therefore reorders itself as you skip, leaving + /// `previous` no longer the inverse of `next`. + ContextoSalto? _contextoSalto; + + /// Whether the read port has already been consulted for this handler. The + /// disk read happens at most once: after it, `_contextoSalto` (set or still + /// null) is the answer. + bool _contextoSaltoLeido = false; + + /// True while [_saltarEmisora] is handing its destination to + /// [playMediaItem]. It suppresses the context re-derivation that every + /// OTHER play path performs — a skip is a move WITHIN the remembered + /// context, never a new choice of context, and re-deriving there is exactly + /// what un-freezes the walk. + bool _saltandoEmisora = false; + + LeerContextoSaltoPersistido? _leerContextoSalto; + GuardarContextoSaltoPersistido? _guardarContextoSalto; + + /// Injects the skip context's persistence ports (see [registrarHandler]). + /// Both accept `null` — a handler with no disk simply derives the context + /// every time, exactly as before this seam existed. + void registrarPersistenciaContextoSalto({ + LeerContextoSaltoPersistido? leer, + GuardarContextoSaltoPersistido? guardar, + }) { + _leerContextoSalto = leer; + _guardarContextoSalto = guardar; + } + + /// The remembered context: memory first, then the read port ONCE. + /// + /// Never throws — an unreadable context must mean "derive it again", not a + /// dead steering-wheel button. + Future _contextoSaltoRecordado() async { + final enMemoria = _contextoSalto; + if (enMemoria != null) return enMemoria; + if (_contextoSaltoLeido) return null; + _contextoSaltoLeido = true; + final leer = _leerContextoSalto; + if (leer == null) return null; + try { + return _contextoSalto = await leer(); + } catch (e) { + debugPrint( + '[PluriWave][ServicioAudio] no se pudo leer el contexto de salto: $e', + ); + return null; + } + } + + /// Freezes [contexto] in memory and pushes it through the write port. + /// Never throws, and never writes the same context twice in a row. + Future _fijarContextoSalto(ContextoSalto contexto) async { + _contextoSaltoLeido = true; + if (_contextoSalto == contexto) return; + _contextoSalto = contexto; + final guardar = _guardarContextoSalto; + if (guardar == null) return; + try { + await guardar(contexto); + } catch (e) { + debugPrint( + '[PluriWave][ServicioAudio] no se pudo guardar el contexto de ' + 'salto: $e', + ); + } + } + + /// Derives the context [actual] belongs to RIGHT NOW, or `null` when it + /// cannot be derived (no browse source registered yet — a real race on a + /// cold headless bind — or a station that is in none of the lists). A + /// `null` deliberately LEAVES the remembered context alone rather than + /// clearing it: the memory is the only thing that still knows the answer. + /// + /// A free-tier driver's context is ALWAYS the curated set + /// (`emisorasDestacadas`), frozen in its compiled-in order. Entitlement is + /// re-read here rather than cached so a purchase mid-session takes effect + /// on the next play, exactly like every other gate in this file. + Future _derivarContextoSalto(Emisora actual) async { + if (!await esPremiumPersistido()) { + return ContextoSalto.destacadas( + uuidsCongeladosDestacadas( + actual: actual, + destacadas: emisorasDestacadas, + ), + ); + } + final fuente = _fuenteNavegacionGlobal; + if (fuente == null) return null; + return contextoParaSaltoEmisora( + actual: actual, + favoritos: await fuente.favoritos(), + misEmisoras: await fuente.misEmisoras(), + todas: await fuente.todas(), + ); + } + + /// Records the skip context for a station that is STARTING. + /// + /// Wired into [playMediaItem], which is the single choke point every + /// external play funnels through — that is the whole point, because a + /// context set only on the browse path is a context the bug walks straight + /// back around. The paths that reach it: + /// 1. `playFromMediaId` — a car browse tap (`emisora:`), including + /// the `recent` resume row Android Auto shows on every reconnect. + /// 2. `playFromSearch` — a voice command, empty ("resume") or named. + /// 3. `ServicioAudio.reproducir` — the phone UI, via `playMediaItem`. + /// 4. `_saltarEmisora`'s own destination — SUPPRESSED by + /// [_saltandoEmisora], because a skip moves within the context rather + /// than choosing a new one. + /// 5. `_reproducirEntradaCola` — local-queue playback. It does NOT go + /// through `playMediaItem`, and a local track is filtered out below + /// anyway. + /// + /// Local files are ignored: their `id` is a `content://` URI, they have + /// their own queue (`_colaLocal`), and letting one overwrite the station + /// context would leave the radio walking a list it never chose. Same scheme + /// test as [_reproduciendoRadio]. + Future _recordarContextoSalto(MediaItem item) async { + try { + final esquema = Uri.tryParse(item.id)?.scheme.toLowerCase(); + if (esquema != 'http' && esquema != 'https') return; + final contexto = await _derivarContextoSalto(emisoraDesdeMediaItem(item)); + if (contexto == null) return; + await _fijarContextoSalto(contexto); + } catch (e) { + debugPrint( + '[PluriWave][ServicioAudio] no se pudo recordar el contexto de ' + 'salto: $e', + ); + } + } + /// The player's live position, used to keep `updatePosition` honest on /// every `playbackState` push. Exposed so tests can assert the re-push /// without reaching into the private player. @@ -1871,6 +2036,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler Future playMediaItem(MediaItem mediaItem) async { _colaLocal = null; _avanzandoCola = false; + // The ONE place the skip context is recorded — see + // [_recordarContextoSalto] for the enumerated paths that arrive here. + // Fire-and-forget: it reads prefs and the browse source, and a station + // change must not wait on either. + if (!_saltandoEmisora) unawaited(_recordarContextoSalto(mediaItem)); return _encolarCambioFuente(mediaItem); } @@ -2614,10 +2784,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler /// Station-to-station skipping for the car's transport row. /// - /// The list to walk is resolved by [listaParaSaltoEmisora]: the narrowest - /// list the current station actually belongs to, favourites first. Anything - /// unresolvable — no source, no current station, a station that is in no - /// list, a single-entry list — leaves playback untouched. Never throws; + /// The list to walk is the REMEMBERED context re-resolved against the live + /// lists ([resolverListaContexto]), falling back to a fresh derivation + /// ([listaParaSaltoEmisora]'s decision, named) only once the memory resolves + /// to nothing at all. Anything unresolvable — no source, no current station, + /// a station that is in no list — leaves playback untouched, and so does a + /// single-entry list the station is already on. Never throws; /// this runs from a hardware/steering-wheel button and an exception here /// would surface as the app going silent mid-drive. Future _saltarEmisora({required bool haciaAtras}) async { @@ -2628,21 +2800,75 @@ class PluriWaveAudioHandler extends BaseAudioHandler // rather than the buttons being refused. A free driver cycles the free // set — which always has at least [emisorasDestacadas.length] entries, // so neither button is ever a dead end — and a premium driver keeps the - // narrowest-context walk (`listaParaSaltoEmisora`) unchanged. - final List lista; - if (await esPremiumPersistido()) { - final fuente = _fuenteNavegacionGlobal; - if (fuente == null) return; - lista = listaParaSaltoEmisora( - actual: actual, - favoritos: await fuente.favoritos(), - misEmisoras: await fuente.misEmisoras(), - todas: await fuente.todas(), - ); - } else { - lista = await resolverEmisorasDestacadas(); + // narrowest-context walk (`contextoParaSaltoEmisora`) unchanged. + final premium = await esPremiumPersistido(); + // Only a premium walk ever touches the browse source; the free set is + // compiled in, so a free driver needs no source at all (and a cold + // headless bind may not have one yet). + final fuente = premium ? _fuenteNavegacionGlobal : null; + final favoritos = + fuente == null ? const [] : await fuente.favoritos(); + final misEmisoras = + fuente == null ? const [] : await fuente.misEmisoras(); + final todas = fuente == null ? const [] : await fuente.todas(); + final grupos = + fuente == null ? const [] : await fuente.grupos(); + + List listaDe(ContextoSalto? contexto) => + contexto == null + ? const [] + : resolverListaContexto( + contexto: contexto, + actual: actual, + favoritos: favoritos, + misEmisoras: misEmisoras, + todas: todas, + destacadas: emisorasDestacadas, + grupos: grupos, + ); + + // 1. The REMEMBERED context wins — that is the whole point: the car + // restarts the engine on every reconnect, and re-deriving from + // scratch is what lost the driver's list. It is only honoured while + // it still resolves to a walkable list (`resolverListaContexto` + // returns empty once it has expired). + var contexto = await _contextoSaltoRecordado(); + // Entitlement gate: a FREE driver cycles the free set and nothing else. + // A context frozen while the account was paying (a group, all + // favourites, the catalogue) is DISCARDED rather than walked, so a + // downgrade cannot keep skipping through premium content. + if (!premium && contexto?.tipo != TipoContextoSalto.destacadas) { + contexto = null; } - final destino = emisoraVecina(actual, lista, haciaAtras: haciaAtras); + var lista = listaDe(contexto); + // 2. EXPIRED (or absent) memory: derive it again from what is live now. + // Expired means "resolves to nothing at all". A list of ONE is not + // expired: a favourites group down to a single station is still the + // group the driver chose, and re-deriving there is exactly what used + // to widen the walk to every favourite behind their back. + if (lista.isEmpty) { + contexto = await _derivarContextoSalto(actual); + lista = listaDe(contexto); + } + // 3. Freeze whatever we are actually about to walk, so the NEXT press + // (this process or the next one) walks the same list. + if (contexto != null && lista.isNotEmpty) { + await _fijarContextoSalto(contexto); + } + + // The context can resolve to a list the playing station is NOT on: the + // owner's rule is that a surviving group (or, once the group is gone, + // the favourites) keeps the walk even when the station left it. Taking + // `lista.first` is that rule. `emisoraVecina` is deliberately NOT + // loosened for it — its "not in the list means do nothing" contract is + // a safety property other callers rely on, so the exception lives here, + // where the context has already said which list to stay on. + final destino = + lista.isEmpty + ? null + : lista.any((e) => e.uuid == actual.uuid) + ? emisoraVecina(actual, lista, haciaAtras: haciaAtras) + : lista.first; // Reported: in the car these buttons did nothing for radio. Every early // return here is silent, so the log has to say WHICH one fired -- // an empty list (the station matched none of the three) and a station @@ -2650,10 +2876,19 @@ class PluriWaveAudioHandler extends BaseAudioHandler debugPrint( '[PluriWave][ServicioAudio] salto emisora atras=$haciaAtras ' 'actual=${actual.nombre} uuid=${actual.uuid} ' - 'lista=${lista.length} destino=${destino?.nombre ?? "NINGUNO"}', + 'contexto=$contexto lista=${lista.length} ' + 'destino=${destino?.nombre ?? "NINGUNO"}', ); if (destino == null) return; - await playMediaItem(mediaItemParaEmisora(destino, l10n: _textos)); + // Suppresses the re-derivation `playMediaItem` performs for every other + // play path: a skip MOVES WITHIN the context, it does not choose a new + // one. Without this latch the walk un-freezes on every press. + _saltandoEmisora = true; + try { + await playMediaItem(mediaItemParaEmisora(destino, l10n: _textos)); + } finally { + _saltandoEmisora = false; + } } catch (e) { debugPrint('[PluriWave][ServicioAudio] Error saltando de emisora: $e'); } diff --git a/lib/servicios/servicio_favoritos.dart b/lib/servicios/servicio_favoritos.dart index dfb146a..2b68ed1 100644 --- a/lib/servicios/servicio_favoritos.dart +++ b/lib/servicios/servicio_favoritos.dart @@ -213,6 +213,43 @@ class ServicioFavoritos { ); } + /// Restaura un favorito tal como estaba en el dispositivo de origen, + /// preservando su `orden` y su `grupo_id`. + /// Usado exclusivamente por importarConfig, igual que [restaurarGrupo]. + /// + /// Existe porque [agregar] NO sirve como primitiva de restauración: es la + /// primitiva de «marcar como favorita» y fuerza `sin_asignar` más un + /// `orden` al final de la lista, cosa correcta para una emisora recién + /// marcada (que de verdad no pertenece a ningún grupo) y destructiva para + /// una copia de seguridad, que trae ambos campos. Reusarla era la causa de + /// que los grupos volvieran vacíos tras restaurar. + /// + /// El grupo se valida igual que en [asignarGrupo]: un `grupo_id` que no + /// existe en `grupos_favoritos` cae a [GrupoFavoritos.sinAsignarId], de modo + /// que una copia editada a mano o restaurada a medias no puede dejar + /// emisoras apuntando a un grupo inexistente. `importarConfig` restaura los + /// grupos ANTES de este bucle, así que en el camino normal siempre existen. + Future restaurarFavorito(Emisora emisora) async { + final db = await _database; + final existe = + Sqflite.firstIntValue( + await db.rawQuery( + 'SELECT COUNT(*) FROM grupos_favoritos WHERE id = ?', + [emisora.grupoFavoritosId], + ), + ) ?? + 0; + final restaurada = + existe > 0 + ? emisora + : emisora.copyWith(grupoFavoritosId: GrupoFavoritos.sinAsignarId); + await db.insert( + 'favoritos', + restaurada.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + Future eliminarGrupo(String id) async { if (id == GrupoFavoritos.sinAsignarId) return; final db = await _database; diff --git a/test/estado/estado_radio_export_import_grupos_test.dart b/test/estado/estado_radio_export_import_grupos_test.dart new file mode 100644 index 0000000..6a01b7e --- /dev/null +++ b/test/estado/estado_radio_export_import_grupos_test.dart @@ -0,0 +1,177 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/modelos/grupo_favoritos.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +/// Copia de seguridad — ROUND TRIP de los grupos de favoritos y de la +/// asignación emisora -> grupo. +/// +/// Reportado desde el uso real: al restaurar una copia en otro dispositivo +/// los grupos volvían VACÍOS y todas las emisoras aparecían en «Sin +/// asignar». El sobre exportado siempre llevó ambas cosas (`gruposFavoritos` +/// desde v2, y `grupo_id` dentro de cada entrada de `favoritos`, porque es +/// una clave intrínseca de `Emisora.toMap()`); lo que fallaba era la +/// APLICACIÓN del estado: `importarConfig` reusaba `ServicioFavoritos.agregar`, +/// la primitiva de «marcar como favorita», que fuerza `sin_asignar` y un +/// `orden` nuevo a propósito. +/// +/// Por eso estos tests prueban el VIAJE COMPLETO (origen -> exportar -> +/// destino limpio -> importar), no la forma del sobre: la forma ya estaba +/// bien y aun así el usuario perdía sus grupos. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + tempDir = await Directory.systemTemp.createTemp( + 'pluriwave_export_grupos_test', + ); + }); + + tearDown(() async { + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + var contadorArchivos = 0; + + Future crearRadio() async { + final prefs = await SharedPreferences.getInstance(); + // Un archivo POR instancia: `importarConfig` escribe siempre en el que + // resuelva `resolverArchivoCustom`, y origen y destino no pueden + // compartirlo. + final archivoCustom = File( + '${tempDir.path}/emisoras_custom_${contadorArchivos++}.json', + ); + if (!archivoCustom.existsSync()) { + await archivoCustom.writeAsString('[]'); + } + final radio = EstadoRadio( + esPremium: () => true, + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + resolverArchivoCustom: () async => archivoCustom, + prefs: prefs, + iniciarAutomaticamente: false, + ); + await radio.ecualizador.cargarPersistido(); + return radio; + } + + Emisora emisora(String uuid, String nombre) => Emisora( + uuid: uuid, + nombre: nombre, + url: 'https://example.com/$uuid.mp3', + ); + + group('EstadoRadio export/import — grupos de favoritos', () { + test('round trip: los grupos y la asignación de CADA emisora sobreviven ' + 'al viaje origen -> copia -> destino limpio', () async { + final origen = await crearRadio(); + await origen.toggleFavorito(emisora('rock-1', 'Rock Uno')); + await origen.toggleFavorito(emisora('rock-2', 'Rock Dos')); + await origen.toggleFavorito(emisora('jazz-1', 'Jazz Uno')); + await origen.toggleFavorito(emisora('suelta', 'Sin grupo')); + await origen.crearGrupoFavoritos('Rock'); + await origen.crearGrupoFavoritos('Jazz'); + final rock = origen.gruposFavoritos.firstWhere((g) => g.nombre == 'Rock'); + final jazz = origen.gruposFavoritos.firstWhere((g) => g.nombre == 'Jazz'); + await origen.asignarGrupoFavorito('rock-1', rock.id); + await origen.asignarGrupoFavorito('rock-2', rock.id); + await origen.asignarGrupoFavorito('jazz-1', jazz.id); + + final copia = await origen.exportarConfig(); + + final destino = await crearRadio(); + await destino.importarConfig(copia); + + // Los grupos vuelven, con su nombre y su orden. + final gruposDestino = destino.gruposFavoritos; + expect( + gruposDestino.map((g) => g.id), + containsAll([rock.id, jazz.id]), + ); + expect(gruposDestino.firstWhere((g) => g.id == rock.id).nombre, 'Rock'); + expect(gruposDestino.firstWhere((g) => g.id == jazz.id).nombre, 'Jazz'); + + // Y la asignación de CADA emisora vuelve con ellos. + String grupoDe(String uuid) => + destino.listaFavoritos.firstWhere((e) => e.uuid == uuid) + .grupoFavoritosId; + expect(grupoDe('rock-1'), rock.id); + expect(grupoDe('rock-2'), rock.id); + expect(grupoDe('jazz-1'), jazz.id); + expect(grupoDe('suelta'), GrupoFavoritos.sinAsignarId); + }); + + test('una copia ANTIGUA sin `gruposFavoritos` importa limpiamente y deja ' + 'intactos los grupos que ya existen en el dispositivo', () async { + final destino = await crearRadio(); + await destino.crearGrupoFavoritos('Mío'); + final propio = destino.gruposFavoritos.firstWhere( + (g) => g.nombre == 'Mío', + ); + await destino.toggleFavorito(emisora('local-1', 'Local Uno')); + await destino.asignarGrupoFavorito('local-1', propio.id); + + // v1: ni `gruposFavoritos` ni `alarmas` ni preferencias. La regla del + // sobre es que un campo AUSENTE no toca ese estado. + await destino.importarConfig({ + 'version': 1, + 'favoritos': >[], + 'emisorasCustom': >[], + 'presetsEcualizador': {}, + }); + + expect(destino.gruposFavoritos.any((g) => g.id == propio.id), isTrue); + expect( + destino.gruposFavoritos.firstWhere((g) => g.id == propio.id).nombre, + 'Mío', + ); + expect( + destino.listaFavoritos.firstWhere((e) => e.uuid == 'local-1') + .grupoFavoritosId, + propio.id, + ); + }); + + test('los grupos importados quedan visibles SIN reiniciar: importarConfig ' + 'recarga la lista en memoria y notifica', () async { + final origen = await crearRadio(); + await origen.toggleFavorito(emisora('rock-1', 'Rock Uno')); + await origen.crearGrupoFavoritos('Rock'); + final rock = origen.gruposFavoritos.firstWhere((g) => g.nombre == 'Rock'); + await origen.asignarGrupoFavorito('rock-1', rock.id); + final copia = await origen.exportarConfig(); + + final destino = await crearRadio(); + var notificaciones = 0; + destino.addListener(() => notificaciones++); + + await destino.importarConfig(copia); + + expect(notificaciones, greaterThan(0)); + expect(destino.gruposFavoritos.any((g) => g.id == rock.id), isTrue); + expect( + destino.listaFavoritos.single.grupoFavoritosId, + rock.id, + reason: + 'la vista de favoritos agrupa por `grupoFavoritosId`: si la lista ' + 'en memoria no se recarga tras restaurar los grupos, la pantalla ' + 'sigue mostrando todo en «Sin asignar» hasta reiniciar', + ); + }); + }); +} diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index cd39ed6..ff34eed 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -171,7 +171,45 @@ class FakeServicioFavoritos extends ServicioFavoritos { @override Future agregar(Emisora emisora) async { _favoritos.removeWhere((e) => e.uuid == emisora.uuid); - _favoritos.add(emisora.copyWith(orden: _favoritos.length)); + // FIEL a producción (`ServicioFavoritos.agregar`): esta es la primitiva de + // «marcar como favorita», y fuerza `sin_asignar` además de un `orden` + // nuevo. El doble NO lo hacía, así que cualquier test de import escrito + // contra él salía verde mientras el dispositivo real perdía la asignación + // de grupo. Para RESTAURAR una copia existe `restaurarFavorito`. + _favoritos.add( + emisora.copyWith( + orden: _favoritos.length, + grupoFavoritosId: GrupoFavoritos.sinAsignarId, + ), + ); + } + + @override + Future restaurarFavorito(Emisora emisora) async { + // Fiel a producción: preserva `orden` y `grupo_id`, cayendo a + // `sin_asignar` cuando el grupo de la copia no existe. + _favoritos.removeWhere((e) => e.uuid == emisora.uuid); + final existe = _grupos.any((g) => g.id == emisora.grupoFavoritosId); + _favoritos.add( + existe + ? emisora + : emisora.copyWith(grupoFavoritosId: GrupoFavoritos.sinAsignarId), + ); + } + + @override + Future restaurarGrupo(GrupoFavoritos grupo) async { + // Sin este override la llamada caía en la implementación REAL de sqflite + // y explotaba con «databaseFactory not initialized»; solo pasaba + // desapercibido porque todos los tests de import existentes mandaban + // `gruposFavoritos: []`. + if (grupo.esSinAsignar) return; + final index = _grupos.indexWhere((g) => g.id == grupo.id); + if (index == -1) { + _grupos.add(grupo); + } else { + _grupos[index] = grupo; + } } @override diff --git a/test/servicios/auto_salto_emisora_test.dart b/test/servicios/auto_salto_emisora_test.dart index 6bb0bd7..7396fed 100644 --- a/test/servicios/auto_salto_emisora_test.dart +++ b/test/servicios/auto_salto_emisora_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/grupo_favoritos.dart'; +import 'package:pluriwave/servicios/contexto_reproduccion.dart'; import 'package:pluriwave/servicios/navegacion_auto.dart'; /// Requested: the Android Auto playback screen must offer previous/next for @@ -172,4 +173,145 @@ void main() { ); }); }); + + group('contextoParaSaltoEmisora — la MISMA decisión, nombrada para poder ' + 'recordarla entre procesos', () { + Emisora favorita(String uuid, String grupo) => Emisora( + uuid: uuid, + nombre: uuid, + url: 'https://example.com/$uuid', + grupoFavoritosId: grupo, + ); + + final rock1 = favorita('rock1', 'g-rock'); + final rock2 = favorita('rock2', 'g-rock'); + final jazz1 = favorita('jazz1', 'g-jazz'); + + test('nombra el grupo cuando el salto se queda dentro del grupo', () { + expect( + contextoParaSaltoEmisora( + actual: rock1, + favoritos: [rock1, jazz1, rock2], + misEmisoras: const [], + todas: const [], + ), + const ContextoSalto.grupo('g-rock'), + ); + }); + + test('nombra la lista de cada uno de los otros tres casos', () { + expect( + contextoParaSaltoEmisora( + actual: jazz1, + favoritos: [rock1, jazz1, rock2], + misEmisoras: const [], + todas: const [], + ), + const ContextoSalto.favoritos(), + reason: 'un grupo de un solo miembro cae a todos los favoritos', + ); + expect( + contextoParaSaltoEmisora( + actual: c, + favoritos: [a, b], + misEmisoras: [c, a], + todas: [a, b, c], + ), + const ContextoSalto.misEmisoras(), + ); + expect( + contextoParaSaltoEmisora( + actual: c, + favoritos: [a], + misEmisoras: [b], + todas: [a, b, c], + ), + const ContextoSalto.todas(), + ); + }); + + test('null cuando la emisora no está en ninguna lista', () { + expect( + contextoParaSaltoEmisora( + actual: emisora('huerfana'), + favoritos: [a], + misEmisoras: [b], + todas: [a, b], + ), + isNull, + ); + }); + + test('CONCUERDA con listaParaSaltoEmisora en todos los casos: son la ' + 'misma decisión y no pueden divergir', () { + final escenarios = >>[ + [ + [rock1], + [rock1, jazz1, rock2], + const [], + const [], + ], + [ + [jazz1], + [rock1, jazz1, rock2], + const [], + const [], + ], + [ + [c], + [a, b], + [c, a], + [a, b, c], + ], + [ + [c], + [a], + [b], + [a, b, c], + ], + [ + [emisora('huerfana')], + [a], + [b], + [a, b], + ], + ]; + for (final escenario in escenarios) { + final actual = escenario[0].single; + final favoritos = escenario[1]; + final misEmisoras = escenario[2]; + final todas = escenario[3]; + final contexto = contextoParaSaltoEmisora( + actual: actual, + favoritos: favoritos, + misEmisoras: misEmisoras, + todas: todas, + ); + final porContexto = + contexto == null + ? const [] + : resolverListaContexto( + contexto: contexto, + actual: actual, + favoritos: favoritos, + misEmisoras: misEmisoras, + todas: todas, + destacadas: const [], + grupos: const [ + GrupoFavoritos(id: 'g-rock', nombre: 'Rock', orden: 1), + GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2), + ], + ); + expect( + porContexto, + listaParaSaltoEmisora( + actual: actual, + favoritos: favoritos, + misEmisoras: misEmisoras, + todas: todas, + ), + ); + } + }); + }); } diff --git a/test/servicios/contexto_reproduccion_test.dart b/test/servicios/contexto_reproduccion_test.dart new file mode 100644 index 0000000..e453056 --- /dev/null +++ b/test/servicios/contexto_reproduccion_test.dart @@ -0,0 +1,308 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/modelos/grupo_favoritos.dart'; +import 'package:pluriwave/servicios/contexto_reproduccion.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Contexto de reproducción — el «en qué lista estoy» que sobrevive a que el +/// proceso muera. +/// +/// Mismo molde headless-safe que `emisoras_destacadas.dart`: solo +/// `shared_preferences` y modelos, jamás `EstadoRadio` ni un `ChangeNotifier`, +/// porque este módulo tiene que leerse desde el motor sin árbol de widgets que +/// levanta Android Auto. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Emisora emisora(String uuid, {String grupo = GrupoFavoritos.sinAsignarId}) => + Emisora( + uuid: uuid, + nombre: uuid, + url: 'https://example.com/$uuid', + grupoFavoritosId: grupo, + ); + + group('ContextoSalto — serialización', () { + test('la clave de persistencia queda fijada literalmente', () { + // Un rename silencioso aquí no rompe nada en compilación y deja al + // conductor sin contexto tras actualizar: se fija a propósito. + expect(claveContextoSalto, 'contexto_salto_v1'); + }); + + test('round trip de los tres tipos que llevan carga útil', () { + for (final contexto in [ + const ContextoSalto.grupo('g-rock'), + const ContextoSalto.favoritos(), + const ContextoSalto.misEmisoras(), + const ContextoSalto.todas(), + const ContextoSalto.destacadas(['a', 'b', 'c']), + ]) { + expect(ContextoSalto.desdeMapa(contexto.aMapa()), contexto); + } + }); + + test('un payload corrupto o ajeno devuelve null en vez de lanzar', () { + expect(ContextoSalto.desdeMapa(const {}), isNull); + expect(ContextoSalto.desdeMapa(const {'tipo': 'inventado'}), isNull); + expect( + ContextoSalto.desdeMapa(const {'tipo': 'grupoFavoritos'}), + isNull, + reason: 'un contexto de grupo sin id de grupo no resuelve a nada', + ); + expect( + ContextoSalto.desdeMapa(const {'tipo': 'destacadas', 'uuids': 7}), + isNull, + ); + }); + }); + + group('persistencia', () { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('round trip por disco', () async { + final prefs = await SharedPreferences.getInstance(); + + await guardarContextoSalto( + const ContextoSalto.grupo('g-rock'), + prefs: prefs, + ); + + expect( + await contextoSaltoPersistido(prefs: prefs), + const ContextoSalto.grupo('g-rock'), + ); + }); + + test('sin nada persistido devuelve null', () async { + final prefs = await SharedPreferences.getInstance(); + expect(await contextoSaltoPersistido(prefs: prefs), isNull); + }); + + test('un JSON ilegible degrada a null, nunca lanza: esto se lee desde un ' + 'botón del volante', () async { + SharedPreferences.setMockInitialValues({ + claveContextoSalto: 'esto no es json', + }); + final prefs = await SharedPreferences.getInstance(); + expect(await contextoSaltoPersistido(prefs: prefs), isNull); + + SharedPreferences.setMockInitialValues({ + claveContextoSalto: jsonEncode({'tipo': 'inventado'}), + }); + expect( + await contextoSaltoPersistido( + prefs: await SharedPreferences.getInstance(), + ), + isNull, + ); + }); + }); + + group('resolverListaContexto — degradación del contexto recordado', () { + final rock1 = emisora('rock1', grupo: 'g-rock'); + final rock2 = emisora('rock2', grupo: 'g-rock'); + final jazz1 = emisora('jazz1', grupo: 'g-jazz'); + final suelta = emisora('suelta'); + final favoritos = [rock1, jazz1, rock2, suelta]; + const grupos = [ + GrupoFavoritos(id: 'g-rock', nombre: 'Rock', orden: 1), + GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2), + ]; + + List resolver( + ContextoSalto contexto, + Emisora actual, { + List? favs, + List? gruposVivos, + List misEmisoras = const [], + List todas = const [], + List destacadas = const [], + }) => resolverListaContexto( + contexto: contexto, + actual: actual, + favoritos: favs ?? favoritos, + misEmisoras: misEmisoras, + todas: todas, + destacadas: destacadas, + grupos: gruposVivos ?? grupos, + ); + + test('el grupo recordado se recorre con sus miembros VIVOS, no con el ' + 'snapshot', () { + final nuevo = emisora('rock3', grupo: 'g-rock'); + expect( + resolver( + const ContextoSalto.grupo('g-rock'), + rock1, + favs: [rock1, jazz1, rock2, nuevo], + ), + [rock1, rock2, nuevo], + ); + }); + + test('el grupo recordado ya NO existe -> cae a todos los favoritos', () { + expect( + resolver( + const ContextoSalto.grupo('g-borrado'), + rock1, + gruposVivos: const [ + GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2), + ], + ), + favoritos, + ); + }); + + test('el grupo SIGUE VIVO con un solo miembro -> se honra igual: un grupo ' + 'de una emisora sigue siendo el grupo que eligió el conductor', () { + expect( + resolver( + const ContextoSalto.grupo('g-jazz'), + jazz1, + ), + [jazz1], + ); + }); + + test('el grupo sigue vivo pero la emisora que suena ya NO pertenece a él ' + '-> se PERMANECE en el grupo (el llamador coge su primera emisora)', + () { + expect( + resolver(const ContextoSalto.grupo('g-rock'), jazz1), + [rock1, rock2], + ); + }); + + test('el grupo sigue vivo pero se quedó VACÍO -> no hay primera emisora ' + 'que coger, así que se ensancha a todos los favoritos', () { + expect( + resolver( + const ContextoSalto.grupo('g-rock'), + jazz1, + favs: [jazz1, suelta], + ), + [jazz1, suelta], + ); + }); + + test('el grupo fue borrado y la emisora ya NO es favorita -> aun así se ' + 'cae a los favoritos: el llamador elegirá una de ellas', () { + expect( + resolver( + const ContextoSalto.grupo('g-borrado'), + emisora('fuera'), + gruposVivos: const [], + ), + favoritos, + ); + }); + + test('no quedan favoritos -> lista vacía: el comportamiento de siempre ' + 'cuando no hay emisoras agregadas', () { + expect( + resolver( + const ContextoSalto.grupo('g-rock'), + emisora('fuera'), + favs: const [], + gruposVivos: const [], + ), + isEmpty, + ); + }); + + test('la emisora salió de favoritos por completo -> el contexto de ' + 'FAVORITOS se descarta', () { + expect( + resolver(const ContextoSalto.favoritos(), emisora('fuera')), + isEmpty, + ); + }); + + test('favoritos / misEmisoras / todas se resuelven contra su lista viva', () { + expect(resolver(const ContextoSalto.favoritos(), rock1), favoritos); + final propia = emisora('propia'); + expect( + resolver( + const ContextoSalto.misEmisoras(), + propia, + misEmisoras: [propia, suelta], + ), + [propia, suelta], + ); + final catalogo = emisora('catalogo'); + expect( + resolver( + const ContextoSalto.todas(), + catalogo, + todas: [catalogo, rock1], + ), + [catalogo, rock1], + ); + }); + + test('destacadas respeta el ORDEN CONGELADO, que es la razón de existir ' + 'del snapshot: la lista viva se reordena sola en cada lectura', () { + final fip = emisora('fip'); + final soma = emisora('soma'); + final ajena = emisora('ajena'); + expect( + resolver( + const ContextoSalto.destacadas(['ajena', 'fip', 'soma']), + ajena, + destacadas: [fip, soma], + ), + [ajena, fip, soma], + reason: + 'la emisora que suena entra en la lista aunque no esté en el set ' + 'curado; si no, ambos botones morirían', + ); + }); + + test('destacadas: un uuid del snapshot que ya no resuelve se descarta', () { + final fip = emisora('fip'); + final soma = emisora('soma'); + expect( + resolver( + const ContextoSalto.destacadas(['fip', 'retirada', 'soma']), + fip, + destacadas: [fip, soma], + ), + [fip, soma], + ); + }); + + test('destacadas: si la emisora que suena no está en el snapshot el ' + 'contexto se descarta', () { + final fip = emisora('fip'); + expect( + resolver( + const ContextoSalto.destacadas(['fip']), + emisora('otra'), + destacadas: [fip], + ), + isEmpty, + ); + }); + }); + + group('uuidsCongeladosDestacadas', () { + test('respeta el orden curado y antepone la emisora que suena cuando no ' + 'pertenece al set', () { + final fip = emisora('fip'); + final soma = emisora('soma'); + expect( + uuidsCongeladosDestacadas(actual: fip, destacadas: [fip, soma]), + ['fip', 'soma'], + ); + expect( + uuidsCongeladosDestacadas( + actual: emisora('ajena'), + destacadas: [fip, soma], + ), + ['ajena', 'fip', 'soma'], + ); + }); + }); +} diff --git a/test/servicios/servicio_audio_contexto_salto_test.dart b/test/servicios/servicio_audio_contexto_salto_test.dart new file mode 100644 index 0000000..908ee14 --- /dev/null +++ b/test/servicios/servicio_audio_contexto_salto_test.dart @@ -0,0 +1,618 @@ +import 'dart:async'; +import 'dart:ui' show Locale; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/modelos/grupo_favoritos.dart'; +import 'package:pluriwave/servicios/contexto_reproduccion.dart'; +import 'package:pluriwave/servicios/emisoras_destacadas.dart'; +import 'package:pluriwave/servicios/navegacion_auto.dart'; +import 'package:pluriwave/servicios/servicio_audio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/handlers_audio.dart'; + +/// Contexto de reproducción en el HANDLER — «al conectar el coche, siguiente/ +/// anterior ya no recuerdan en qué lista estaba». +/// +/// El handler es el único que existe en los dos motores (el del móvil y el +/// headless que levanta Android Auto sin Activity), así que es él quien tiene +/// que escribir y leer el contexto. Lo hace por un PUERTO inyectado en +/// `registrarHandler`, exactamente igual que el flag on/off del ecualizador: +/// `servicio_audio.dart` no importa `shared_preferences` ni conoce +/// `EstadoRadio`, que en el coche no llega a construirse nunca. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final crearHandler = registrarHandlersLiberables(); + + late _GuionReproductor guion; + late _PuertoContexto puerto; + + setUp(() { + guion = _GuionReproductor(); + puerto = _PuertoContexto(); + PluriWaveAudioHandler.fabricaReproductorPrueba = + (pipeline, carga) => _ReproductorFalso(guion, pipeline, carga); + SharedPreferences.setMockInitialValues({}); + }); + + tearDown(() { + PluriWaveAudioHandler.fabricaReproductorPrueba = null; + // `_fuenteNavegacionGlobal` es global de módulo: se deja siempre en una + // fuente vacía para que un test no herede las listas del anterior. + registrarFuenteNavegacion(_FuenteFalsa()); + }); + + AppLocalizations textos() => lookupAppLocalizations(const Locale('es')); + + Emisora favorita(String uuid, String grupo) => Emisora( + uuid: uuid, + nombre: uuid, + url: 'https://example.com/$uuid', + grupoFavoritosId: grupo, + ); + + final rock1 = favorita('rock1', 'g-rock'); + final rock2 = favorita('rock2', 'g-rock'); + final rock3 = favorita('rock3', 'g-rock'); + final jazz1 = favorita('jazz1', 'g-jazz'); + final jazz2 = favorita('jazz2', 'g-jazz'); + const gruposVivos = [ + GrupoFavoritos(id: 'g-rock', nombre: 'Rock', orden: 1), + GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2), + ]; + + PluriWaveAudioHandler handlerConPuerto() { + final handler = crearHandler(); + registrarHandler( + handler, + leerContextoSalto: puerto.leer, + guardarContextoSalto: puerto.guardar, + ); + return handler; + } + + Future premium() async { + SharedPreferences.setMockInitialValues({'compra_premium_v1': true}); + } + + group('escritura — el contexto se fija en TODO camino que arranca una ' + 'emisora, no solo en el del árbol del coche', () { + setUp(premium); + + test('playFromMediaId (toque en el árbol del coche)', () async { + registrarFuenteNavegacion( + _FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos), + ); + final handler = handlerConPuerto(); + + await handler.playFromMediaId('emisora:rock1'); + await pumpEventQueue(); + + expect(puerto.guardados.last, const ContextoSalto.grupo('g-rock')); + }); + + test('playFromSearch (voz)', () async { + registrarFuenteNavegacion( + _FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos), + ); + final handler = handlerConPuerto(); + + await handler.playFromSearch('jazz1'); + await pumpEventQueue(); + + expect(puerto.guardados.last, const ContextoSalto.favoritos()); + }); + + test('playMediaItem (el camino del móvil: ServicioAudio.reproducir)', + () async { + registrarFuenteNavegacion( + _FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos), + ); + final handler = handlerConPuerto(); + + await handler.playMediaItem(mediaItemParaEmisora(rock2, l10n: textos())); + await pumpEventQueue(); + + expect(puerto.guardados.last, const ContextoSalto.grupo('g-rock')); + }); + + test('una PISTA LOCAL no escribe contexto de emisora: su cola es otra ' + 'cosa y pisarlo dejaría la radio recorriendo una lista ajena', + () async { + registrarFuenteNavegacion( + _FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos), + ); + final handler = handlerConPuerto(); + + await handler.playMediaItem( + const MediaItem( + id: 'content://media/documents/pista-1', + title: 'Pista local', + ), + ); + await pumpEventQueue(); + + expect(puerto.guardados, isEmpty); + }); + }); + + group('lectura — el contexto recordado sobrevive al reinicio del proceso', + () { + setUp(premium); + + test('la fuente aún no está registrada cuando arranca la emisora (carrera ' + 'real del bind en frío): el salto usa el contexto de la sesión ' + 'anterior en vez de morir', () async { + puerto.persistido = const ContextoSalto.grupo('g-rock'); + // Bind en frío: `main.dart` todavía no ha llamado a + // registrarFuenteNavegacion cuando el coche pide reproducir. + registrarFuenteNavegacion(_FuenteFalsa()); + final handler = handlerConPuerto(); + await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos())); + await pumpEventQueue(); + + // La fuente llega después, ya con las listas cargadas. + registrarFuenteNavegacion( + _FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos), + ); + await handler.skipToNext(); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas.last, rock2.url); + expect(puerto.lecturas, greaterThan(0)); + }); + + test('el contexto recordado MANDA sobre la rederivación: el registro de ' + 'favoritos perdió el grupo, y aun así se recorre el grupo', () async { + // Escenario real: el snapshot que empuja el móvil llega antes de que + // los grupos estén cargados, así que `favoritos()` reporta + // «sin asignar» un rato. Sin memoria, el salto se ensancha a todos los + // favoritos justo en mitad del trayecto. + puerto.persistido = const ContextoSalto.grupo('g-rock'); + registrarFuenteNavegacion( + _FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos), + ); + final handler = handlerConPuerto(); + await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos())); + await pumpEventQueue(); + guion.urlsSolicitadas.clear(); + + await handler.skipToNext(); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas.last, rock2.url); + }); + }); + + group('degradación del contexto recordado', () { + setUp(premium); + + test('el grupo recordado fue BORRADO -> se recorren todos los favoritos', + () async { + puerto.persistido = const ContextoSalto.grupo('g-rock'); + registrarFuenteNavegacion( + _FuenteFalsa( + favoritos: [ + favorita('rock1', GrupoFavoritos.sinAsignarId), + jazz1, + jazz2, + ], + grupos: const [ + GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2), + ], + ), + ); + final handler = handlerConPuerto(); + + await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos())); + await pumpEventQueue(); + guion.urlsSolicitadas.clear(); + await handler.skipToNext(); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas.last, jazz1.url); + }); + + test('el grupo SIGUE VIVO pero la emisora se salió de él -> se PERMANECE ' + 'en el grupo y suena su PRIMERA emisora', () async { + registrarFuenteNavegacion( + _FuenteFalsa(favoritos: [rock1, rock2, jazz1], grupos: gruposVivos), + ); + final handler = handlerConPuerto(); + + await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos())); + await pumpEventQueue(); + expect(puerto.guardados.last, const ContextoSalto.grupo('g-rock')); + guion.urlsSolicitadas.clear(); + + // El móvil saca rock1 del grupo a media marcha: g-rock sigue existiendo + // y sigue teniendo emisoras, solo que ya no la que suena. + registrarFuenteNavegacion( + _FuenteFalsa( + // jazz1 va DELANTE de rock2 a propósito: si el contexto se + // ensanchara a todos los favoritos, «siguiente» sonaría jazz1. + favoritos: [ + favorita('rock1', GrupoFavoritos.sinAsignarId), + jazz1, + rock2, + rock3, + ], + grupos: gruposVivos, + ), + ); + await handler.skipToNext(); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas.last, rock2.url); + expect( + puerto.persistido, + const ContextoSalto.grupo('g-rock'), + reason: 'el grupo sobrevive, así que el contexto no se rederiva', + ); + }); + + test('el grupo sigue vivo con UNA sola emisora -> el salto NO se ensancha ' + 'a todos los favoritos: se queda donde está', () async { + registrarFuenteNavegacion( + _FuenteFalsa( + favoritos: [rock1, rock2, jazz1, jazz2], + grupos: gruposVivos, + ), + ); + final handler = handlerConPuerto(); + + await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos())); + await pumpEventQueue(); + guion.urlsSolicitadas.clear(); + + // rock2 sale del grupo: g-rock se queda solo con la emisora que suena. + registrarFuenteNavegacion( + _FuenteFalsa( + favoritos: [ + rock1, + favorita('rock2', GrupoFavoritos.sinAsignarId), + jazz1, + jazz2, + ], + grupos: gruposVivos, + ), + ); + await handler.skipToNext(); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas, isEmpty); + expect(puerto.persistido, const ContextoSalto.grupo('g-rock')); + }); + + test('el grupo fue BORRADO y la emisora ya NO es favorita -> suena la ' + 'PRIMERA de los favoritos que queden', () async { + registrarFuenteNavegacion( + _FuenteFalsa(favoritos: [rock1, rock2], grupos: gruposVivos), + ); + final handler = handlerConPuerto(); + + await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos())); + await pumpEventQueue(); + guion.urlsSolicitadas.clear(); + + registrarFuenteNavegacion( + _FuenteFalsa( + favoritos: [jazz1, jazz2], + grupos: const [ + GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2), + ], + ), + ); + await handler.skipToNext(); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas.last, jazz1.url); + }); + + test('no queda ningún favorito y la emisora no está en ninguna lista -> ' + 'no-op silencioso, nunca un salto arbitrario', () async { + puerto.persistido = const ContextoSalto.grupo('g-rock'); + registrarFuenteNavegacion(_FuenteFalsa(grupos: gruposVivos)); + final handler = handlerConPuerto(); + + await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos())); + await pumpEventQueue(); + guion.urlsSolicitadas.clear(); + await handler.skipToNext(); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas, isEmpty); + }); + }); + + group('tier gratuito', () { + test('la lista de salto queda CONGELADA en el orden curado: que cambie ' + '`ultima_emisora_v1` a media marcha no reordena el recorrido, así ' + 'que «anterior» vuelve a deshacer «siguiente»', () async { + // `resolverEmisorasDestacadas` se recompone como + // `[última reproducida, ...curadas]`, así que la lista SE REORDENA SOLA + // según suena cada emisora. Recorrerla en vivo hacía que «anterior» + // dejara de ser el inverso de «siguiente» a mitad de trayecto. + final kexp = emisorasDestacadas[3]; + final siguienteCurada = emisorasDestacadas[4]; + SharedPreferences.setMockInitialValues({ + claveUltimaEmisora: + '{"uuid":"${kexp.uuid}","nombre":"${kexp.nombre}",' + '"url":"${kexp.url}"}', + }); + final handler = handlerConPuerto(); + + // Fija la divergencia: la lista VIVA con `ultima` = KEXP empieza por + // KEXP, así que recorrerla daría la primera curada. Sin esta línea el + // test de abajo pasaría también con el comportamiento antiguo. + final enVivo = await resolverEmisorasDestacadas(); + expect( + emisoraVecina(kexp, enVivo, haciaAtras: false)?.url, + emisorasDestacadas.first.url, + ); + + await handler.playFromMediaId('emisora:${kexp.uuid}'); + await pumpEventQueue(); + await handler.skipToNext(); + await pumpEventQueue(); + + expect( + guion.urlsSolicitadas.last, + siguienteCurada.url, + reason: + 'en vivo la lista sería [KEXP, ...resto], y «siguiente» se iría a ' + 'la primera curada en vez de a la que va detrás de KEXP', + ); + + // El móvil (vivo) persiste la emisora que acaba de sonar. + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + claveUltimaEmisora, + '{"uuid":"${siguienteCurada.uuid}",' + '"nombre":"${siguienteCurada.nombre}",' + '"url":"${siguienteCurada.url}"}', + ); + + await handler.skipToPrevious(); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas.last, kexp.url); + }); + + test('una emisora ajena al set curado se ANTEPONE al recorrido congelado, ' + 'para que ningún botón quede muerto', () async { + SharedPreferences.setMockInitialValues({ + claveUltimaEmisora: + '{"uuid":"ajena","nombre":"Ajena",' + '"url":"https://example.com/ajena"}', + }); + final handler = handlerConPuerto(); + + await handler.playFromMediaId('emisora:ajena'); + await pumpEventQueue(); + + expect( + puerto.guardados.last, + ContextoSalto.destacadas([ + 'ajena', + ...emisorasDestacadas.map((e) => e.uuid), + ]), + ); + + await handler.skipToPrevious(); + await pumpEventQueue(); + expect(guion.urlsSolicitadas.last, emisorasDestacadas.last.url); + }); + + test('un contexto PREMIUM recordado NO se camina en tier gratuito: una ' + 'cuenta degradada no sigue recorriendo el catálogo', () async { + puerto.persistido = const ContextoSalto.grupo('g-rock'); + registrarFuenteNavegacion( + _FuenteFalsa(favoritos: [rock1, rock2], grupos: gruposVivos), + ); + final handler = handlerConPuerto(); + + await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}'); + await pumpEventQueue(); + guion.urlsSolicitadas.clear(); + await handler.skipToNext(); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas.last, emisorasDestacadas[1].url); + expect( + puerto.guardados.last.tipo, + TipoContextoSalto.destacadas, + reason: 'el contexto de un usuario free es SIEMPRE el set gratuito', + ); + }); + + test('free: aunque el grupo de favoritos siga VIVO, el recorrido no entra ' + 'en él tampoco en el segundo salto', () async { + // La nueva caída «grupo vivo -> su primera emisora» no puede convertirse + // en una puerta trasera al contenido de pago. + puerto.persistido = const ContextoSalto.grupo('g-rock'); + registrarFuenteNavegacion( + _FuenteFalsa(favoritos: [rock1, rock2, rock3], grupos: gruposVivos), + ); + final handler = handlerConPuerto(); + + await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}'); + await pumpEventQueue(); + guion.urlsSolicitadas.clear(); + await handler.skipToNext(); + await pumpEventQueue(); + await handler.skipToNext(); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas, [ + emisorasDestacadas[1].url, + emisorasDestacadas[2].url, + ]); + }); + }); + + group('robustez del puerto', () { + setUp(premium); + + test('sin puerto registrado el salto sigue funcionando por rederivación ' + '(un test de widgets, o el arranque antes de main.dart)', () async { + registrarFuenteNavegacion( + _FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos), + ); + final handler = crearHandler(); + registrarHandler(handler); + + await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos())); + await pumpEventQueue(); + guion.urlsSolicitadas.clear(); + await handler.skipToNext(); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas.last, rock2.url); + }); + + test('un puerto que revienta degrada a rederivación en vez de dejar al ' + 'conductor sin botones', () async { + puerto.explota = true; + registrarFuenteNavegacion( + _FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos), + ); + final handler = handlerConPuerto(); + + await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos())); + await pumpEventQueue(); + guion.urlsSolicitadas.clear(); + await handler.skipToNext(); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas.last, rock2.url); + }); + }); +} + +/// Doble del puerto de persistencia que `main.dart` ata a +/// `contexto_reproduccion.dart`. +class _PuertoContexto { + ContextoSalto? persistido; + final guardados = []; + int lecturas = 0; + bool explota = false; + + Future leer() async { + lecturas++; + if (explota) throw StateError('disco ilegible'); + return persistido; + } + + Future guardar(ContextoSalto contexto) async { + if (explota) throw StateError('disco de solo lectura'); + guardados.add(contexto); + persistido = contexto; + } +} + +class _FuenteFalsa implements FuenteEmisorasAuto { + _FuenteFalsa({ + List? favoritos, + List? misEmisoras, + List? todas, + List? grupos, + }) : _favoritos = favoritos ?? const [], + _misEmisoras = misEmisoras ?? const [], + _todas = todas ?? const [], + _grupos = grupos ?? const []; + + final List _favoritos; + final List _misEmisoras; + final List _todas; + final List _grupos; + + @override + Future> favoritos() async => _favoritos; + + @override + Future> misEmisoras() async => _misEmisoras; + + @override + Future> todas() async => _todas; + + @override + Future> grupos() async => _grupos; + + @override + Future porUuid(String uuid) async { + for (final lista in [_favoritos, _misEmisoras, _todas]) { + for (final e in lista) { + if (e.uuid == uuid) return e; + } + } + for (final e in await resolverEmisorasDestacadas()) { + if (e.uuid == uuid) return e; + } + return null; + } + + @override + void actualizarSnapshot({ + List? favoritos, + List? misEmisoras, + List? todas, + List? grupos, + }) {} +} + +/// Misma forma que el doble de `servicio_audio_auto_free_test.dart`. +class _GuionReproductor { + final urlsSolicitadas = []; + _ReproductorFalso? ultimoReproductor; +} + +class _ReproductorFalso extends AudioPlayer { + _ReproductorFalso( + this._guion, + AudioPipeline pipeline, + AudioLoadConfiguration carga, + ) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) { + _guion.ultimoReproductor = this; + } + + final _GuionReproductor _guion; + final _estados = StreamController.broadcast(); + + @override + Stream get playerStateStream => _estados.stream; + + @override + Future setUrl( + String url, { + Map? headers, + Duration? initialPosition, + bool preload = true, + dynamic tag, + }) async { + _guion.urlsSolicitadas.add(url); + return null; + } + + @override + Future play() async {} + + @override + Future pause() async {} + + @override + Future stop() async {} + + @override + Future setVolume(double volume) async {} + + @override + Future dispose() async { + await _estados.close(); + } +} diff --git a/test/servicios/servicio_favoritos_sqlite_test.dart b/test/servicios/servicio_favoritos_sqlite_test.dart index c9a7d00..ab005ce 100644 --- a/test/servicios/servicio_favoritos_sqlite_test.dart +++ b/test/servicios/servicio_favoritos_sqlite_test.dart @@ -105,6 +105,80 @@ void main() { expect(favoritos.single.grupoFavoritosId, GrupoFavoritos.sinAsignarId); expect(grupos.any((g) => g.id == grupo.id), isFalse); }); + + // ── Primitivas de restauración (importarConfig) ──────────────────────────── + // + // `agregar` es la primitiva de "marcar como favorita": fuerza `sin_asignar` + // y un `orden` nuevo a propósito, porque una emisora recién marcada no + // pertenece a ningún grupo. Reusarla para RESTAURAR una copia de seguridad + // destruía justo los dos campos que la copia traía. `restaurarFavorito` es + // la primitiva que faltaba. + + test('restaurarFavorito preserva el grupo y el orden de la copia', () async { + final servicio = crearServicio(); + addTearDown(servicio.cerrar); + + await servicio.restaurarGrupo( + const GrupoFavoritos(id: 'grupo-rock', nombre: 'Rock', orden: 7), + ); + + await servicio.restaurarFavorito( + _emisora( + 'radio-1', + 'Radio Uno', + ).copyWith(orden: 42, grupoFavoritosId: 'grupo-rock'), + ); + + final favoritos = await servicio.obtenerTodos(); + expect(favoritos.single.grupoFavoritosId, 'grupo-rock'); + expect(favoritos.single.orden, 42); + }); + + test('restaurarFavorito cae a Sin asignar cuando el grupo de la copia no ' + 'existe (copia editada a mano o restauración parcial)', () async { + final servicio = crearServicio(); + addTearDown(servicio.cerrar); + + await servicio.restaurarFavorito( + _emisora( + 'radio-1', + 'Radio Uno', + ).copyWith(orden: 3, grupoFavoritosId: 'grupo-fantasma'), + ); + + final favoritos = await servicio.obtenerTodos(); + expect(favoritos.single.grupoFavoritosId, GrupoFavoritos.sinAsignarId); + expect(favoritos.single.orden, 3); + }); + + test('restaurarGrupo hace upsert preservando id, nombre y orden, y NUNCA ' + 'duplica el grupo protegido', () async { + final servicio = crearServicio(); + addTearDown(servicio.cerrar); + + await servicio.restaurarGrupo( + const GrupoFavoritos(id: 'grupo-jazz', nombre: 'Jazz', orden: 5), + ); + // Segunda pasada (reimportar la misma copia): upsert, no duplicado. + await servicio.restaurarGrupo( + const GrupoFavoritos(id: 'grupo-jazz', nombre: 'Jazz renombrado', orden: 5), + ); + await servicio.restaurarGrupo( + const GrupoFavoritos( + id: GrupoFavoritos.sinAsignarId, + nombre: 'Unassigned', + orden: 0, + protegido: true, + ), + ); + + final grupos = await servicio.obtenerGrupos(); + final jazz = grupos.singleWhere((g) => g.id == 'grupo-jazz'); + expect(jazz.nombre, 'Jazz renombrado'); + expect(jazz.orden, 5); + // El protegido conserva su nombre local: no se importa desde la copia. + expect(grupos.singleWhere((g) => g.esSinAsignar).nombre, 'Sin asignar'); + }); } Emisora _emisora(String uuid, String nombre) {