Files
pluriwave/test/servicios/servicio_audio_contexto_salto_test.dart
FreeTLab 5f35ab7d6a
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m27s
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.
2026-09-02 22:56:10 +02:00

619 lines
20 KiB
Dart

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