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.
This commit is contained in:
2026-07-30 19:18:14 +02:00
parent c6ab295c54
commit 727e18737a
2 changed files with 339 additions and 229 deletions
+284 -229
View File
@@ -412,259 +412,314 @@ void main() {
);
});
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',
});
},
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,
);
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,
);
await estado.inicializar();
// 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, hasLength(2));
expect(estado.emisorasCustom.map((e) => e.uuid).toSet(), {
'custom-1',
'custom-2',
});
});
expect(estado.emisorasCustom, isEmpty);
},
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,
);
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,
);
// 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();
await estado.inicializar();
expect(estado.emisorasCustom, isEmpty);
});
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('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,
);
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();
await estado.inicializar();
final nueva = emisoraDemo(uuid: 'nueva-1', nombre: 'Nueva');
await estado.agregarEmisoraCustom(nueva);
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);
});
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('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,
);
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.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);
},
await estado.agregarEmisoraCustom(
emisoraDemo(uuid: 'nueva-x', nombre: 'X'),
);
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');
expect(estado.emisorasCustom.map((e) => e.uuid), contains('nueva-x'));
expect(espia.writeAsStringCalls, 0);
});
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
iniciarAutomaticamente: false,
);
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');
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'),
);
},
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
iniciarAutomaticamente: false,
);
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',
);
await estado.inicializar();
audio.seleccionarDesdeAuto(desdeCoche);
await Future<void>.delayed(Duration.zero);
expect(await sidecar.readAsString(), 'contenido-previo-X');
expect(await archivo.exists(), isFalse);
expect(estado.emisorasCustom, isEmpty);
});
});
expect(estado.emisoraActual?.uuid, desdeCoche.uuid);
},
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