feat: restaurar los grupos de favoritos al importar y recordar la lista del coche
Dos fallos reportados desde el uso real en el coche. Los grupos de favoritos no volvian al importar La exportacion nunca estuvo rota: los grupos viajaban desde siempre y la asignacion de cada emisora va dentro de cada favorito como grupo_id. El fallo estaba solo al importar. La restauracion reutilizaba ServicioFavoritos.agregar, que fuerza "sin asignar" a proposito, porque una emisora recien marcada como favorita no tiene grupo. Correcto para esa ruta, destructivo como primitiva de restauracion: los grupos volvian vacios y todo aterrizaba en Sin asignar. Se separa la ruta de restaurar, que respeta el grupo_id del backup y su orden. Ningun test lo detectaba porque el doble de pruebas era infiel: el fake conservaba el grupo que la implementacion real destruia, y no tenia restaurarGrupo, asi que esa ruta estaba sin cubrir. Los tests existentes lo esquivaban pasando siempre una lista de grupos vacia. Se corrige el doble. No hace falta subir la version del formato: el envoltorio ya llevaba todo. El coche perdia la lista al reconectar El contexto de reproduccion vivia solo en memoria, y el motor que arranca Android Auto es un proceso nuevo sin interfaz ni EstadoRadio, asi que al reconectar se reproducia la ultima emisora sin saber a que lista pertenecia y siguiente/anterior no hacian nada hasta entrar a favoritos a mano. Ahora se persiste el TIPO de contexto y, cuando aplica, el id del grupo, y se resuelve contra las listas vivas en cada salto: si el grupo cambia de contenido entre sesiones, el coche ve lo actual y no una foto vieja. Cadena de repliegue, decidida por el propietario: grupo vivo con la emisora dentro -> se recorre el grupo grupo vivo sin la emisora -> se permanece en el grupo, primera grupo vivo pero vacio -> se ensancha a favoritos grupo borrado -> favoritos sin favoritos -> comportamiento actual Honrar un grupo de una sola emisora exigia levantar dos barreras, no una: el resolutor y el propio _saltarEmisora, que se negaba a nombrar un grupo con menos de dos miembros. emisoraVecina queda intacta: su contrato de no saltar a ciegas es deliberado y se usa desde mas sitios, asi que el caso de "la emisora se salio del grupo" se trata en el flujo del salto. La puerta de entitlement no cambia: un conductor sin premium sigue recorriendo solo el conjunto gratuito, y un contexto congelado mientras pagaba se descarta en vez de recorrerse. Suite completa: 1501 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos preexistentes.
This commit is contained in:
@@ -877,7 +877,12 @@ class EstadoRadio extends ChangeNotifier {
|
||||
final favRaw = data['favoritos'] as List? ?? [];
|
||||
for (final raw in favRaw) {
|
||||
final emisora = Emisora.fromMap(Map<String, dynamic>.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 ───────────────────────────────────────────────────
|
||||
|
||||
@@ -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<void> 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
|
||||
|
||||
@@ -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<String> 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<String> uuidsOrdenados;
|
||||
|
||||
Map<String, dynamic> 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<String, dynamic> 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<String>().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<String> a, List<String> 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<void> 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<ContextoSalto?> 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<String, dynamic>.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<Emisora> resolverListaContexto({
|
||||
required ContextoSalto contexto,
|
||||
required Emisora actual,
|
||||
required List<Emisora> favoritos,
|
||||
required List<Emisora> misEmisoras,
|
||||
required List<Emisora> todas,
|
||||
required List<Emisora> destacadas,
|
||||
required List<GrupoFavoritos> grupos,
|
||||
}) {
|
||||
bool contiene(List<Emisora> 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 = <String, Emisora>{
|
||||
for (final e in destacadas) e.uuid: e,
|
||||
actual.uuid: actual,
|
||||
};
|
||||
final lista = <Emisora>[
|
||||
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<String> uuidsCongeladosDestacadas({
|
||||
required Emisora actual,
|
||||
required List<Emisora> destacadas,
|
||||
}) => [
|
||||
if (!destacadas.any((e) => e.uuid == actual.uuid)) actual.uuid,
|
||||
...destacadas.map((e) => e.uuid),
|
||||
];
|
||||
@@ -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<Emisora> listaParaSaltoEmisora({
|
||||
required List<Emisora> favoritos,
|
||||
required List<Emisora> misEmisoras,
|
||||
required List<Emisora> 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<Emisora> favoritos,
|
||||
required List<Emisora> misEmisoras,
|
||||
required List<Emisora> todas,
|
||||
}) {
|
||||
Emisora? enLista(List<Emisora> lista) {
|
||||
for (final e in lista) {
|
||||
@@ -1263,13 +1310,13 @@ List<Emisora> 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
|
||||
|
||||
@@ -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<bool?> Function();
|
||||
/// `ServicioEcualizador.guardarActivo`.
|
||||
typedef GuardarEqActivoPersistido = Future<void> 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<ContextoSalto?> Function();
|
||||
|
||||
/// Write port for the same context. Bound to `guardarContextoSalto`.
|
||||
typedef GuardarContextoSaltoPersistido =
|
||||
Future<void> 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<ContextoSalto?> _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<void> _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<ContextoSalto?> _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:<uuid>`), 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<void> _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<void> 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<void> _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<Emisora> 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 <Emisora>[] : await fuente.favoritos();
|
||||
final misEmisoras =
|
||||
fuente == null ? const <Emisora>[] : await fuente.misEmisoras();
|
||||
final todas = fuente == null ? const <Emisora>[] : await fuente.todas();
|
||||
final grupos =
|
||||
fuente == null ? const <GrupoFavoritos>[] : await fuente.grupos();
|
||||
|
||||
List<Emisora> 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');
|
||||
}
|
||||
|
||||
@@ -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<void> 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<void> eliminarGrupo(String id) async {
|
||||
if (id == GrupoFavoritos.sinAsignarId) return;
|
||||
final db = await _database;
|
||||
|
||||
@@ -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<EstadoRadio> 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(<String>[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(<String, dynamic>{
|
||||
'version': 1,
|
||||
'favoritos': <Map<String, dynamic>>[],
|
||||
'emisorasCustom': <Map<String, dynamic>>[],
|
||||
'presetsEcualizador': <String, dynamic>{},
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+39
-1
@@ -171,7 +171,45 @@ class FakeServicioFavoritos extends ServicioFavoritos {
|
||||
@override
|
||||
Future<void> 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<void> 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<void> 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
|
||||
|
||||
@@ -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 = <List<List<Emisora>>>[
|
||||
[
|
||||
[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 <Emisora>[]
|
||||
: 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<Emisora> resolver(
|
||||
ContextoSalto contexto,
|
||||
Emisora actual, {
|
||||
List<Emisora>? favs,
|
||||
List<GrupoFavoritos>? gruposVivos,
|
||||
List<Emisora> misEmisoras = const [],
|
||||
List<Emisora> todas = const [],
|
||||
List<Emisora> 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'],
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<void> 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 = <ContextoSalto>[];
|
||||
int lecturas = 0;
|
||||
bool explota = false;
|
||||
|
||||
Future<ContextoSalto?> leer() async {
|
||||
lecturas++;
|
||||
if (explota) throw StateError('disco ilegible');
|
||||
return persistido;
|
||||
}
|
||||
|
||||
Future<void> guardar(ContextoSalto contexto) async {
|
||||
if (explota) throw StateError('disco de solo lectura');
|
||||
guardados.add(contexto);
|
||||
persistido = contexto;
|
||||
}
|
||||
}
|
||||
|
||||
class _FuenteFalsa implements FuenteEmisorasAuto {
|
||||
_FuenteFalsa({
|
||||
List<Emisora>? favoritos,
|
||||
List<Emisora>? misEmisoras,
|
||||
List<Emisora>? todas,
|
||||
List<GrupoFavoritos>? grupos,
|
||||
}) : _favoritos = favoritos ?? const [],
|
||||
_misEmisoras = misEmisoras ?? const [],
|
||||
_todas = todas ?? const [],
|
||||
_grupos = grupos ?? const [];
|
||||
|
||||
final List<Emisora> _favoritos;
|
||||
final List<Emisora> _misEmisoras;
|
||||
final List<Emisora> _todas;
|
||||
final List<GrupoFavoritos> _grupos;
|
||||
|
||||
@override
|
||||
Future<List<Emisora>> favoritos() async => _favoritos;
|
||||
|
||||
@override
|
||||
Future<List<Emisora>> misEmisoras() async => _misEmisoras;
|
||||
|
||||
@override
|
||||
Future<List<Emisora>> todas() async => _todas;
|
||||
|
||||
@override
|
||||
Future<List<GrupoFavoritos>> grupos() async => _grupos;
|
||||
|
||||
@override
|
||||
Future<Emisora?> 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<Emisora>? favoritos,
|
||||
List<Emisora>? misEmisoras,
|
||||
List<Emisora>? todas,
|
||||
List<GrupoFavoritos>? grupos,
|
||||
}) {}
|
||||
}
|
||||
|
||||
/// Misma forma que el doble de `servicio_audio_auto_free_test.dart`.
|
||||
class _GuionReproductor {
|
||||
final urlsSolicitadas = <String>[];
|
||||
_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<PlayerState>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<PlayerState> get playerStateStream => _estados.stream;
|
||||
|
||||
@override
|
||||
Future<Duration?> setUrl(
|
||||
String url, {
|
||||
Map<String, String>? headers,
|
||||
Duration? initialPosition,
|
||||
bool preload = true,
|
||||
dynamic tag,
|
||||
}) async {
|
||||
_guion.urlsSolicitadas.add(url);
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() async {}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
|
||||
@override
|
||||
Future<void> setVolume(double volume) async {}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await _estados.close();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user