Files
pluriwave/test/estado/estado_radio_test.dart
T
FreeTLab 727e18737a fix(radio): persist the last-played station across restarts
EstadoRadio.emisoraActual only ever reflected in-memory state
(_emisoraSeleccionada or the live audio service), so stopping playback
and reopening the app left the Escuchar hero empty even though the
user had a station selected right before closing it.

Persist the station whenever it changes (reproducir(), and the
Android-Auto out-of-band reconciliation path) and restore it as
_emisoraSeleccionada on the next cold start, only when nothing is
already selected. This never touches the audio service directly: no
playback starts and estadoStream/estaSonando stay at their stopped
default, matching how every consumer already gates "is it playing" on
the playback-status stream rather than on emisoraActual itself.
2026-07-30 19:18:14 +02:00

883 lines
31 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/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(
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(
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(
audio: FakeServicioAudio(ecualizadorActivo: 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(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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('reconcilia _emisoraSeleccionada cuando la selección viene desde '
'el auto (no via reproducir())', () async {
final audio = _AudioControlado();
final estado = EstadoRadio(
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');
final estadoUno = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoUno.inicializar();
await estadoUno.reproducir(emisora);
await estadoUno.detenerReproduccion();
// Lets the fire-and-forget persistence write settle before
// spinning up the "restart" instance.
await Future<void>.delayed(Duration.zero);
final audioDos = FakeServicioAudio();
final estadoDos = EstadoRadio(
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(
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()) '
'también se recuerda para la próxima instancia', () async {
final audio = _AudioControlado();
final estadoUno = EstadoRadio(
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoUno.inicializar();
final desdeCoche = emisoraDemo(
uuid: 'auto-remembered',
nombre: 'Recordada desde el auto',
);
audio.seleccionarDesdeAuto(desdeCoche);
await Future<void>.delayed(Duration.zero);
final estadoDos = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoDos.inicializar();
expect(estadoDos.emisoraActual?.uuid, desdeCoche.uuid);
});
});
}
/// 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;
}