import 'package:audio_service/audio_service.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/modelos/emisora.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 3 carpetas no reproducibles con los ids ' 'esperados', () { final raiz = ConstructorArbolAuto().raiz(); expect(raiz, hasLength(3)); final ids = raiz.map((item) => item.id).toSet(); expect( ids, equals({ ConstructorArbolAuto.idFavoritos, ConstructorArbolAuto.idTodas, ConstructorArbolAuto.idMisEmisoras, }), ); for (final item in raiz) { expect(item.playable, isFalse); expect(item.title, isNotEmpty); } }); }); 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('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, }) { return Emisora( uuid: uuid, nombre: nombre, url: url, favicon: favicon, codec: codec, bitrate: bitrate, ); } 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 void actualizarSnapshot({ List? favoritos, List? misEmisoras, List? todas, }) {} }