Files
pluriwave/test/estado/estado_radio_test.dart
T
FreeTLab 8fc3d99fbd fix: el coche recuerda la ultima emisora y deja de publicar una sesion fantasma
Tres defectos preexistentes alrededor de la reanudacion en Android Auto. Ninguno
es una regresion: el consumidor (la raiz `recent`) se añadio en septiembre y es
lo que dejo el hueco a la vista.

La ultima emisora solo la escribia el telefono

La clave `ultima_emisora_v1` tenia como unico escritor a
`EstadoRadio._persistirUltimaEmisora`, y `EstadoRadio` solo existe si hay arbol
de widgets. El motor que arranca Android Auto es headless de verdad, asi que una
sesion que ocurriera solo en el coche jamas actualizaba la clave y al reconectar
se ofrecia la emisora de la ultima vez que se uso el movil.

El handler recibe ahora sus puertos de lectura y escritura, con la misma forma
que los del ecualizador y el contexto de salto, y escribe desde `_cambiarFuente`:
el cuello de botella por el que pasan todas las rutas -- telefono, toque en el
coche, voz, saltos, avance de cola y la propia reanudacion.

Se ELIMINA el escritor del telefono en vez de sumar un segundo. Dos escritores
independientes de la misma clave acaban divergiendo siempre; es exactamente lo
que ya costo varias rondas con el flag del ecualizador.

Las pistas locales quedan excluidas: un `content://` guardado como ultima
emisora seria una fila de reanudacion que no resuelve a nada.

play() sin fuente levantaba un servicio en primer plano vacio

just_audio publica `playing:true` antes de comprobar si hay fuente, asi que un
`play()` en frio no tocaba la plataforma pero si emitia ese estado sobre
`processingState: idle`. audio_service entraba en estado de reproduccion
mientras el estado nativo seguia en NONE: notificacion con boton de pausa, cero
audio, sin titulo ni caratula, y un Future que no se completaba nunca. El coche
enruta su tecla de play directamente ahi.

Ahora `play()` sin fuente abierta restaura la ultima emisora por la ruta normal,
y si no hay nada que restaurar no toca el reproductor ni publica nada.

En frio no habia metadatos que enseñar

El unico `mediaItem.add` util vivia dentro de `_cambiarFuente`, asi que en un
motor recien arrancado el lado nativo nunca recibia metadatos. Se siembra el
`mediaItem` de la emisora persistida sin cargar ni reproducir nada, con guarda
antes y despues de la lectura de disco para no pisar una emisora ya sonando.

`getMediaItem` resolvia solo contra el universo completo -- vacio en el motor del
coche -- mientras `porUuid` si caia en las destacadas. El coche podia navegar una
emisora destacada y luego no resolver su ficha. Ambos usan ahora la misma ruta.

Suite completa: 1529 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-06 00:08:09 +02:00

1054 lines
38 KiB
Dart

