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/pista_local.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/servicios/musica_local_auto.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( 'con incluirMusicaLocal: true devuelve exactamente 5 carpetas no ' 'reproducibles con los ids esperados, Música Local en 4to lugar, ' 'terminando en Ecualizador', () { final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: true); expect(raiz, hasLength(5)); final ids = raiz.map((item) => item.id).toSet(); expect( ids, equals({ ConstructorArbolAuto.idFavoritos, ConstructorArbolAuto.idTodas, ConstructorArbolAuto.idMisEmisoras, ConstructorArbolAuto.idMusicaLocal, ConstructorArbolAuto.idEcualizador, }), ); for (final item in raiz) { expect(item.playable, isFalse); expect(item.title, isNotEmpty); } expect(raiz[3].id, ConstructorArbolAuto.idMusicaLocal); expect(raiz.last.id, ConstructorArbolAuto.idEcualizador); expect(raiz.last.playable, isFalse); }, ); test( 'con incluirMusicaLocal: false devuelve exactamente 4 carpetas, ' 'byte-idéntico al comportamiento previo al cambio (regresión) — ' 'Música Local queda OCULTA, no vacía', () { final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: false); expect(raiz, hasLength(4)); final ids = raiz.map((item) => item.id).toSet(); expect( ids, equals({ ConstructorArbolAuto.idFavoritos, ConstructorArbolAuto.idTodas, ConstructorArbolAuto.idMisEmisoras, ConstructorArbolAuto.idEcualizador, }), ); expect(ids, isNot(contains(ConstructorArbolAuto.idMusicaLocal))); 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('esCarpetaLocalMediaId / esPistaMediaId', () { test( 'reconocen sus prefijos, rechazan el resto y no colisionan entre sí ' 'ni con emisora:/grupo:/eq_preset:/los ids de carpeta fijos', () { final builder = ConstructorArbolAuto(); expect(builder.esCarpetaLocalMediaId('carpeta_local:doc1'), isTrue); expect(builder.esCarpetaLocalMediaId('carpeta_local:'), isTrue); expect(esPistaMediaId('pista:doc1'), isTrue); expect(esPistaMediaId('pista:'), isTrue); final noLocales = [ 'emisora:x', 'grupo:g1', 'eq_preset:Rock', ConstructorArbolAuto.idFavoritos, ConstructorArbolAuto.idTodas, ConstructorArbolAuto.idMisEmisoras, ConstructorArbolAuto.idEcualizador, ConstructorArbolAuto.idMusicaLocal, '', ]; for (final id in noLocales) { expect( builder.esCarpetaLocalMediaId(id), isFalse, reason: 'esCarpetaLocalMediaId($id) debería ser false', ); expect( esPistaMediaId(id), isFalse, reason: 'esPistaMediaId($id) debería ser false', ); } // carpeta_local: y pista: nunca colisionan entre sí. expect(builder.esCarpetaLocalMediaId('pista:doc1'), isFalse); expect(esPistaMediaId('carpeta_local:doc1'), isFalse); }, ); test( 'idCarpetaLocalDesde recorta por longitud, preservando un documentId ' 'que contiene ":" (SAF documentIds reales, p.ej. ' '"primary:Music/Local")', () { final builder = ConstructorArbolAuto(); const docId = 'primary:Music/Local'; expect(builder.idCarpetaLocalDesde('carpeta_local:$docId'), docId); }, ); }); group('artUriLocal', () { test( 'reproduce la misma rotación que artUriPara/indiceArtePara, sembrada ' 'por documentId en vez de por uuid', () { const documentIds = ['doc-a', 'doc-b', 'doc-c', 'doc-d']; const nombresPorIndice = ['aurora', 'cosmic', 'pulse', 'nova']; for (final docId in documentIds) { final indice = indiceArtePara(docId); expect( artUriLocal(docId), 'android.resource://es.freetimelab.pluriwave/drawable/' 'station_art_${nombresPorIndice[indice]}', ); } }, ); }); group('ConstructorArbolAuto.itemsLocales', () { test('ordena alfabéticamente por nombre y capea a 50', () { final nodos = List.generate( 60, (i) => NodoLocal( documentId: 'doc-$i', nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', esDirectorio: false, ), )..shuffle(); final items = ConstructorArbolAuto().itemsLocales(nodos); expect(items, hasLength(50)); expect(items.first.title, 'cancion_00'); expect(items[1].title, 'cancion_01'); }); test('subcarpeta vacía devuelve lista vacía, no un error', () { expect(ConstructorArbolAuto().itemsLocales(const []), isEmpty); }); test( 'una carpeta mapea a un item no reproducible carpeta_local: con ' 'el nombre crudo (sin recortar extensión)', () { const nodo = NodoLocal( documentId: 'doc-carpeta', nombre: 'Mi Carpeta', esDirectorio: true, ); final item = ConstructorArbolAuto().itemsLocales([nodo]).single; expect(item.id, 'carpeta_local:doc-carpeta'); expect(item.playable, isFalse); expect(item.title, 'Mi Carpeta'); }, ); test( 'un archivo mapea a un item reproducible pista: con título = ' 'nombre sin la última extensión', () { const nodo = NodoLocal( documentId: 'doc-pista', nombre: 'Cancion Genial.mp3', esDirectorio: false, ); final item = ConstructorArbolAuto().itemsLocales([nodo]).single; expect(item.id, 'pista:doc-pista'); expect(item.playable, isTrue); expect(item.title, 'Cancion Genial'); }, ); test('un archivo sin punto en el nombre conserva el nombre completo', () { const nodo = NodoLocal( documentId: 'doc-sin-ext', nombre: 'CancionSinExtension', esDirectorio: false, ); final item = ConstructorArbolAuto().itemsLocales([nodo]).single; expect(item.title, 'CancionSinExtension'); }); test( 'un archivo con nombre en blanco cae al título de reserva, nunca ' 'queda vacío', () { for (final nombre in ['', ' ']) { final nodo = NodoLocal( documentId: 'doc-blanco', nombre: nombre, esDirectorio: false, ); final item = ConstructorArbolAuto().itemsLocales([nodo]).single; expect(item.title, isNotEmpty); } }, ); test( 'un archivo cuyo nombre es solo la extensión (punto en posición 0, ' 'p.ej. ".mp3") conserva el nombre completo en vez de recortar a ' 'blanco', () { const nodo = NodoLocal( documentId: 'doc-oculto', nombre: '.mp3', esDirectorio: false, ); final item = ConstructorArbolAuto().itemsLocales([nodo]).single; expect(item.title, '.mp3'); }, ); test( 'el artUri de un archivo usa la misma rotación que artUriLocal, ' 'sembrada con el documentId', () { const nodo = NodoLocal( documentId: 'doc-arte', nombre: 'Cancion.mp3', esDirectorio: false, ); final item = ConstructorArbolAuto().itemsLocales([nodo]).single; expect(item.artUri.toString(), artUriLocal('doc-arte')); expect( item.artUri.toString(), startsWith( 'android.resource://es.freetimelab.pluriwave/drawable/' 'station_art_', ), ); }, ); }); group('hijosMusicaLocal', () { test( "id musica_local delega en fuente.hijos('') y mapea vía itemsLocales", () async { final fuente = _FakeFuenteMusicaLocalAuto( hijosPorDocId: { '': const [ NodoLocal( documentId: 'd1', nombre: 'Cancion.mp3', esDirectorio: false, ), ], }, ); final items = await hijosMusicaLocal( ConstructorArbolAuto.idMusicaLocal, fuente: fuente, ); expect(items, isNotNull); expect(items!.single.id, 'pista:d1'); }, ); test( 'id carpeta_local: recorta el prefijo y delega en ' 'fuente.hijos(id)', () async { final fuente = _FakeFuenteMusicaLocalAuto( hijosPorDocId: { 'sub1': const [ NodoLocal( documentId: 'd2', nombre: 'Otra.mp3', esDirectorio: false, ), ], }, ); final items = await hijosMusicaLocal( 'carpeta_local:sub1', fuente: fuente, ); expect(items, isNotNull); expect(items!.single.id, 'pista:d2'); }, ); test( 'un id que no es de música local devuelve null (deja pasar al ' 'siguiente branch del caller)', () async { final fuente = _FakeFuenteMusicaLocalAuto(); expect(await hijosMusicaLocal('favoritos', fuente: fuente), isNull); expect(await hijosMusicaLocal('grupo:g1', fuente: fuente), isNull); }, ); test( 'fuente null (cold-start, nunca registrada) devuelve lista vacía ' 'para un id de música local válido, no null y sin lanzar', () async { final resultado = await hijosMusicaLocal( ConstructorArbolAuto.idMusicaLocal, fuente: null, ); expect(resultado, isNotNull); expect(resultado, isEmpty); }, ); test( 'una fuente que lanza (permiso revocado) degrada a lista vacía, sin ' 'propagar la excepción', () async { final fuente = _FakeFuenteMusicaLocalAuto( errorEnHijos: Exception('permiso revocado'), ); final resultado = await hijosMusicaLocal( ConstructorArbolAuto.idMusicaLocal, fuente: fuente, ); expect(resultado, isNotNull); expect(resultado, isEmpty); }, ); }); group('reproducirPistaLocal', () { test( 'resuelve el content uri y delega a reproducir con un MediaItem ' 'reproducible', () async { final fuente = _FakeFuenteMusicaLocalAuto( uriPorDocId: const {'doc1': 'content://provider/doc1'}, ); MediaItem? recibido; await reproducirPistaLocal( 'pista:doc1', fuente: fuente, reproducir: (item) async { recibido = item; }, ); expect(recibido, isNotNull); expect(recibido!.id, 'content://provider/doc1'); }, ); test( 'id obsoleto/desconocido (uriContenidoDePista devuelve null) no ' 'llama a reproducir ni lanza excepción', () async { final fuente = _FakeFuenteMusicaLocalAuto(); var llamadas = 0; await reproducirPistaLocal( 'pista:doc-fantasma', fuente: fuente, reproducir: (item) async { llamadas++; }, ); expect(llamadas, 0); }, ); test('id sin el prefijo pista: es un no-op, no llama a reproducir', () async { final fuente = _FakeFuenteMusicaLocalAuto( uriPorDocId: const {'doc1': 'content://provider/doc1'}, ); var llamadas = 0; await reproducirPistaLocal( 'emisora:doc1', fuente: fuente, reproducir: (item) async { llamadas++; }, ); expect(llamadas, 0); }); test( 'regresión EQ (estructural): reproducirPistaLocal expone el mismo ' 'seam `reproducir` que reproducirPorMediaId usa para playMediaItem — ' 'no existe un parámetro/seam alternativo para música local, por lo ' 'que la reproducción local pasa por la MISMA cadena de EQ', () async { final fuente = _FakeFuenteMusicaLocalAuto( uriPorDocId: const {'doc1': 'content://provider/doc1'}, ); final llamadasReproducir = []; await reproducirPistaLocal( 'pista:doc1', fuente: fuente, reproducir: (item) async => llamadasReproducir.add(item), // NOTE: no existe ningún parámetro alternativo de reproducción — // esa ausencia ES la prueba de que no hay un camino separado que // evite el EQ (mismo patrón que el test estructural de // aplicarPresetPorMediaId). ); expect(llamadasReproducir, hasLength(1)); }, ); }); 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, }) {} } class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto { _FakeFuenteMusicaLocalAuto({ bool configurada = true, Map>? hijosPorDocId, Map? uriPorDocId, Object? errorEnHijos, Object? errorEnUriContenido, }) : _configurada = configurada, _hijosPorDocId = hijosPorDocId ?? const {}, _uriPorDocId = uriPorDocId ?? const {}, _errorEnHijos = errorEnHijos, _errorEnUriContenido = errorEnUriContenido; final bool _configurada; final Map> _hijosPorDocId; final Map _uriPorDocId; final Object? _errorEnHijos; final Object? _errorEnUriContenido; @override Future hayCarpetaConfigurada() async => _configurada; @override Future> hijos(String documentId) async { if (_errorEnHijos != null) throw _errorEnHijos; return _hijosPorDocId[documentId] ?? const []; } @override Future uriContenidoDePista(String documentId) async { if (_errorEnUriContenido != null) throw _errorEnUriContenido; return _uriPorDocId[documentId]; } }