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('paginaDe', () { test('slicea al medio de la lista', () { final items = List.generate(10, (i) => i); expect(paginaDe(items, pagina: 1, tamano: 3), [3, 4, 5]); }); test('lista vacía devuelve lista vacía', () { expect(paginaDe([], pagina: 0, tamano: 5), isEmpty); }); test('página más allá del rango devuelve lista vacía, sin lanzar', () { final items = List.generate(5, (i) => i); expect(paginaDe(items, pagina: 3, tamano: 5), isEmpty); }); test('última página parcial devuelve solo los elementos restantes', () { final items = List.generate(7, (i) => i); expect(paginaDe(items, pagina: 1, tamano: 5), [5, 6]); }); }); group('hayPaginaSiguiente', () { test( 'boundary exacto: total == (pagina+1)*tamano es false, mayor es true', () { expect(hayPaginaSiguiente(50, pagina: 0, tamano: 50), isFalse); expect(hayPaginaSiguiente(51, pagina: 0, tamano: 50), isTrue); expect(hayPaginaSiguiente(100, pagina: 1, tamano: 50), isFalse); expect(hayPaginaSiguiente(101, pagina: 1, tamano: 50), isTrue); }, ); }); group( 'esCarpetaLocalPaginadaMediaId / paginaCarpetaLocalDesde', () { test( 'reconoce el prefijo carpeta_local_pag: y no colisiona con ' 'emisora:/grupo:/eq_preset:/carpeta_local:/pista: ni con los ids de ' 'carpeta fijos', () { final builder = ConstructorArbolAuto(); expect( builder.esCarpetaLocalPaginadaMediaId('carpeta_local_pag:1:doc1'), isTrue, ); expect( builder.esCarpetaLocalPaginadaMediaId('carpeta_local_pag:1:'), isTrue, ); final otros = [ 'emisora:x', 'grupo:g1', 'eq_preset:Rock', 'carpeta_local:doc1', 'pista:doc1', ConstructorArbolAuto.idFavoritos, ConstructorArbolAuto.idTodas, ConstructorArbolAuto.idMisEmisoras, ConstructorArbolAuto.idEcualizador, ConstructorArbolAuto.idMusicaLocal, '', ]; for (final id in otros) { expect( builder.esCarpetaLocalPaginadaMediaId(id), isFalse, reason: 'esCarpetaLocalPaginadaMediaId($id) debería ser false', ); } // Simétricamente, un carpeta_local: plano nunca matchea el prefijo // paginado, y viceversa. expect( builder.esCarpetaLocalMediaId('carpeta_local_pag:1:doc1'), isFalse, ); }, ); test( 'parsea (documentId, pagina) recortando el prefijo y separando en ' 'el PRIMER ":" — el documentId sobrevive intacto aunque contenga ' '":" o "/"', () { final builder = ConstructorArbolAuto(); expect( builder.paginaCarpetaLocalDesde('carpeta_local_pag:1:doc1'), ('doc1', 1), ); expect( builder.paginaCarpetaLocalDesde( 'carpeta_local_pag:3:primary:Music/Local', ), ('primary:Music/Local', 3), ); }, ); test( 'página raíz con tail vacío ("carpeta_local_pag:1:") parsea a ' 'documentId vacío', () { final builder = ConstructorArbolAuto(); expect( builder.paginaCarpetaLocalDesde('carpeta_local_pag:1:'), ('', 1), ); }, ); }, ); group('ConstructorArbolAuto.itemsLocales', () { test( 'invariante de conteo de llamadas (ADR-3): con 200 nodos, cada página ' 'invoca construirItem exactamente min(50, restantes) veces — nunca ' 'una vez por el total de la carpeta', () async { final nodos = List.generate( 200, (i) => NodoLocal( documentId: 'doc-$i', nombre: 'cancion_${i.toString().padLeft(3, '0')}.mp3', esDirectorio: false, ), ); final builder = ConstructorArbolAuto(); var llamadas = 0; MediaItem espia(NodoLocal nodo, Map _) { llamadas++; return MediaItem(id: 'pista:${nodo.documentId}', title: nodo.nombre); } llamadas = 0; await builder.itemsLocales( nodos, documentIdPadre: 'x', pagina: 0, metadatosDe: _metadatosVacio, construirItem: espia, ); expect(llamadas, 50); llamadas = 0; await builder.itemsLocales( nodos, documentIdPadre: 'x', pagina: 1, metadatosDe: _metadatosVacio, construirItem: espia, ); expect(llamadas, 50); llamadas = 0; await builder.itemsLocales( nodos, documentIdPadre: 'x', pagina: 3, metadatosDe: _metadatosVacio, construirItem: espia, ); expect(llamadas, 50); // 200 - 150 = 50 restantes llamadas = 0; await builder.itemsLocales( nodos, documentIdPadre: 'x', pagina: 4, metadatosDe: _metadatosVacio, construirItem: espia, ); expect(llamadas, 0); // más allá del rango, no queda nada }, ); test( 'invariante de resolución de metadata (THE load-bearing test, mirror ' 'del spy de conteo de arriba): con 200 nodos, cada página invoca ' 'metadatosDe con EXACTAMENTE los documentIds de esa página — página 0 ' 'recibe los 50 primeros, página 3 recibe los 50 finales, nunca los ' '200 completos', () async { final nodos = List.generate( 200, (i) => NodoLocal( documentId: 'doc-$i', nombre: 'cancion_${i.toString().padLeft(3, '0')}.mp3', esDirectorio: false, ), ); final builder = ConstructorArbolAuto(); List? idsRecibidos; Future> espia(List ids) async { idsRecibidos = ids; return const {}; } idsRecibidos = null; await builder.itemsLocales( nodos, documentIdPadre: 'x', pagina: 0, metadatosDe: espia, ); expect(idsRecibidos, hasLength(50)); expect( idsRecibidos, List.generate(50, (i) => 'doc-$i'), ); idsRecibidos = null; await builder.itemsLocales( nodos, documentIdPadre: 'x', pagina: 3, metadatosDe: espia, ); expect(idsRecibidos, hasLength(50)); expect( idsRecibidos, List.generate(50, (i) => 'doc-${150 + i}'), ); }, ); test( 'las carpetas (esDirectorio: true) de la página NO se incluyen en ' 'los documentIds pasados a metadatosDe', () async { final nodos = [ const NodoLocal( documentId: 'doc-carpeta', nombre: 'AAA Carpeta', esDirectorio: true, ), const NodoLocal( documentId: 'doc-pista', nombre: 'ZZZ Pista.mp3', esDirectorio: false, ), ]; List? idsRecibidos; await ConstructorArbolAuto().itemsLocales( nodos, documentIdPadre: 'x', metadatosDe: (ids) async { idsRecibidos = ids; return const {}; }, ); expect(idsRecibidos, ['doc-pista']); }, ); test( 'caso con metadata presente: título/artUri resueltos reflejan la ' 'metadata, no el nombre de archivo ni el placeholder', () async { const nodo = NodoLocal( documentId: 'doc-meta', nombre: 'nombre_crudo.mp3', esDirectorio: false, ); const metadatos = MetadatosPista( titulo: 'Titulo Embebido', artista: 'Artista Embebido', artUri: 'content://es.freetimelab.pluriwave.fileprovider/cache/' 'pluriwave_art/abc', bitrate: 320000, ); final item = (await ConstructorArbolAuto().itemsLocales( [nodo], documentIdPadre: 'x', metadatosDe: (ids) async => {'doc-meta': metadatos}, )).singleWhere((i) => i.id.startsWith('pista:')); expect(item.title, 'Titulo Embebido'); expect(item.artUri.toString(), metadatos.artUri); expect(item.artist, 'Artista Embebido'); }, ); test( 'caso sin metadata (docId ausente del mapa resuelto, o mapa ' 'completamente vacío): cae al nombre de archivo y al placeholder ' 'rotativo, sin excepción', () async { const nodo = NodoLocal( documentId: 'doc-sin-meta', nombre: 'Cancion Sin Metadata.mp3', esDirectorio: false, ); for (final resolver in [ (List ids) async => const {}, (List ids) async => { 'doc-otro': const MetadatosPista(titulo: 'No debería usarse'), }, ]) { final item = (await ConstructorArbolAuto().itemsLocales( [nodo], documentIdPadre: 'x', metadatosDe: resolver, )).singleWhere((i) => i.id.startsWith('pista:')); expect(item.title, 'Cancion Sin Metadata'); expect(item.artUri.toString(), artUriLocal('doc-sin-meta')); } }, ); test( 'exactamente 50 items: no aparece "Más…", nada se pierde', () async { final nodos = List.generate( 50, (i) => NodoLocal( documentId: 'doc-$i', nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', esDirectorio: false, ), )..shuffle(); final items = await ConstructorArbolAuto().itemsLocales( nodos, documentIdPadre: 'x', metadatosDe: _metadatosVacio, ); // 50 pistas: <=150 -> entrada de calidad presente; exactamente 50 // NO dispara buckets (umbral es "> 50", Design ADR-4). final pistas = items.where((i) => i.id.startsWith('pista:')).toList(); expect(items, hasLength(51)); expect(pistas, hasLength(50)); expect(pistas.first.title, 'cancion_00'); expect(pistas.last.title, 'cancion_49'); expect(items.any((i) => i.title == 'Más…'), isFalse); }, ); test( '51 a 100 items: primera página trae 50 + "Más…"; la última página ' 'trae el resto sin "Más…"', () async { final nodos = List.generate( 60, (i) => NodoLocal( documentId: 'doc-$i', nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', esDirectorio: false, ), )..shuffle(); final builder = ConstructorArbolAuto(); final pagina0 = await builder.itemsLocales( nodos, documentIdPadre: 'x', metadatosDe: _metadatosVacio, ); // 60 pistas: <=150 -> entrada de calidad; >50 -> 4 buckets; // prepended antes de las 50 pistas + "Más…" (Design ADR-4). final pistas0 = pagina0.where((i) => i.id.startsWith('pista:')).toList(); expect(pagina0, hasLength(56)); expect(pistas0, hasLength(50)); expect(pistas0.first.title, 'cancion_00'); expect(pistas0[49].title, 'cancion_49'); final mas0 = pagina0.last; expect(mas0.title, 'Más…'); expect(mas0.playable, isFalse); expect(mas0.artUri, isNull); final pagina1 = await builder.itemsLocales( nodos, documentIdPadre: 'x', pagina: 1, metadatosDe: _metadatosVacio, ); expect(pagina1, hasLength(10)); expect(pagina1.last.title, 'cancion_59'); expect(pagina1.any((i) => i.title == 'Más…'), isFalse); }, ); test( '100+ items encadena 3+ páginas, cada item es alcanzable y nada se ' 'pierde', () async { final nodos = List.generate( 120, (i) => NodoLocal( documentId: 'doc-$i', nombre: 'cancion_${i.toString().padLeft(3, '0')}.mp3', esDirectorio: false, ), )..shuffle(); final builder = ConstructorArbolAuto(); final vistos = {}; var pagina = 0; var seguir = true; var paginasVisitadas = 0; while (seguir) { final items = await builder.itemsLocales( nodos, documentIdPadre: 'x', pagina: pagina, metadatosDe: _metadatosVacio, ); paginasVisitadas++; final tieneMas = items.isNotEmpty && items.last.title == 'Más…'; final soloItemsMas = tieneMas ? items.sublist(0, items.length - 1) : items; // Excluye las entradas de modo/bucket prepended en página 0 // (Design ADR-4) — este test cuenta pistas, no vistas. final hojas = soloItemsMas.where((i) => i.id.startsWith('pista:')); for (final item in hojas) { vistos.add(item.id); } seguir = tieneMas; pagina++; } expect(paginasVisitadas, greaterThanOrEqualTo(3)); expect(vistos, hasLength(120)); }, ); test( 'el item "Más…" tiene forma round-trip: id ' 'carpeta_local_pag::, playable false, sin artUri', () async { final nodos = List.generate( 51, (i) => NodoLocal( documentId: 'doc-$i', nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', esDirectorio: false, ), ); final builder = ConstructorArbolAuto(); final items = await builder.itemsLocales( nodos, documentIdPadre: 'sub1', pagina: 0, metadatosDe: _metadatosVacio, ); final mas = items.last; expect(mas.id, 'carpeta_local_pag:1:sub1'); expect(mas.title, 'Más…'); expect(mas.playable, isFalse); expect(mas.artUri, isNull); expect( builder.esCarpetaLocalPaginadaMediaId(mas.id), isTrue, ); expect( builder.paginaCarpetaLocalDesde(mas.id), ('sub1', 1), ); }, ); test('paginación en la raíz (documentIdPadre vacío) también funciona', () async { final nodos = List.generate( 51, (i) => NodoLocal( documentId: 'doc-$i', nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', esDirectorio: false, ), ); final builder = ConstructorArbolAuto(); final items = await builder.itemsLocales( nodos, documentIdPadre: '', metadatosDe: _metadatosVacio, ); final mas = items.last; expect(mas.id, 'carpeta_local_pag:1:'); expect(builder.paginaCarpetaLocalDesde(mas.id), ('', 1)); }); test( 'página obsoleta más allá del rango (la carpeta se achicó) devuelve ' 'lista vacía, sin "Más…" y sin lanzar', () async { final nodos = List.generate( 10, (i) => NodoLocal( documentId: 'doc-$i', nombre: 'cancion_$i.mp3', esDirectorio: false, ), ); final items = await ConstructorArbolAuto().itemsLocales( nodos, documentIdPadre: 'x', pagina: 5, metadatosDe: _metadatosVacio, ); expect(items, isEmpty); }, ); test('subcarpeta vacía devuelve lista vacía, no un error', () async { expect( await ConstructorArbolAuto().itemsLocales( const [], documentIdPadre: 'x', metadatosDe: _metadatosVacio, ), isEmpty, ); }); test( 'una carpeta mapea a un item no reproducible carpeta_local: con ' 'el nombre crudo (sin recortar extensión)', () async { const nodo = NodoLocal( documentId: 'doc-carpeta', nombre: 'Mi Carpeta', esDirectorio: true, ); final item = (await ConstructorArbolAuto().itemsLocales( [nodo], documentIdPadre: 'x', metadatosDe: _metadatosVacio, )).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', () async { const nodo = NodoLocal( documentId: 'doc-pista', nombre: 'Cancion Genial.mp3', esDirectorio: false, ); final item = (await ConstructorArbolAuto().itemsLocales( [nodo], documentIdPadre: 'x', metadatosDe: _metadatosVacio, )).singleWhere((i) => i.id.startsWith('pista:')); 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', () async { const nodo = NodoLocal( documentId: 'doc-sin-ext', nombre: 'CancionSinExtension', esDirectorio: false, ); final item = (await ConstructorArbolAuto().itemsLocales( [nodo], documentIdPadre: 'x', metadatosDe: _metadatosVacio, )).singleWhere((i) => i.id.startsWith('pista:')); expect(item.title, 'CancionSinExtension'); }); test( 'un archivo con nombre en blanco cae al título de reserva, nunca ' 'queda vacío', () async { for (final nombre in ['', ' ']) { final nodo = NodoLocal( documentId: 'doc-blanco', nombre: nombre, esDirectorio: false, ); final item = (await ConstructorArbolAuto().itemsLocales( [nodo], documentIdPadre: 'x', metadatosDe: _metadatosVacio, )).singleWhere((i) => i.id.startsWith('pista:')); 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', () async { const nodo = NodoLocal( documentId: 'doc-oculto', nombre: '.mp3', esDirectorio: false, ); final item = (await ConstructorArbolAuto().itemsLocales( [nodo], documentIdPadre: 'x', metadatosDe: _metadatosVacio, )).singleWhere((i) => i.id.startsWith('pista:')); expect(item.title, '.mp3'); }, ); test( 'el artUri de un archivo sin metadata usa la misma rotación que ' 'artUriLocal, sembrada con el documentId', () async { const nodo = NodoLocal( documentId: 'doc-arte', nombre: 'Cancion.mp3', esDirectorio: false, ); final item = (await ConstructorArbolAuto().itemsLocales( [nodo], documentIdPadre: 'x', metadatosDe: _metadatosVacio, )).singleWhere((i) => i.id.startsWith('pista:')); expect(item.artUri.toString(), artUriLocal('doc-arte')); expect( item.artUri.toString(), startsWith( 'android.resource://es.freetimelab.pluriwave/drawable/' 'station_art_', ), ); }, ); }); group('compararCalidadLocal / ordenarPorCalidadLocal', () { test( 'ordena por bitrate descendente; bitrate null siempre queda al ' 'final, sin lanzar', () { final nodos = [ const NodoLocal(documentId: 'd-128', nombre: 'b.mp3', esDirectorio: false), const NodoLocal(documentId: 'd-null', nombre: 'a.mp3', esDirectorio: false), const NodoLocal(documentId: 'd-320', nombre: 'c.mp3', esDirectorio: false), const NodoLocal(documentId: 'd-64', nombre: 'd.mp3', esDirectorio: false), ]; const metadatos = { 'd-128': MetadatosPista(bitrate: 128000), 'd-320': MetadatosPista(bitrate: 320000), 'd-64': MetadatosPista(bitrate: 64000), // 'd-null' deliberately absent from the resolved map. }; final ordenados = ordenarPorCalidadLocal(nodos, metadatos); expect( ordenados.map((n) => n.documentId).toList(), ['d-320', 'd-128', 'd-64', 'd-null'], ); }, ); test('múltiples bitrates desconocidos no lanzan y mantienen orden estable', () { final nodos = [ const NodoLocal(documentId: 'd-1', nombre: 'a.mp3', esDirectorio: false), const NodoLocal(documentId: 'd-2', nombre: 'b.mp3', esDirectorio: false), ]; final ordenados = ordenarPorCalidadLocal(nodos, const {}); expect(ordenados, hasLength(2)); }); }); group('ConstructorArbolAuto.ofreceOrdenCalidad', () { test( 'boundary de _maxPistasParaOrdenCalidad (150): 149 y 150 ofrecen la ' 'entrada de calidad, 151 la omite', () { final builder = ConstructorArbolAuto(); expect(builder.ofreceOrdenCalidad(149), isTrue); expect(builder.ofreceOrdenCalidad(150), isTrue); expect(builder.ofreceOrdenCalidad(151), isFalse); }, ); test('0 pistas nunca ofrece la entrada de calidad', () { expect(ConstructorArbolAuto().ofreceOrdenCalidad(0), isFalse); }); }); group('ConstructorArbolAuto.ofreceBuckets', () { test('boundary: 50 pistas no ofrece buckets, 51 sí', () { final builder = ConstructorArbolAuto(); expect(builder.ofreceBuckets(50), isFalse); expect(builder.ofreceBuckets(51), isTrue); }); }); group('bucketsDe', () { test( 'particiona nombres en rangos alfabéticos contiguos A-F/G-M/N-S/T-Z', () { final nodos = [ const NodoLocal(documentId: 'd-a', nombre: 'Amor.mp3', esDirectorio: false), const NodoLocal(documentId: 'd-g', nombre: 'Gato.mp3', esDirectorio: false), const NodoLocal(documentId: 'd-n', nombre: 'Noche.mp3', esDirectorio: false), const NodoLocal(documentId: 'd-t', nombre: 'Tren.mp3', esDirectorio: false), ]; final buckets = bucketsDe(nodos); expect(buckets, hasLength(4)); expect(buckets[0].etiqueta, 'A-F'); expect(buckets[0].nodos.map((n) => n.documentId), ['d-a']); expect(buckets[1].etiqueta, 'G-M'); expect(buckets[1].nodos.map((n) => n.documentId), ['d-g']); expect(buckets[2].etiqueta, 'N-S'); expect(buckets[2].nodos.map((n) => n.documentId), ['d-n']); expect(buckets[3].etiqueta, 'T-Z'); expect(buckets[3].nodos.map((n) => n.documentId), ['d-t']); }, ); test( 'un bucket sin coincidencias devuelve lista vacía, no un error', () { final nodos = [ const NodoLocal(documentId: 'd-a', nombre: 'Amor.mp3', esDirectorio: false), ]; final buckets = bucketsDe(nodos); expect(buckets[3].nodos, isEmpty); // T-Z sin coincidencias }, ); test( 'partición usa SOLO NodoLocal.nombre — bucketsDe no puede invocar ' 'metadatosDe porque su firma no lo recibe (invariante estructural)', () { // La firma de bucketsDe(List) no acepta ninguna función // metadatosDe — de haberla, este call site fallaría a compilar. Esa // ausencia ES la prueba de que la partición es metadata-free. final nodos = [ const NodoLocal(documentId: 'd-a', nombre: 'Amor.mp3', esDirectorio: false), ]; expect(() => bucketsDe(nodos), returnsNormally); }, ); test('excluye carpetas (esDirectorio: true) de la partición', () { final nodos = [ const NodoLocal(documentId: 'd-carpeta', nombre: 'Album.mp3', esDirectorio: true), ]; final buckets = bucketsDe(nodos); expect(buckets[0].nodos, isEmpty); }); }); group( 'esCarpetaLocalOrdMediaId / esCarpetaLocalBucketMediaId / ordenLocalDesde / bucketLocalDesde', () { test( 'reconocen sus prefijos y NUNCA colisionan entre sí ni con ' 'emisora:/grupo:/eq_preset:/carpeta_local:/carpeta_local_pag:/pista:', () { final builder = ConstructorArbolAuto(); expect( builder.esCarpetaLocalOrdMediaId('carpeta_local_ord:calidad:0:doc1'), isTrue, ); expect( builder.esCarpetaLocalBucketMediaId('carpeta_local_bucket:0:0:doc1'), isTrue, ); // Nunca ambos matchean el mismo id. expect( builder.esCarpetaLocalBucketMediaId('carpeta_local_ord:calidad:0:doc1'), isFalse, ); expect( builder.esCarpetaLocalOrdMediaId('carpeta_local_bucket:0:0:doc1'), isFalse, ); final otros = [ 'emisora:x', 'grupo:g1', 'eq_preset:Rock', 'carpeta_local:doc1', 'carpeta_local_pag:1:doc1', 'pista:doc1', ConstructorArbolAuto.idFavoritos, ConstructorArbolAuto.idTodas, ConstructorArbolAuto.idMisEmisoras, ConstructorArbolAuto.idEcualizador, ConstructorArbolAuto.idMusicaLocal, '', ]; for (final id in otros) { expect( builder.esCarpetaLocalOrdMediaId(id), isFalse, reason: 'esCarpetaLocalOrdMediaId($id) debería ser false', ); expect( builder.esCarpetaLocalBucketMediaId(id), isFalse, reason: 'esCarpetaLocalBucketMediaId($id) debería ser false', ); } }, ); test( 'ordenLocalDesde parsea (modo, documentId, pagina), preservando un ' 'documentId con ":" o "/"', () { final builder = ConstructorArbolAuto(); expect( builder.ordenLocalDesde('carpeta_local_ord:calidad:0:doc1'), ('calidad', 'doc1', 0), ); expect( builder.ordenLocalDesde( 'carpeta_local_ord:calidad:2:primary:Music/Local', ), ('calidad', 'primary:Music/Local', 2), ); }, ); test( 'bucketLocalDesde parsea (idxBucket, documentId, pagina), ' 'preservando un documentId con ":" o "/"', () { final builder = ConstructorArbolAuto(); expect( builder.bucketLocalDesde('carpeta_local_bucket:0:0:doc1'), (0, 'doc1', 0), ); expect( builder.bucketLocalDesde( 'carpeta_local_bucket:3:1:primary:Music/Local', ), (3, 'primary:Music/Local', 1), ); }, ); }, ); group('ConstructorArbolAuto.itemsLocales: page-0 mode/bucket prepend', () { test( 'folder con >150 pistas: SIN entrada de calidad; folder con >50 ' 'pistas: CON entradas de bucket; ambas prepended antes de la lista ' 'ordenada por nombre, solo en página 0', () async { final nodos60 = List.generate( 60, (i) => NodoLocal( documentId: 'd$i', nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', esDirectorio: false, ), ); final builder = ConstructorArbolAuto(); final pagina0 = await builder.itemsLocales( nodos60, documentIdPadre: 'x', metadatosDe: _metadatosVacio, ); // 60 pistas <= 150: entrada de calidad presente; > 50: 4 buckets. expect( pagina0.where((i) => i.id.startsWith('carpeta_local_ord:')), hasLength(1), ); expect( pagina0.where((i) => i.id.startsWith('carpeta_local_bucket:')), hasLength(4), ); // Prepended ANTES de la lista ordenada: los primeros 5 items son // modo+buckets, no pistas. for (var i = 0; i < 5; i++) { expect(pagina0[i].playable, isFalse); } final pagina1 = await builder.itemsLocales( nodos60, documentIdPadre: 'x', pagina: 1, metadatosDe: _metadatosVacio, ); expect( pagina1.where((i) => i.id.startsWith('carpeta_local_ord:')), isEmpty, ); expect( pagina1.where((i) => i.id.startsWith('carpeta_local_bucket:')), isEmpty, ); }, ); test( 'folder con 151 pistas: entrada de calidad OMITIDA, buckets ' 'presentes (>50)', () async { final nodos151 = List.generate( 151, (i) => NodoLocal( documentId: 'd$i', nombre: 'cancion_${i.toString().padLeft(3, '0')}.mp3', esDirectorio: false, ), ); final pagina0 = await ConstructorArbolAuto().itemsLocales( nodos151, documentIdPadre: 'x', metadatosDe: _metadatosVacio, ); expect( pagina0.where((i) => i.id.startsWith('carpeta_local_ord:')), isEmpty, ); expect( pagina0.where((i) => i.id.startsWith('carpeta_local_bucket:')), hasLength(4), ); }, ); test( 'folder con <=50 pistas: ni calidad-arriba-de-cero-hasta-150 cambia, ' 'ni buckets aparecen', () async { final nodos10 = List.generate( 10, (i) => NodoLocal( documentId: 'd$i', nombre: 'cancion_$i.mp3', esDirectorio: false, ), ); final pagina0 = await ConstructorArbolAuto().itemsLocales( nodos10, documentIdPadre: 'x', metadatosDe: _metadatosVacio, ); expect( pagina0.where((i) => i.id.startsWith('carpeta_local_ord:')), hasLength(1), ); expect( pagina0.where((i) => i.id.startsWith('carpeta_local_bucket:')), isEmpty, ); }, ); }); group('ConstructorArbolAuto.itemsLocalesOrdenCalidad', () { test( 'ordena TODA la carpeta por bitrate desc vía UN batch de metadatosDe ' '(full-folder, no page-scoped) y luego pagina', () async { final nodos = List.generate( 5, (i) => NodoLocal( documentId: 'd$i', nombre: 'cancion_$i.mp3', esDirectorio: false, ), ); final metadatos = { 'd0': const MetadatosPista(bitrate: 64000), 'd1': const MetadatosPista(bitrate: 320000), 'd2': const MetadatosPista(bitrate: 128000), 'd3': const MetadatosPista(bitrate: 256000), 'd4': const MetadatosPista(bitrate: 192000), }; var llamadas = 0; List? idsRecibidos; final items = await ConstructorArbolAuto().itemsLocalesOrdenCalidad( nodos, documentIdPadre: 'x', metadatosDe: (ids) async { llamadas++; idsRecibidos = ids; return metadatos; }, ); expect(llamadas, 1); expect(idsRecibidos, hasLength(5)); // full-folder, not page-scoped expect( items.map((i) => i.id).toList(), ['pista:d1', 'pista:d3', 'pista:d4', 'pista:d2', 'pista:d0'], ); }, ); test('paginación con "Más…" enrutado a carpeta_local_ord:calidad:', () async { final nodos = List.generate( 60, (i) => NodoLocal( documentId: 'd$i', nombre: 'cancion_$i.mp3', esDirectorio: false, ), ); final pagina0 = await ConstructorArbolAuto().itemsLocalesOrdenCalidad( nodos, documentIdPadre: 'x', metadatosDe: _metadatosVacio, ); expect(pagina0, hasLength(51)); final mas = pagina0.last; expect(mas.title, 'Más…'); expect( ConstructorArbolAuto().esCarpetaLocalOrdMediaId(mas.id), isTrue, ); expect( ConstructorArbolAuto().ordenLocalDesde(mas.id), ('calidad', 'x', 1), ); }); }); group( 'ConstructorArbolAuto.itemsLocales: matriz de fallback de arte ' '(Spec "Local Track Embedded Album Art Display")', () { test( 'cache-miss (native devuelve artUri: null) cae al placeholder ' 'rotativo, nunca queda vacío/roto', () async { const nodo = NodoLocal( documentId: 'doc-miss', nombre: 'Cancion.mp3', esDirectorio: false, ); const metadatos = MetadatosPista( titulo: 'Con titulo pero sin arte', artUri: null, // cache-miss nativo ); final item = (await ConstructorArbolAuto().itemsLocales( [nodo], documentIdPadre: 'x', metadatosDe: (ids) async => {'doc-miss': metadatos}, )).singleWhere((i) => i.id.startsWith('pista:')); expect(item.artUri.toString(), artUriLocal('doc-miss')); expect(item.artUri.toString(), isNotEmpty); }, ); test( 'parse-failure (entrada de metadata completamente null) cae al ' 'placeholder rotativo, nunca queda vacío/roto', () async { const nodo = NodoLocal( documentId: 'doc-fail', nombre: 'Cancion.mp3', esDirectorio: false, ); const metadatos = MetadatosPista(); // todo-null, mirrors a // native per-entry parse failure (Design "readAudioMetadataBatch" // — per-file try/catch -> all-null-but-documentId row). final item = (await ConstructorArbolAuto().itemsLocales( [nodo], documentIdPadre: 'x', metadatosDe: (ids) async => {'doc-fail': metadatos}, )).singleWhere((i) => i.id.startsWith('pista:')); expect(item.artUri.toString(), artUriLocal('doc-fail')); expect(item.title, 'Cancion'); // también cae el título }, ); }, ); group('ConstructorArbolAuto.itemsLocalesBucket', () { test( 'devuelve solo las pistas del bucket indicado, paginadas y con ' 'metadata resuelta SOLO para la página', () async { final nodos = [ const NodoLocal(documentId: 'd-a', nombre: 'Amor.mp3', esDirectorio: false), const NodoLocal(documentId: 'd-g', nombre: 'Gato.mp3', esDirectorio: false), ]; List? idsRecibidos; final items = await ConstructorArbolAuto().itemsLocalesBucket( nodos, documentIdPadre: 'x', idxBucket: 0, // A-F metadatosDe: (ids) async { idsRecibidos = ids; return const {}; }, ); expect(items, hasLength(1)); expect(items.single.id, 'pista:d-a'); expect(idsRecibidos, ['d-a']); }, ); test('bucket sin coincidencias devuelve lista vacía, no un error', () async { final nodos = [ const NodoLocal(documentId: 'd-a', nombre: 'Amor.mp3', esDirectorio: false), ]; final items = await ConstructorArbolAuto().itemsLocalesBucket( nodos, documentIdPadre: 'x', idxBucket: 3, // T-Z, sin coincidencias metadatosDe: _metadatosVacio, ); expect(items, isEmpty); }); test('idxBucket fuera de rango devuelve lista vacía, sin lanzar', () async { final nodos = [ const NodoLocal(documentId: 'd-a', nombre: 'Amor.mp3', esDirectorio: false), ]; final items = await ConstructorArbolAuto().itemsLocalesBucket( nodos, documentIdPadre: 'x', idxBucket: 99, metadatosDe: _metadatosVacio, ); expect(items, isEmpty); }); }); 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!.singleWhere((i) => i.id.startsWith('pista:')).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!.singleWhere((i) => i.id.startsWith('pista:')).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); }, ); test( 'un id carpeta_local_pag:: enruta a (docId, n) y devuelve ' 'esa página', () async { final nodos = List.generate( 60, (i) => NodoLocal( documentId: 'd$i', nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3', esDirectorio: false, ), ); final fuente = _FakeFuenteMusicaLocalAuto( hijosPorDocId: {'sub1': nodos}, ); final items = await hijosMusicaLocal( 'carpeta_local_pag:1:sub1', fuente: fuente, ); expect(items, isNotNull); // Página 1 (0-indexed) de 60 items con tamaño 50 trae los 10 // restantes, sin "Más…". expect(items, hasLength(10)); expect(items!.any((i) => i.title == 'Más…'), isFalse); }, ); test( 'un id que no es de música local (incluido uno con prefijo distinto) ' 'devuelve null', () async { final fuente = _FakeFuenteMusicaLocalAuto(); expect( await hijosMusicaLocal('carpeta_local_pag_otro:1:x', fuente: fuente), isNull, ); }, ); test( 'fuente null con un id paginado válido devuelve lista vacía, no null ' 'y sin lanzar', () async { final resultado = await hijosMusicaLocal( 'carpeta_local_pag:1:sub1', fuente: null, ); expect(resultado, isNotNull); expect(resultado, isEmpty); }, ); test( 'una fuente que lanza en la rama paginada degrada a lista vacía, sin ' 'propagar la excepción', () async { final fuente = _FakeFuenteMusicaLocalAuto( errorEnHijos: Exception('permiso revocado'), ); final resultado = await hijosMusicaLocal( 'carpeta_local_pag:1:sub1', fuente: fuente, ); expect(resultado, isNotNull); expect(resultado, isEmpty); }, ); test( 'un id carpeta_local_ord:calidad:: enruta a la lista ' 'ordenada por calidad de esa carpeta', () async { final nodos = [ const NodoLocal(documentId: 'd-1', nombre: 'a.mp3', esDirectorio: false), const NodoLocal(documentId: 'd-2', nombre: 'b.mp3', esDirectorio: false), ]; final fuente = _FakeFuenteMusicaLocalAuto( hijosPorDocId: {'sub1': nodos}, metadatosPorDocId: { 'd-1': const MetadatosPista(bitrate: 64000), 'd-2': const MetadatosPista(bitrate: 320000), }, ); final items = await hijosMusicaLocal( 'carpeta_local_ord:calidad:0:sub1', fuente: fuente, ); expect(items, isNotNull); expect(items!.map((i) => i.id).toList(), ['pista:d-2', 'pista:d-1']); }, ); test( 'un id carpeta_local_bucket::: enruta al bucket ' 'correspondiente de esa carpeta', () async { final nodos = [ const NodoLocal(documentId: 'd-a', nombre: 'Amor.mp3', esDirectorio: false), const NodoLocal(documentId: 'd-g', nombre: 'Gato.mp3', esDirectorio: false), ]; final fuente = _FakeFuenteMusicaLocalAuto(hijosPorDocId: {'sub1': nodos}); final items = await hijosMusicaLocal( 'carpeta_local_bucket:0:0:sub1', fuente: fuente, ); expect(items, isNotNull); expect(items!.single.id, 'pista:d-a'); }, ); test( 'fuente null para un id _ord o _bucket válido devuelve lista vacía, ' 'no null y sin lanzar', () async { final ord = await hijosMusicaLocal( 'carpeta_local_ord:calidad:0:sub1', fuente: null, ); final bucket = await hijosMusicaLocal( 'carpeta_local_bucket:0:0:sub1', fuente: null, ); expect(ord, isNotNull); expect(ord, isEmpty); expect(bucket, isNotNull); expect(bucket, isEmpty); }, ); test( 'una fuente que lanza en la rama _ord o _bucket degrada a lista ' 'vacía, sin propagar la excepción', () async { final fuente = _FakeFuenteMusicaLocalAuto( errorEnHijos: Exception('permiso revocado'), ); final ord = await hijosMusicaLocal( 'carpeta_local_ord:calidad:0:sub1', fuente: fuente, ); final bucket = await hijosMusicaLocal( 'carpeta_local_bucket:0:0:sub1', fuente: fuente, ); expect(ord, isNotNull); expect(ord, isEmpty); expect(bucket, isNotNull); expect(bucket, 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); }, ); }); } /// Fake `metadatosDe` that always resolves to an empty map — used by every /// `itemsLocales` test that doesn't care about metadata-backed titles/art, /// mirroring Phase 1's fallback behavior verbatim. Future> _metadatosVacio(List ids) async => const {}; 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, Map? metadatosPorDocId, Object? errorEnHijos, Object? errorEnUriContenido, Object? errorEnMetadatosDe, }) : _configurada = configurada, _hijosPorDocId = hijosPorDocId ?? const {}, _uriPorDocId = uriPorDocId ?? const {}, _metadatosPorDocId = metadatosPorDocId ?? const {}, _errorEnHijos = errorEnHijos, _errorEnUriContenido = errorEnUriContenido, _errorEnMetadatosDe = errorEnMetadatosDe; final bool _configurada; final Map> _hijosPorDocId; final Map _uriPorDocId; final Map _metadatosPorDocId; final Object? _errorEnHijos; final Object? _errorEnUriContenido; final Object? _errorEnMetadatosDe; /// Records every `metadatosDe` call's documentIds list, in call order — /// used by tests asserting the page-scoped resolution invariant at the /// `hijosMusicaLocal` level. final List> llamadasMetadatosDe = []; @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]; } @override Future> metadatosDe( List documentIds, ) async { if (_errorEnMetadatosDe != null) throw _errorEnMetadatosDe; llamadasMetadatosDe.add(List.from(documentIds)); final resultado = {}; for (final id in documentIds) { final metadatos = _metadatosPorDocId[id]; if (metadatos != null) resultado[id] = metadatos; } return resultado; } }