EstadoAlarmas, EstadoGrabacion and EstadoRadio defaulted `esPremium` to `() => true`, so any construction site that forgot to wire entitlement compiled fine and silently ran ungated — failing OPEN to premium and disabling the paywall with no test able to catch it. The parameter is now required with no default. Production wiring in app.dart was already correct and is unchanged; the 184 pre-existing test call sites now pass `() => true` explicitly, which is exactly the old implicit default, so every assertion is untouched. EstadoRadio has no gate of its own but constructs EstadoGrabacion, so it inherits the same contract. The one test that existed to pin the old default is renamed to describe what it still covers (the premium path through iniciar() with no duracion); its assertions are unchanged.
1044 lines
37 KiB
Dart
1044 lines
37 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(
|
|
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(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(
|
|
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');
|
|
final estadoUno = EstadoRadio(
|
|
esPremium: () => true,
|
|
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(
|
|
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()) '
|
|
'también se recuerda para la próxima instancia', () async {
|
|
final audio = _AudioControlado();
|
|
final estadoUno = EstadoRadio(
|
|
esPremium: () => true,
|
|
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(
|
|
esPremium: () => true,
|
|
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;
|
|
}
|