import 'dart:async';
import 'dart:convert';
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:pluriwave/modelos/preset_ecualizador.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/fakes.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
group('EstadoRadio integración de custom + EQ persistente', () {
test('incluye emisoras custom en el listado principal de inicio', () async {
final archivo = await _crearArchivoCustom([
emisoraDemo(uuid: 'custom-1', nombre: 'Custom Uno'),
]);
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(
populares: [emisoraDemo(uuid: 'api-1', nombre: 'API Uno')],
),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(
estado.emisorasInicio.map((e) => e.uuid).toList(),
equals(['custom-1', 'api-1']),
);
});
test(
'carga EQ principal persistido antes de decidir EQ de reproducción',
() async {
final audio = FakeServicioAudio();
final principal = PresetEcualizador.rock;
final emisora = emisoraDemo(uuid: 'api-1', nombre: 'API Uno');
final estado = EstadoRadio(
esPremium: () => true,
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(populares: [emisora]),
servicioEcualizador: FakeServicioEcualizador(principal: principal),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
await estado.reproducir(emisora);
expect(estado.ecualizador.presetActual, principal);
expect(audio.presetsAplicados.first, principal);
expect(audio.presetsAplicados.last, principal);
},
);
test(
'mantiene EQ persistido aunque el ecualizador nativo no esté disponible',
() async {
final principal = PresetEcualizador.jazz;
final porEmisora = {'fav-1': PresetEcualizador.rock};
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(ecualizadorDisponible: false),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(
principal: principal,
porEmisora: porEmisora,
),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(estado.ecualizador.disponible, isFalse);
expect(estado.ecualizador.presetActual, principal);
expect(estado.ecualizador.presetPrincipal, principal);
expect(
estado.ecualizador.presetPorEmisora('fav-1'),
PresetEcualizador.rock,
);
},
);
test(
'inicializar deja error tras fallo y cargarPopulares manual recupera estaciones',
() async {
final radio = FakeServicioRadio(
erroresPopularesPorLlamada: [Exception('sin red')],
popularesPorLlamada: [
const [],
[emisoraDemo(uuid: 'api-ok', nombre: 'API Recuperada')],
],
tendenciasPorLlamada: [
const [],
[emisoraDemo(uuid: 'trend-ok', nombre: 'Trend Recuperada')],
],
);
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: radio,
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(estado.error, 'Sin conexión a la API de radio');
await estado.cargarPopulares();
expect(estado.error, isNull);
expect(estado.populares.map((e) => e.uuid), contains('api-ok'));
expect(estado.tendencias.map((e) => e.uuid), contains('trend-ok'));
expect(radio.obtenerPopularesCalls, 2);
},
);
test(
'EQ propio por emisora pisa al principal y puede volver a fallback',
() async {
final audio = FakeServicioAudio();
final favoritos = FakeServicioFavoritos();
final emisora = emisoraDemo(uuid: 'fav-1', nombre: 'Favorita');
final principal = PresetEcualizador.pop;
final propio = PresetEcualizador.jazz;
await favoritos.agregar(emisora);
final estado = EstadoRadio(
esPremium: () => true,
audio: audio,
favoritos: favoritos,
radio: FakeServicioRadio(populares: [emisora]),
servicioEcualizador: FakeServicioEcualizador(principal: principal),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
await estado.cargarFavoritos();
await estado.ecualizador.guardarPresetPorEmisora(emisora.uuid, propio);
await estado.reproducir(emisora);
expect(estado.ecualizador.presetActual, propio);
expect(audio.presetsAplicados.last, propio);
await estado.ecualizador.deshabilitarPresetPorEmisora(emisora.uuid);
await estado.reproducir(emisora);
expect(estado.ecualizador.presetActual, principal);
expect(audio.presetsAplicados.last, principal);
},
);
test(
'favorita sin EQ propio usa EQ principal desde el primer play',
() async {
final audio = FakeServicioAudio();
final favoritos = FakeServicioFavoritos();
final emisora = emisoraDemo(uuid: 'fav-main', nombre: 'Favorita Main');
final principal = PresetEcualizador.voz;
await favoritos.agregar(emisora);
final estado = EstadoRadio(
esPremium: () => true,
audio: audio,
favoritos: favoritos,
radio: FakeServicioRadio(populares: [emisora]),
servicioEcualizador: FakeServicioEcualizador(principal: principal),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
await estado.cargarFavoritos();
await estado.reproducir(emisora);
expect(estado.ecualizador.tienePresetPorEmisora(emisora.uuid), isFalse);
expect(estado.ecualizador.presetActual, principal);
expect(audio.presetsAplicados.last, principal);
},
);
test(
'permite activar y desactivar el ecualizador de forma persistente',
() async {
final audio = FakeServicioAudio();
final servicioEcualizador = FakeServicioEcualizador();
final estado = EstadoRadio(
esPremium: () => true,
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: servicioEcualizador,
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(estado.ecualizador.activo, isTrue);
await estado.ecualizador.cambiarActivo(false);
expect(estado.ecualizador.activo, isFalse);
expect(servicioEcualizador.config.activo, isFalse);
expect(audio.cambiosEcualizadorActivo.last, isFalse);
await estado.ecualizador.cambiarActivo(true);
expect(estado.ecualizador.activo, isTrue);
expect(servicioEcualizador.config.activo, isTrue);
expect(audio.cambiosEcualizadorActivo.last, isTrue);
},
);
test(
'notifica cambios de estado de audio para mostrar reproductor al primer play',
() async {
final audio = FakeServicioAudio();
final emisora = emisoraDemo(uuid: 'play-1', nombre: 'Primera');
final estado = EstadoRadio(
esPremium: () => true,
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(populares: [emisora]),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
var notificaciones = 0;
estado.addListener(() => notificaciones++);
await estado.inicializar();
final antes = notificaciones;
audio.emitirEstado(EstadoReproduccion.cargando);
await Future<void>.delayed(Duration.zero);
expect(notificaciones, greaterThan(antes));
},
);
test(
'reproducir la misma emisora mientras suena fuerza recarga del stream',
() async {
final audio = FakeServicioAudio();
final emisora = emisoraDemo(uuid: 'same-1', nombre: 'Misma');
final estado = EstadoRadio(
esPremium: () => true,
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(populares: [emisora]),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
await estado.reproducir(emisora);
await estado.reproducir(emisora);
expect(audio.emisorasReproducidas, hasLength(2));
},
);
test(
'ignora finalizaciones stale cuando se cambia de emisora rapido',
() async {
final audio = _AudioControlado();
final radio = FakeServicioRadio();
final primera = emisoraDemo(uuid: 'slow-1', nombre: 'Lenta');
final segunda = emisoraDemo(uuid: 'fast-2', nombre: 'Rapida');
final estado = EstadoRadio(
esPremium: () => true,
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: radio,
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
await estado.ecualizador.guardarPresetPorEmisora(
primera.uuid,
PresetEcualizador.rock,
);
await estado.ecualizador.guardarPresetPorEmisora(
segunda.uuid,
PresetEcualizador.jazz,
);
final primeraFuture = estado.reproducir(primera);
final segundaFuture = estado.reproducir(segunda);
audio.completar(segunda.uuid);
await segundaFuture;
audio.completar(primera.uuid);
await primeraFuture;
expect(estado.ecualizador.presetActual, PresetEcualizador.jazz);
expect(radio.ultimoUuidClick, segunda.uuid);
},
);
test('reordenar favoritos reindexa de forma determinística', () async {
final favoritos = FakeServicioFavoritos();
await favoritos.agregar(emisoraDemo(uuid: 'a', nombre: 'A'));
await favoritos.agregar(emisoraDemo(uuid: 'b', nombre: 'B'));
await favoritos.agregar(emisoraDemo(uuid: 'c', nombre: 'C'));
await favoritos.reordenar('a', 2);
final lista = await favoritos.obtenerTodos();
expect(lista.map((e) => e.uuid).toList(), equals(['b', 'c', 'a']));
expect(lista.map((e) => e.orden).toList(), equals([0, 1, 2]));
});
// The search pagination test moved to test/estado/estado_busqueda_test.dart
// (S4-R3: search state extracted to EstadoBusqueda).
test('toggleFavorito refresca lista global y evita estado stale', () async {
final favoritos = FakeServicioFavoritos();
final emisora = emisoraDemo(uuid: 'fav-sync', nombre: 'Sync');
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(populares: [emisora]),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isFalse);
await estado.toggleFavorito(emisora);
expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isTrue);
await estado.toggleFavorito(emisora);
expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isFalse);
});
test('crea y asigna grupos de favoritos sin tocar Sin asignar', () async {
final favoritos = FakeServicioFavoritos();
final emisora = emisoraDemo(uuid: 'fav-group', nombre: 'Grupo');
await favoritos.agregar(emisora);
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(populares: [emisora]),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(estado.gruposFavoritos.first.protegido, isTrue);
await estado.crearGrupoFavoritos('Noticias');
final grupo = estado.gruposFavoritos.last;
await estado.asignarGrupoFavorito(emisora.uuid, grupo.id);
expect(estado.listaFavoritos.first.grupoFavoritosId, grupo.id);
await estado.eliminarGrupoFavoritos(grupo.id);
expect(estado.listaFavoritos.first.grupoFavoritosId, 'sin_asignar');
});
});
group('EstadoRadio orden de listas — persistencia (WU6)', () {
// Correction applied at apply time: cambiarOrdenListas'/_cargarOrdenListas
// round-trip had zero test coverage before WU6 added OrdenEmisoras.
// popularidad — a latent gap similar to WU5's androidAudioSessionIdStream
// discovery. Adding a new enum case without covering the persistence
// switch would have shipped a silent revert-to-calidad-on-restart bug.
test(
'popularidad persiste y sobrevive a una nueva instancia (reinicio)',
() async {
final estadoUno = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoUno.inicializar();
await estadoUno.cambiarOrdenListas(OrdenEmisoras.popularidad);
expect(estadoUno.ordenListas, OrdenEmisoras.popularidad);
// Fresh instance, same (mocked) SharedPreferences-backed store —
// simulates an app restart re-reading the persisted preference.
final estadoDos = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoDos.inicializar();
expect(estadoDos.ordenListas, OrdenEmisoras.popularidad);
},
);
});
group('EstadoRadio — emisoras custom: lectura tolerante y guardia de '
'degradacion (persistence-resilience)', () {
test('entradas invalidas se omiten sin perder las validas ni fabricar '
'uuid (D5 parcial)', () async {
final archivo = await _crearArchivoCustomRaw(
jsonEncode([
{'uuid': 'custom-1', 'nombre': 'Valida Uno', 'url': 'http://a'},
{'uuid': 'custom-2', 'nombre': 'Valida Dos', 'url': 'http://b'},
// falta 'url' (campo requerido) -> Emisora.fromMap lanza.
{'uuid': 'custom-3', 'nombre': 'Sin url'},
// falta 'uuid' -> Emisora.fromMap lanza.
{'nombre': 'Sin uuid', 'url': 'http://d'},
]),
);
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(estado.emisorasCustom, hasLength(2));
expect(estado.emisorasCustom.map((e) => e.uuid).toSet(), {
'custom-1',
'custom-2',
});
});
test('si resolver la ruta del archivo custom falla, la inicializacion '
'sobrevive y las cargas hermanas no se pierden (D5 IO-fail)', () async {
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom:
() async => throw const FileSystemException('sin storage'),
iniciarAutomaticamente: false,
);
// Path resolution failing must be treated as an IO-fail, not
// escape _cargarEmisorasCustom: it runs inside _init()'s
// Future.wait, so an uncaught throw would also reject the
// sibling loads (populares/favoritos/grupos).
await estado.inicializar();
expect(estado.emisorasCustom, isEmpty);
});
test('JSON invalido al nivel superior pone en cuarentena el archivo '
'original (D5 parse-fail)', () async {
final archivo = await _crearArchivoCustomRaw('{bad');
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(estado.emisorasCustom, isEmpty);
final sidecar = File('${archivo.path}.corrupt');
expect(await sidecar.exists(), isTrue);
expect(await sidecar.readAsString(), '{bad');
expect(await archivo.exists(), isFalse);
});
test('agregar tras la cuarentena escribe solo la nueva emisora y no '
'toca el sidecar (D5, autoridad de escritura restaurada)', () async {
final archivo = await _crearArchivoCustomRaw('{bad');
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
iniciarAutomaticamente: false,
);
await estado.inicializar();
final sidecar = File('${archivo.path}.corrupt');
final sidecarPrevio = await sidecar.readAsString();
final nueva = emisoraDemo(uuid: 'nueva-1', nombre: 'Nueva');
await estado.agregarEmisoraCustom(nueva);
expect(estado.emisorasCustom.map((e) => e.uuid), ['nueva-1']);
final contenidoVivo = jsonDecode(await archivo.readAsString()) as List;
expect(contenidoVivo, hasLength(1));
expect((contenidoVivo.single as Map)['uuid'], 'nueva-1');
expect(await sidecar.readAsString(), sidecarPrevio);
});
test('fallo de IO al leer suprime la escritura y no se restaura con un '
'alta explicita (D5 IO-fail)', () async {
final espia = _ArchivoEspia(
path: '/fake/emisoras_custom.json',
exists: () async => true,
readAsString:
() async =>
throw const FileSystemException('fallo simulado de lectura'),
);
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => espia,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(estado.emisorasCustom, isEmpty);
await estado.agregarEmisoraCustom(
emisoraDemo(uuid: 'nueva-x', nombre: 'X'),
);
expect(estado.emisorasCustom.map((e) => e.uuid), contains('nueva-x'));
expect(espia.writeAsStringCalls, 0);
});
test('si ya existe un sidecar .corrupt no lo pisa, solo limpia el '
'archivo vivo (D5)', () async {
final archivo = await _crearArchivoCustomRaw('{bad-nuevo');
final sidecar = File('${archivo.path}.corrupt');
await sidecar.writeAsString('contenido-previo-X');
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(await sidecar.readAsString(), 'contenido-previo-X');
expect(await archivo.exists(), isFalse);
expect(estado.emisorasCustom, isEmpty);
});
});
group('EstadoRadio — Android Auto: snapshot en vivo y reconciliación '
'(android-auto-media)', () {
test('empuja un snapshot actualizado a la fuente registrada cuando '
'cambian favoritos/custom/populares', () async {
final fuenteAuto = _FuenteEmisorasAutoEspia();
final archivo = await _crearArchivoCustom([
emisoraDemo(uuid: 'custom-auto-1', nombre: 'Custom Auto'),
]);
final emisoraFav = emisoraDemo(uuid: 'fav-auto-1', nombre: 'Fav Auto');
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(
populares: [emisoraDemo(uuid: 'pop-auto-1', nombre: 'Pop Auto')],
),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(
fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid),
contains('custom-auto-1'),
);
expect(
fuenteAuto.ultimoTodas?.map((e) => e.uuid),
contains('pop-auto-1'),
);
expect(
fuenteAuto.ultimoGrupos?.map((g) => g.id),
contains(GrupoFavoritos.sinAsignarId),
);
await estado.toggleFavorito(emisoraFav);
expect(
fuenteAuto.ultimoFavoritos?.map((e) => e.uuid),
contains('fav-auto-1'),
);
});
test(
'fix android-auto-orden: el snapshot de favoritos es EXACTAMENTE '
'listaFavoritosManual (el orden manual que la pantalla Favoritos '
'muestra y reordena), nunca la lista cruda ni un getter reordenado',
() async {
final favoritosServicio = FakeServicioFavoritos();
// Agregados en orden Z, A: si el push usara un getter reordenado
// (p. ej. alfabético), 'alfa' iría primero. El orden manual conserva
// el orden de inserción/persistencia: Z, A.
await favoritosServicio.agregar(
emisoraDemo(uuid: 'zulu', nombre: 'Zulu Fav'),
);
await favoritosServicio.agregar(
emisoraDemo(uuid: 'alfa', nombre: 'Alfa Fav'),
);
final fuenteAuto = _FuenteEmisorasAutoEspia();
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: favoritosServicio,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(fuenteAuto.ultimoFavoritos?.map((e) => e.uuid).toList(), [
'zulu',
'alfa',
]);
expect(fuenteAuto.ultimoFavoritos, equals(estado.listaFavoritosManual));
},
);
test('fix android-auto-orden: el snapshot de "Todas" honra el orden '
'global (ordenListas) igual que "Tendencias" en el teléfono, no el '
'orden crudo de llegada de la API', () async {
SharedPreferences.setMockInitialValues({
'orden_listas_emisoras_v1': 'nombre',
});
final fuenteAuto = _FuenteEmisorasAutoEspia();
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(
populares: [
emisoraDemo(uuid: 'zulu-pop', nombre: 'Zulu Pop'),
emisoraDemo(uuid: 'alfa-pop', nombre: 'Alfa Pop'),
],
),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(fuenteAuto.ultimoTodas?.map((e) => e.uuid).toList(), [
'alfa-pop',
'zulu-pop',
]);
});
test('fix android-auto-orden: el snapshot de "Mis emisoras" honra el '
'orden global (ordenListas), no el orden crudo del archivo', () async {
SharedPreferences.setMockInitialValues({
'orden_listas_emisoras_v1': 'nombre',
});
final archivo = await _crearArchivoCustom([
emisoraDemo(uuid: 'zulu-custom', nombre: 'Zulu Custom'),
emisoraDemo(uuid: 'alfa-custom', nombre: 'Alfa Custom'),
]);
final fuenteAuto = _FuenteEmisorasAutoEspia();
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid).toList(), [
'alfa-custom',
'zulu-custom',
]);
});
test('fix android-auto-orden: cambiarOrdenListas re-empuja de inmediato '
'los snapshots de "Todas" y "Mis emisoras" con el nuevo orden, sin '
'esperar a la próxima recarga completa', () async {
final archivo = await _crearArchivoCustom([
emisoraDemo(uuid: 'zulu-custom', nombre: 'Zulu Custom'),
emisoraDemo(uuid: 'alfa-custom', nombre: 'Alfa Custom'),
]);
final fuenteAuto = _FuenteEmisorasAutoEspia();
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(
populares: [
emisoraDemo(uuid: 'zulu-pop', nombre: 'Zulu Pop'),
emisoraDemo(uuid: 'alfa-pop', nombre: 'Alfa Pop'),
],
),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
await estado.cambiarOrdenListas(OrdenEmisoras.nombre);
expect(fuenteAuto.ultimoTodas?.map((e) => e.uuid).toList(), [
'alfa-pop',
'zulu-pop',
]);
expect(fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid).toList(), [
'alfa-custom',
'zulu-custom',
]);
});
test('reconcilia _emisoraSeleccionada cuando la selección viene desde '
'el auto (no via reproducir())', () async {
final audio = _AudioControlado();
final estado = EstadoRadio(
esPremium: () => true,
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
final desdeCoche = emisoraDemo(
uuid: 'auto-selected',
nombre: 'Desde el auto',
);
audio.seleccionarDesdeAuto(desdeCoche);
await Future<void>.delayed(Duration.zero);
expect(estado.emisoraActual?.uuid, desdeCoche.uuid);
});
});
group('EstadoRadio — última emisora reproducida (feedback-pruebas #4)', () {
test('la última emisora reproducida sobrevive a una nueva instancia '
'(reinicio) como emisoraActual DETENIDA -- no arranca audio, no '
'reproduce, sólo queda seleccionada', () async {
final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM');
// The record is now written by the audio handler's `_cambiarFuente`
// (`GuardarUltimaEmisoraPersistida`), which is the SINGLE writer of
// `ultima_emisora_v1` and the only one that also exists on the headless
// Android Auto engine — `EstadoRadio` used to write it too and no
// longer does. Seeded through that same production function here, so
// this test covers what `EstadoRadio` actually owns (the RESTORE) with
// a real payload instead of one a fake invented. The write itself is
// covered end to end in
// `test/servicios/servicio_audio_ultima_emisora_test.dart`.
await guardarUltimaEmisoraPersistida(emisora);
final audioDos = FakeServicioAudio();
final estadoDos = EstadoRadio(
esPremium: () => true,
audio: audioDos,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoDos.inicializar();
expect(estadoDos.emisoraActual?.uuid, emisora.uuid);
expect(estadoDos.emisoraActual?.nombre, emisora.nombre);
expect(
audioDos.estaSonando,
isFalse,
reason: 'restoring the last station must never auto-start audio',
);
});
test('sin ninguna emisora previamente reproducida, emisoraActual sigue '
'siendo null tras inicializar (instalación nueva)', () async {
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(estado.emisoraActual, isNull);
});
test('una emisora seleccionada desde el auto (fuera de reproducir()) '
'deja de estar ensombrecida por la seleccion previa del telefono',
() async {
// The PERSISTENCE half of this scenario moved to the handler, which is
// the only writer that exists on a car-only session — it is covered by
// «playFromMediaId desde el coche persiste ESA emisora» in
// `test/servicios/servicio_audio_ultima_emisora_test.dart`. What
// `EstadoRadio` still owns here, and what this test now pins, is the
// shadowing fix: a car selection bypasses `reproducir()`, so without
// the `estadoStream` listener `_emisoraSeleccionada` would keep
// shadowing the car's station on the `emisoraActual` getter.
final audio = _AudioControlado();
final estado = EstadoRadio(
esPremium: () => true,
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
final desdeElTelefono = emisoraDemo(
uuid: 'phone-picked',
nombre: 'Elegida en el telefono',
);
unawaited(estado.reproducir(desdeElTelefono));
audio.completar(desdeElTelefono.uuid);
await Future<void>.delayed(Duration.zero);
expect(estado.emisoraActual?.uuid, desdeElTelefono.uuid);
final desdeCoche = emisoraDemo(
uuid: 'auto-remembered',
nombre: 'Recordada desde el auto',
);
audio.seleccionarDesdeAuto(desdeCoche);
await Future<void>.delayed(Duration.zero);
expect(
estado.emisoraActual?.uuid,
desdeCoche.uuid,
reason:
'the car changed the station without going through reproducir(); '
'the phone UI must follow it instead of keeping the previous '
'selection on screen',
);
});
});
}
/// Spy [FuenteEmisorasAuto] that only records the last snapshot pushed by
/// [EstadoRadio] via [actualizarSnapshot] — used to assert the live-snapshot
/// wiring (Design "live snapshot the source prefers") without touching the
/// real local data source.
class _FuenteEmisorasAutoEspia implements FuenteEmisorasAuto {
List<Emisora>? ultimoFavoritos;
List<Emisora>? ultimoMisEmisoras;
List<Emisora>? ultimoTodas;
List<GrupoFavoritos>? ultimoGrupos;
void actualizarSnapshot({
List<Emisora>? favoritos,
List<Emisora>? misEmisoras,
List<Emisora>? todas,
List<GrupoFavoritos>? grupos,
}) {
if (favoritos != null) ultimoFavoritos = favoritos;
if (misEmisoras != null) ultimoMisEmisoras = misEmisoras;
if (todas != null) ultimoTodas = todas;
if (grupos != null) ultimoGrupos = grupos;
}
@override
Future<List<Emisora>> favoritos() async => ultimoFavoritos ?? const [];
@override
Future<List<Emisora>> misEmisoras() async => ultimoMisEmisoras ?? const [];
@override
Future<List<Emisora>> todas() async => ultimoTodas ?? const [];
@override
Future<List<GrupoFavoritos>> grupos() async => ultimoGrupos ?? const [];
@override
Future<Emisora?> porUuid(String uuid) async => null;
}
/// [File] spy: only the members `_cargarEmisorasCustom`/
/// `_guardarEmisorasCustom` touch are implemented (with injectable
/// overrides); everything else throws via noSuchMethod. Mirrors
/// servicio_alarmas_corrupcion_test.dart's `_PrefsEspia` pattern, adapted
/// to force an IO failure independent of the host OS (persistence-
/// resilience D5).
class _ArchivoEspia implements File {
_ArchivoEspia({
required String path,
Future<bool> Function()? exists,
Future<String> Function()? readAsString,
Future<File> Function(String contenido)? writeAsString,
Future<File> Function(String nuevoPath)? rename,
}) : _path = path,
_existsImpl = exists,
_readAsStringImpl = readAsString,
_writeAsStringImpl = writeAsString,
_renameImpl = rename;
final String _path;
final Future<bool> Function()? _existsImpl;
final Future<String> Function()? _readAsStringImpl;
final Future<File> Function(String contenido)? _writeAsStringImpl;
final Future<File> Function(String nuevoPath)? _renameImpl;
int writeAsStringCalls = 0;
@override
String get path => _path;
@override
Future<bool> exists() => (_existsImpl ?? () async => true)();
@override
Future<String> readAsString({Encoding encoding = utf8}) =>
(_readAsStringImpl ?? () async => '')();
@override
Future<File> writeAsString(
String contents, {
FileMode mode = FileMode.write,
Encoding encoding = utf8,
bool flush = false,
}) {
writeAsStringCalls++;
return (_writeAsStringImpl ?? (_) async => this)(contents);
}
@override
Future<File> rename(String newPath) =>
(_renameImpl ?? (_) async => this)(newPath);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _AudioControlado extends ServicioAudio {
final _estadoController = StreamController<EstadoReproduccion>.broadcast();
final _pendientes = <String, Completer<void>>{};
Emisora? _actual;
@override
Emisora? get emisoraActual => _actual;
@override
Stream<EstadoReproduccion> get estadoStream => _estadoController.stream;
@override
Future<void> reproducir(Emisora emisora) async {
final completer = Completer<void>();
_pendientes[emisora.uuid] = completer;
_actual = emisora;
_estadoController.add(EstadoReproduccion.cargando);
await completer.future;
_estadoController.add(EstadoReproduccion.reproduciendo);
}
void completar(String uuid) {
_pendientes.remove(uuid)?.complete();
}
/// Simulates a car-initiated selection (android-auto-media task 4.3):
/// changes [emisoraActual] WITHOUT going through [reproducir], then pushes
/// an `estadoStream` event, exactly like the real handler would after
/// `playFromMediaId` bypasses `EstadoRadio.reproducir()`.
void seleccionarDesdeAuto(Emisora emisora) {
_actual = emisora;
_estadoController.add(EstadoReproduccion.reproduciendo);
}
@override
Future<void> aplicarPreset(PresetEcualizador preset) async {}
@override
Future<void> dispose() async {
await _estadoController.close();
}
}
Future<File> _archivoCustomVacio() async => _crearArchivoCustom(const []);
Future<File> _crearArchivoCustom(List<Emisora> emisoras) async {
final dir = await Directory.systemTemp.createTemp('pluriwave-test-');
final archivo = File('${dir.path}/emisoras_custom.json');
await archivo.writeAsString(
jsonEncode(emisoras.map((e) => e.toMap()).toList()),
);
return archivo;
}
/// Writes RAW (already-encoded) [contenido] to a fresh temp
/// `emisoras_custom.json` -- unlike [_crearArchivoCustom], this bypasses
/// `Emisora.toMap` so tests can seed malformed/partially-invalid JSON that
/// a real Emisora could never produce (persistence-resilience D5).
Future<File> _crearArchivoCustomRaw(String contenido) async {
final dir = await Directory.systemTemp.createTemp('pluriwave-test-');
final archivo = File('${dir.path}/emisoras_custom.json');
await archivo.writeAsString(contenido);
return archivo;
}