import 'package:audio_service/audio_service.dart'; import 'package:flutter_test/flutter_test.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'; void main() { group('faviconUsable', () { test('rechaza favicons malformados o no http(s), acepta http(s) válidos', () { final casos = { null: false, '': false, ' ': false, 'ftp://cdn.example.com/logo.png': false, 'cdn.example.com/logo.png': false, 'http://': false, '/relative/path/logo.png': false, 'not a url at all \$\$\$ ///': false, 'http://cdn.example.com/logo.png': true, 'https://cdn.example.com/logo.png': true, }; casos.forEach((entrada, esperado) { expect( faviconUsable(entrada), esperado, reason: 'faviconUsable(${entrada.toString()}) debería ser $esperado', ); }); }); }); group('indiceArtePara', () { test('reproduce la misma fórmula que _fallbackArtFor', () { const seeds = ['', 'a', 'uuid-1234-abcd-real-looking']; for (final seed in seeds) { final esperado = seed.codeUnits.fold(0, (a, b) => a + b) % 4; expect(indiceArtePara(seed), esperado); } }); test('el orden canónico de nombres es aurora, cosmic, pulse, nova ' '(verificado vía artUriPara con seeds que cubren los 4 índices)', () { // Seeds de una sola letra cuyo codeUnit % 4 cubre los 4 índices, en // el mismo orden que tarjeta_emisora.dart's _fallbackArtFor: // 'd'(100)->índice 0 aurora, 'a'(97)->índice 1 cosmic, // 'b'(98)->índice 2 pulse, 'c'(99)->índice 3 nova. const indicesEsperados = {'d': 0, 'a': 1, 'b': 2, 'c': 3}; const nombresPorIndice = ['aurora', 'cosmic', 'pulse', 'nova']; indicesEsperados.forEach((seed, indiceEsperado) { expect(indiceArtePara(seed), indiceEsperado); final emisora = _emisora(uuid: seed, nombre: 'Radio $seed'); expect( artUriPara(emisora), 'android.resource://es.freetimelab.pluriwave/drawable/station_art_' '${nombresPorIndice[indiceEsperado]}', ); }); }); }); group('artUriPara', () { String nombreEsperadoPara(String uuid) { final indice = indiceArtePara(uuid); const nombres = ['aurora', 'cosmic', 'pulse', 'nova']; return nombres[indice]; } test('favicon https válido se usa como artUri', () { final emisora = _emisora( uuid: 'uuid-logo', nombre: 'Radio Con Logo', favicon: 'https://cdn.example.com/logo.png', ); expect(artUriPara(emisora), emisora.favicon); }); test('favicon null cae al drawable rotativo correspondiente', () { final emisora = _emisora(uuid: 'uuid-null', nombre: 'Radio Sin Logo'); final nombre = nombreEsperadoPara(emisora.uuid); expect( artUriPara(emisora), 'android.resource://es.freetimelab.pluriwave/drawable/station_art_$nombre', ); }); test('favicon vacío cae al mismo comportamiento rotativo que null', () { final emisora = _emisora( uuid: 'uuid-vacio', nombre: 'Radio Logo Vacio', favicon: '', ); final nombre = nombreEsperadoPara(emisora.uuid); expect( artUriPara(emisora), 'android.resource://es.freetimelab.pluriwave/drawable/station_art_$nombre', ); }); test('favicon malformado cae al mismo comportamiento rotativo', () { final malformados = [ _emisora(uuid: 'uuid-m1', nombre: 'M1', favicon: 'ftp://x/y.png'), _emisora(uuid: 'uuid-m2', nombre: 'M2', favicon: 'not a url'), ]; for (final emisora in malformados) { final nombre = nombreEsperadoPara(emisora.uuid); expect( artUriPara(emisora), 'android.resource://es.freetimelab.pluriwave/drawable/station_art_$nombre', ); } }); test('uuids distintos que cayeron en índices distintos resuelven a ' 'artUriPara distintos', () { final a = _emisora(uuid: 'uuid-a', nombre: 'A'); final b = _emisora(uuid: 'uuid-bbbb', nombre: 'B'); // Solo tiene sentido si de hecho hashean a índices distintos. if (indiceArtePara(a.uuid) != indiceArtePara(b.uuid)) { expect(artUriPara(a), isNot(equals(artUriPara(b)))); } }); test('el mismo uuid llamado dos veces da resultado idéntico', () { final emisora = _emisora(uuid: 'uuid-determinista', nombre: 'Det'); expect(artUriPara(emisora), artUriPara(emisora)); }); }); group('subtituloCalidad', () { test('matriz de codec/bitrate conocidos y desconocidos', () { expect( subtituloCalidad(_emisora(uuid: 'u1', nombre: 'N', codec: 'mp3', bitrate: 128)), '128 kbps · MP3', ); expect( subtituloCalidad(_emisora(uuid: 'u2', nombre: 'N', codec: null, bitrate: 128)), '128 kbps', ); expect( subtituloCalidad(_emisora(uuid: 'u3', nombre: 'N', codec: 'mp3', bitrate: null)), 'MP3', ); expect( subtituloCalidad(_emisora(uuid: 'u4', nombre: 'N', codec: 'mp3', bitrate: 0)), 'MP3', ); expect( subtituloCalidad(_emisora(uuid: 'u5', nombre: 'N', codec: null, bitrate: null)), isNull, ); expect( subtituloCalidad(_emisora(uuid: 'u6', nombre: 'N', codec: null, bitrate: 0)), isNull, ); expect( subtituloCalidad( _emisora(uuid: 'u7', nombre: 'N', codec: ' ', bitrate: null), ), isNull, ); expect( subtituloCalidad( _emisora(uuid: 'u8', nombre: 'N', codec: ' mp3 ', bitrate: 128), ), '128 kbps · MP3', ); }); test('ningún resultado contiene la subcadena "null"', () { final casos = [ _emisora(uuid: 'u1', nombre: 'N', codec: 'mp3', bitrate: 128), _emisora(uuid: 'u2', nombre: 'N', codec: null, bitrate: 128), _emisora(uuid: 'u3', nombre: 'N', codec: 'mp3', bitrate: null), _emisora(uuid: 'u4', nombre: 'N', codec: null, bitrate: null), ]; for (final emisora in casos) { final resultado = subtituloCalidad(emisora); expect(resultado?.contains('null') ?? false, isFalse); } }); }); group('parity: phone/auto art order', () { test('el orden de nombres de arte coincide con tarjeta_emisora.dart', () { // Orden canónico hardcodeado una única vez aquí (mismo patrón que // tarjeta_emisora.dart:363-367's _fallbackArtFor); debe mantenerse // sincronizado manualmente si esa lista cambia. _nombresArte es // privado en navegacion_auto.dart, así que este test verifica el // orden indirectamente vía artUriPara con seeds que cubren los 4 // índices (mismo patrón que el segundo test de 'indiceArtePara'). const ordenCanonico = ['aurora', 'cosmic', 'pulse', 'nova']; const seedsPorIndice = ['d', 'a', 'b', 'c']; for (var indice = 0; indice < ordenCanonico.length; indice++) { final emisora = _emisora( uuid: seedsPorIndice[indice], nombre: 'Radio ${seedsPorIndice[indice]}', ); expect(indiceArtePara(emisora.uuid), indice); expect( artUriPara(emisora), 'android.resource://es.freetimelab.pluriwave/drawable/station_art_' '${ordenCanonico[indice]}', ); } }); }); group('ConstructorArbolAuto.raiz', () { test('devuelve exactamente 4 carpetas no reproducibles con los ids ' 'esperados, terminando en Ecualizador', () { final raiz = ConstructorArbolAuto().raiz(); expect(raiz, hasLength(4)); final ids = raiz.map((item) => item.id).toSet(); expect( ids, equals({ ConstructorArbolAuto.idFavoritos, ConstructorArbolAuto.idTodas, ConstructorArbolAuto.idMisEmisoras, ConstructorArbolAuto.idEcualizador, }), ); for (final item in raiz) { expect(item.playable, isFalse); expect(item.title, isNotEmpty); } expect(raiz.last.id, ConstructorArbolAuto.idEcualizador); expect(raiz.last.playable, isFalse); }); }); group('esPresetMediaId', () { test('reconoce ids con el prefijo eq_preset:, rechaza el resto', () { final casos = { 'eq_preset:Rock': true, 'eq_preset:': true, 'emisora:x': false, 'grupo:g1': false, '': false, }; casos.forEach((id, esperado) { expect( esPresetMediaId(id), esperado, reason: 'esPresetMediaId($id) debería ser $esperado', ); }); }); }); group('ConstructorArbolAuto.itemPresetEq', () { test('mapea un PresetEcualizador a un item reproducible con id ' 'eq_preset:', () { final item = ConstructorArbolAuto().itemPresetEq( PresetEcualizador.rock, ); expect(item.id, 'eq_preset:${PresetEcualizador.rock.nombre}'); expect(item.playable, isTrue); expect(item.title, PresetEcualizador.rock.nombre); }); }); group('ConstructorArbolAuto.presetsEq', () { test('devuelve exactamente 6 items reproducibles, uno por preset', () { final items = ConstructorArbolAuto().presetsEq( PresetEcualizador.presets, ); expect(items, hasLength(6)); for (var i = 0; i < items.length; i++) { expect(items[i].id, 'eq_preset:${PresetEcualizador.presets[i].nombre}'); expect(items[i].title, PresetEcualizador.presets[i].nombre); expect(items[i].playable, isTrue); } }); }); group('ConstructorArbolAuto.itemEmisora', () { test('usa el favicon remoto como artUri cuando existe', () { final emisora = _emisora( uuid: 'uuid-logo', nombre: 'Radio Con Logo', favicon: 'https://cdn.example.com/logo.png', ); final item = ConstructorArbolAuto().itemEmisora(emisora); expect(item.id, 'emisora:${emisora.uuid}'); expect(item.title, emisora.nombre); expect(item.artUri.toString(), emisora.favicon); expect(item.playable, isTrue); }); test('cae al drawable rotativo correspondiente cuando el favicon es ' 'null o vacío', () { final sinFavicon = _emisora( uuid: 'uuid-null', nombre: 'Radio Sin Logo', favicon: null, ); final faviconVacio = _emisora( uuid: 'uuid-vacio', nombre: 'Radio Logo Vacio', favicon: '', ); final builder = ConstructorArbolAuto(); expect( builder.itemEmisora(sinFavicon).artUri.toString(), artUriPara(sinFavicon), ); expect( builder.itemEmisora(faviconVacio).artUri.toString(), artUriPara(faviconVacio), ); }); test('setea displaySubtitle desde subtituloCalidad cuando el codec y ' 'bitrate son conocidos', () { final emisora = _emisora( uuid: 'uuid-calidad', nombre: 'Radio Calidad', codec: 'mp3', bitrate: 128, ); final item = ConstructorArbolAuto().itemEmisora(emisora); expect(item.displaySubtitle, '128 kbps · MP3'); }); test('omite displaySubtitle (queda null) cuando codec y bitrate son ' 'ambos desconocidos', () { final emisora = _emisora( uuid: 'uuid-sin-calidad', nombre: 'Radio Sin Calidad', ); final item = ConstructorArbolAuto().itemEmisora(emisora); expect(item.displaySubtitle, isNull); }); }); group('artUriPara: favicon sin sanear desde Emisora.fromMap', () { test('un favicon malformado proveniente de favoritos (SQLite) resuelve ' 'al drawable rotativo, no al string malformado', () { // Emisora.fromMap (favoritos) no sanea favicon (a diferencia de // Emisora.fromApi), así que valores en blanco/malformados llegan sin // filtrar. Este test fija que la validez-gate de artUriPara es el // punto que efectivamente los atrapa. final desdeMapa = Emisora.fromMap({ 'uuid': 'uuid-favorito', 'nombre': 'Radio Favorita', 'url': 'https://stream.demo/radio', 'favicon': ' ', }); final resultado = artUriPara(desdeMapa); expect(resultado, isNot(equals(' '))); expect( resultado, startsWith('android.resource://es.freetimelab.pluriwave/drawable/station_art_'), ); }); }); group('ConstructorArbolAuto.hijos', () { test('limita a 50 items y respeta el orden de ordenarEmisoras', () { final emisoras = List.generate( 60, (i) => _emisora(uuid: 'uuid-$i', nombre: 'Radio $i', bitrate: i), ); final hijos = ConstructorArbolAuto().hijos( ConstructorArbolAuto.idFavoritos, emisoras: emisoras, ); expect(hijos, hasLength(50)); // ordenarEmisoras por calidad ordena por bitrate descendente: el // primer item debe ser el de mayor bitrate (59) y el ultimo (el #50) // el de bitrate 10 (59..10 son 50 valores). expect(hijos.first.id, 'emisora:uuid-59'); expect(hijos.last.id, 'emisora:uuid-10'); }); test('lista vacía cuando no hay emisoras, sin lanzar', () { final hijos = ConstructorArbolAuto().hijos( ConstructorArbolAuto.idFavoritos, emisoras: const [], ); expect(hijos, isEmpty); }); test('parentId desconocido devuelve lista vacía', () { final emisoras = [_emisora(uuid: 'uuid-1', nombre: 'Radio 1')]; final hijos = ConstructorArbolAuto().hijos( 'carpeta-inexistente', emisoras: emisoras, ); expect(hijos, isEmpty); }); }); group('ConstructorArbolAuto.resolver', () { test('resuelve un uuid conocido a su Emisora', () { final emisora = _emisora(uuid: 'uuid-conocido', nombre: 'Conocida'); final universo = [emisora, _emisora(uuid: 'otra', nombre: 'Otra')]; final resultado = ConstructorArbolAuto().resolver( 'emisora:uuid-conocido', universo, ); expect(resultado, equals(emisora)); }); test( 'devuelve null para id sin prefijo, id malformado o uuid sin match, ' 'sin lanzar', () { final universo = [_emisora(uuid: 'uuid-conocido', nombre: 'Conocida')]; final builder = ConstructorArbolAuto(); expect(builder.resolver('favoritos', universo), isNull); expect(builder.resolver('emisora:', universo), isNull); expect( builder.resolver('emisora:uuid-desconocido', universo), isNull, ); }, ); }); group('ConstructorArbolAuto.esCarpetaGrupo', () { test('reconoce ids con el prefijo grupo:, rechaza el resto', () { final builder = ConstructorArbolAuto(); expect(builder.esCarpetaGrupo('grupo:g1'), isTrue); expect(builder.esCarpetaGrupo('favoritos'), isFalse); expect(builder.esCarpetaGrupo('emisora:x'), isFalse); expect(builder.esCarpetaGrupo(''), isFalse); }); }); group('ConstructorArbolAuto.itemGrupo', () { test('mapea un GrupoFavoritos a una carpeta no reproducible con id ' 'grupo:', () { final grupo = _grupo(id: 'g1', nombre: 'Rock'); final item = ConstructorArbolAuto().itemGrupo(grupo); expect(item.id, 'grupo:g1'); expect(item.playable, isFalse); expect(item.title, grupo.nombre); }); }); group('ConstructorArbolAuto.carpetasFavoritos', () { test('sin grupos personalizados, el resultado es idéntico a hijos ' '(idFavoritos, emisoras: favoritos) — regresión más importante de ' 'este cambio', () { final favoritos = [ _emisora(uuid: 'uuid-1', nombre: 'Radio 1', bitrate: 128), _emisora(uuid: 'uuid-2', nombre: 'Radio 2', bitrate: 64), ]; final builder = ConstructorArbolAuto(); final resultado = builder.carpetasFavoritos( grupos: const [ GrupoFavoritos(id: GrupoFavoritos.sinAsignarId, nombre: 'Sin asignar', orden: 0), ], favoritos: favoritos, ); final esperado = builder.hijos( ConstructorArbolAuto.idFavoritos, emisoras: favoritos, ); expect(resultado.map((i) => i.id).toList(), esperado.map((i) => i.id).toList()); expect(resultado.length, esperado.length); for (var i = 0; i < resultado.length; i++) { expect(resultado[i].id, esperado[i].id); expect(resultado[i].playable, esperado[i].playable); expect(resultado[i].title, esperado[i].title); } }); test('con un grupo personalizado no vacío, devuelve la carpeta grupo: ' 'seguida de las emisoras sin asignar como hojas', () { final rock = _grupo(id: 'g-rock', nombre: 'Rock', orden: 1); final sinAsignar = const GrupoFavoritos( id: GrupoFavoritos.sinAsignarId, nombre: 'Sin asignar', orden: 0, ); final favoritos = [ _emisora(uuid: 'uuid-rock', nombre: 'Radio Rock', grupoId: 'g-rock'), _emisora(uuid: 'uuid-suelta', nombre: 'Radio Suelta'), ]; final resultado = ConstructorArbolAuto().carpetasFavoritos( grupos: [sinAsignar, rock], favoritos: favoritos, ); expect(resultado, hasLength(2)); expect(resultado[0].id, 'grupo:g-rock'); expect(resultado[0].playable, isFalse); expect(resultado[1].id, 'emisora:uuid-suelta'); expect(resultado[1].playable, isTrue); }); test('un grupo personalizado vacío se omite; sin_asignar nunca aparece ' 'como carpeta propia aunque esté en la lista de grupos', () { final vacio = _grupo(id: 'g-vacio', nombre: 'Vacío', orden: 1); final sinAsignar = const GrupoFavoritos( id: GrupoFavoritos.sinAsignarId, nombre: 'Sin asignar', orden: 0, ); final favoritos = [_emisora(uuid: 'uuid-1', nombre: 'Radio 1')]; final resultado = ConstructorArbolAuto().carpetasFavoritos( grupos: [sinAsignar, vacio], favoritos: favoritos, ); expect(resultado.map((i) => i.id), isNot(contains('grupo:g-vacio'))); expect( resultado.map((i) => i.id), isNot(contains('grupo:${GrupoFavoritos.sinAsignarId}')), ); expect(resultado.map((i) => i.id), contains('emisora:uuid-1')); }); test('más de 50 grupos elegibles no vacíos se truncan a 50', () { final favoritos = List.generate( 60, (i) => _emisora(uuid: 'uuid-$i', nombre: 'Radio $i', grupoId: 'g-$i'), ); final grupos = List.generate( 60, (i) => _grupo(id: 'g-$i', nombre: 'Grupo $i', orden: i), ); final resultado = ConstructorArbolAuto().carpetasFavoritos( grupos: grupos, favoritos: favoritos, ); final carpetas = resultado.where((i) => i.playable != true).toList(); expect(carpetas, hasLength(50)); }); }); group('ConstructorArbolAuto.hijosGrupo', () { test('filtra por grupoFavoritosId, ordena y capea a 50; id desconocido ' 'devuelve lista vacía', () { final favoritos = List.generate( 60, (i) => _emisora( uuid: 'uuid-$i', nombre: 'Radio $i', bitrate: i, grupoId: 'g-rock', ), ); final hijos = ConstructorArbolAuto().hijosGrupo( 'grupo:g-rock', favoritos: favoritos, ); expect(hijos, hasLength(50)); expect(hijos.first.id, 'emisora:uuid-59'); expect(hijos.last.id, 'emisora:uuid-10'); final vacio = ConstructorArbolAuto().hijosGrupo( 'grupo:g-inexistente', favoritos: favoritos, ); expect(vacio, isEmpty); }); }); group('ConstructorArbolAuto.resolver: colisión con esquema grupo:', () { test('un id grupo: nunca resuelve a una Emisora', () { final universo = [_emisora(uuid: 'g1', nombre: 'No debería matchear')]; final resultado = ConstructorArbolAuto().resolver('grupo:g1', universo); expect(resultado, isNull); }); }); group('resolverPresetEq', () { test('resuelve un nombre conocido al preset exacto', () { final resultado = resolverPresetEq( 'eq_preset:${PresetEcualizador.rock.nombre}', PresetEcualizador.presets, ); expect(resultado, equals(PresetEcualizador.rock)); }); test( 'devuelve null para nombre desconocido, id vacío-tras-prefijo o id sin ' 'prefijo eq_preset:, sin lanzar', () { expect( resolverPresetEq('eq_preset:Inexistente', PresetEcualizador.presets), isNull, ); expect( resolverPresetEq('eq_preset:', PresetEcualizador.presets), isNull, ); expect( resolverPresetEq('emisora:x', PresetEcualizador.presets), isNull, ); }, ); }); group('debeAplicarPrincipalAhora', () { test('true cuando no hay estación actual', () { expect( debeAplicarPrincipalAhora( uuidActual: null, clavesPorEmisora: const {'uuid-otra'}, ), isTrue, ); }); test('true cuando la estación actual no tiene override por-emisora', () { expect( debeAplicarPrincipalAhora( uuidActual: 'uuid-sin-override', clavesPorEmisora: const {'uuid-otra'}, ), isTrue, ); }); test('false cuando la estación actual tiene override por-emisora', () { expect( debeAplicarPrincipalAhora( uuidActual: 'uuid-con-override', clavesPorEmisora: const {'uuid-con-override'}, ), isFalse, ); }); }); group('aplicarPresetPorMediaId', () { test( 'preset conocido: persiste siempre y aplica cuando el gate es true', () async { PresetEcualizador? persistido; PresetEcualizador? aplicado; await aplicarPresetPorMediaId( 'eq_preset:${PresetEcualizador.rock.nombre}', presets: PresetEcualizador.presets, uuidActual: null, clavesPorEmisora: () async => {}, persistirPrincipal: (preset) async { persistido = preset; }, aplicar: (preset) async { aplicado = preset; }, ); expect(persistido, equals(PresetEcualizador.rock)); expect(aplicado, equals(PresetEcualizador.rock)); }, ); test( 'preset conocido con override por-emisora en la estación actual: ' 'persiste pero NO aplica en vivo (gate false)', () async { var persistirLlamadas = 0; var aplicarLlamadas = 0; await aplicarPresetPorMediaId( 'eq_preset:${PresetEcualizador.jazz.nombre}', presets: PresetEcualizador.presets, uuidActual: 'uuid-con-override', clavesPorEmisora: () async => {'uuid-con-override'}, persistirPrincipal: (preset) async { persistirLlamadas++; }, aplicar: (preset) async { aplicarLlamadas++; }, ); expect(persistirLlamadas, 1); expect(aplicarLlamadas, 0); }, ); test( 'id desconocido/obsoleto: no persiste ni aplica, no lanza excepción', () async { var persistirLlamadas = 0; var aplicarLlamadas = 0; await aplicarPresetPorMediaId( 'eq_preset:Inexistente', presets: PresetEcualizador.presets, uuidActual: null, clavesPorEmisora: () async => {}, persistirPrincipal: (preset) async { persistirLlamadas++; }, aplicar: (preset) async { aplicarLlamadas++; }, ); expect(persistirLlamadas, 0); expect(aplicarLlamadas, 0); }, ); test( 'invariante estructural: la firma solo expone seams de ' 'persistencia/aplicación EQ, ningun seam de reproducción es ' 'inyectable (ADR-3, no-playback-by-construction)', () async { // Este test documenta y prueba por construcción que // aplicarPresetPorMediaId no puede tocar playback: los únicos // parámetros de función inyectables en su firma son // persistirPrincipal y aplicar (ambos EQ-only). No existe ningún // parámetro playMediaItem/mediaItem/playbackState que un caller // pueda pasar — de haberlo, este call site fallaría a compilar. final llamadasAplicar = []; final llamadasPersistir = []; await aplicarPresetPorMediaId( 'eq_preset:${PresetEcualizador.pop.nombre}', presets: PresetEcualizador.presets, uuidActual: null, clavesPorEmisora: () async => {}, persistirPrincipal: (preset) async => llamadasPersistir.add(preset), aplicar: (preset) async => llamadasAplicar.add(preset), // NOTE: no `playMediaItem`/`mediaItem`/`playbackState` parameter // exists on this function — there is nothing to pass here even // if a caller wanted to. That absence IS the non-playback proof. ); expect(llamadasPersistir, [PresetEcualizador.pop]); expect(llamadasAplicar, [PresetEcualizador.pop]); }, ); }); group('reproducirPorMediaId', () { test( 'resuelve el id y delega a reproducir con un MediaItem con forma de ' 'telefono (id=url, extras.uuid)', () async { final emisora = _emisora( uuid: 'uuid-play', nombre: 'Radio a reproducir', url: 'https://stream.demo/play', ); final fuente = _FakeFuenteEmisorasAuto( porUuidResultado: {emisora.uuid: emisora}, ); MediaItem? recibido; await reproducirPorMediaId( 'emisora:${emisora.uuid}', fuente: fuente, reproducir: (item) async { recibido = item; }, ); expect(recibido, isNotNull); expect(recibido!.id, emisora.url); expect(recibido!.extras?['uuid'], emisora.uuid); }, ); test( 'id obsoleto/desconocido no llama a reproducir ni lanza excepción', () async { final fuente = _FakeFuenteEmisorasAuto(porUuidResultado: const {}); var llamadas = 0; await reproducirPorMediaId( 'emisora:uuid-fantasma', fuente: fuente, reproducir: (item) async { llamadas++; }, ); expect(llamadas, 0); }, ); }); } Emisora _emisora({ required String uuid, required String nombre, String url = 'https://stream.demo/radio', String? favicon, String? codec, int? bitrate, String grupoId = GrupoFavoritos.sinAsignarId, }) { return Emisora( uuid: uuid, nombre: nombre, url: url, favicon: favicon, codec: codec, bitrate: bitrate, grupoFavoritosId: grupoId, ); } GrupoFavoritos _grupo({ required String id, required String nombre, int orden = 0, }) { return GrupoFavoritos(id: id, nombre: nombre, orden: orden); } class _FakeFuenteEmisorasAuto implements FuenteEmisorasAuto { _FakeFuenteEmisorasAuto({required Map porUuidResultado}) : _porUuidResultado = porUuidResultado; final Map _porUuidResultado; @override Future> favoritos() async => const []; @override Future> misEmisoras() async => const []; @override Future> todas() async => const []; @override Future porUuid(String uuid) async => _porUuidResultado[uuid]; @override Future> grupos() async => const []; @override void actualizarSnapshot({ List? favoritos, List? misEmisoras, List? todas, List? grupos, }) {} }