Folders over the 50-item cap now show a "Mas..." item that reveals the next page on tap, instead of silently dropping the rest. Paging slices the cheap raw list before building any MediaItem, so items beyond the requested page are never resolved (art, title) -- proven by a call-count test. Also swaps the raw SAF content:// URI shown in settings for a parsed, human-readable folder name with a localized fallback across all 13 locales. servicio_audio.dart is untouched; this stays entirely within the local-music tree/dispatch layer.
1766 lines
52 KiB
Dart
1766 lines
52 KiB
Dart
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 = <String?, bool>{
|
|
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<int>(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 = <String, bool>{
|
|
'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 = <String>[
|
|
'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(<int>[], 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 = <String>[
|
|
'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',
|
|
() {
|
|
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) {
|
|
llamadas++;
|
|
return MediaItem(id: 'pista:${nodo.documentId}', title: nodo.nombre);
|
|
}
|
|
|
|
llamadas = 0;
|
|
builder.itemsLocales(
|
|
nodos,
|
|
documentIdPadre: 'x',
|
|
pagina: 0,
|
|
construirItem: espia,
|
|
);
|
|
expect(llamadas, 50);
|
|
|
|
llamadas = 0;
|
|
builder.itemsLocales(
|
|
nodos,
|
|
documentIdPadre: 'x',
|
|
pagina: 1,
|
|
construirItem: espia,
|
|
);
|
|
expect(llamadas, 50);
|
|
|
|
llamadas = 0;
|
|
builder.itemsLocales(
|
|
nodos,
|
|
documentIdPadre: 'x',
|
|
pagina: 3,
|
|
construirItem: espia,
|
|
);
|
|
expect(llamadas, 50); // 200 - 150 = 50 restantes
|
|
|
|
llamadas = 0;
|
|
builder.itemsLocales(
|
|
nodos,
|
|
documentIdPadre: 'x',
|
|
pagina: 4,
|
|
construirItem: espia,
|
|
);
|
|
expect(llamadas, 0); // más allá del rango, no queda nada
|
|
},
|
|
);
|
|
|
|
test(
|
|
'exactamente 50 items: no aparece "Más…", nada se pierde',
|
|
() {
|
|
final nodos = List.generate(
|
|
50,
|
|
(i) => NodoLocal(
|
|
documentId: 'doc-$i',
|
|
nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3',
|
|
esDirectorio: false,
|
|
),
|
|
)..shuffle();
|
|
|
|
final items = ConstructorArbolAuto().itemsLocales(
|
|
nodos,
|
|
documentIdPadre: 'x',
|
|
);
|
|
|
|
expect(items, hasLength(50));
|
|
expect(items.first.title, 'cancion_00');
|
|
expect(items.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…"',
|
|
() {
|
|
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 = builder.itemsLocales(nodos, documentIdPadre: 'x');
|
|
expect(pagina0, hasLength(51));
|
|
expect(pagina0.first.title, 'cancion_00');
|
|
expect(pagina0[49].title, 'cancion_49');
|
|
final mas0 = pagina0.last;
|
|
expect(mas0.title, 'Más…');
|
|
expect(mas0.playable, isFalse);
|
|
expect(mas0.artUri, isNull);
|
|
|
|
final pagina1 = builder.itemsLocales(
|
|
nodos,
|
|
documentIdPadre: 'x',
|
|
pagina: 1,
|
|
);
|
|
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',
|
|
() {
|
|
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 = <String>{};
|
|
var pagina = 0;
|
|
var seguir = true;
|
|
var paginasVisitadas = 0;
|
|
while (seguir) {
|
|
final items = builder.itemsLocales(
|
|
nodos,
|
|
documentIdPadre: 'x',
|
|
pagina: pagina,
|
|
);
|
|
paginasVisitadas++;
|
|
final tieneMas = items.isNotEmpty && items.last.title == 'Más…';
|
|
final hojas = tieneMas ? items.sublist(0, items.length - 1) : items;
|
|
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:<n+1>:<docId>, playable false, sin artUri',
|
|
() {
|
|
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 = builder.itemsLocales(
|
|
nodos,
|
|
documentIdPadre: 'sub1',
|
|
pagina: 0,
|
|
);
|
|
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', () {
|
|
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 = builder.itemsLocales(nodos, documentIdPadre: '');
|
|
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',
|
|
() {
|
|
final nodos = List.generate(
|
|
10,
|
|
(i) => NodoLocal(
|
|
documentId: 'doc-$i',
|
|
nombre: 'cancion_$i.mp3',
|
|
esDirectorio: false,
|
|
),
|
|
);
|
|
|
|
final items = ConstructorArbolAuto().itemsLocales(
|
|
nodos,
|
|
documentIdPadre: 'x',
|
|
pagina: 5,
|
|
);
|
|
|
|
expect(items, isEmpty);
|
|
},
|
|
);
|
|
|
|
test('subcarpeta vacía devuelve lista vacía, no un error', () {
|
|
expect(
|
|
ConstructorArbolAuto().itemsLocales(const [], documentIdPadre: 'x'),
|
|
isEmpty,
|
|
);
|
|
});
|
|
|
|
test(
|
|
'una carpeta mapea a un item no reproducible carpeta_local:<id> con '
|
|
'el nombre crudo (sin recortar extensión)',
|
|
() {
|
|
const nodo = NodoLocal(
|
|
documentId: 'doc-carpeta',
|
|
nombre: 'Mi Carpeta',
|
|
esDirectorio: true,
|
|
);
|
|
|
|
final item = ConstructorArbolAuto()
|
|
.itemsLocales([nodo], documentIdPadre: 'x')
|
|
.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:<id> 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], documentIdPadre: 'x')
|
|
.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], documentIdPadre: 'x')
|
|
.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], documentIdPadre: 'x')
|
|
.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], documentIdPadre: 'x')
|
|
.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], documentIdPadre: 'x')
|
|
.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:<id> 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);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'un id carpeta_local_pag:<n>:<docId> 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);
|
|
},
|
|
);
|
|
});
|
|
|
|
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 = <MediaItem>[];
|
|
|
|
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:<nombre>', () {
|
|
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:<id>', () {
|
|
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:<id> '
|
|
'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:<id> 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 => <String>{},
|
|
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 => <String>{},
|
|
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 = <PresetEcualizador>[];
|
|
final llamadasPersistir = <PresetEcualizador>[];
|
|
|
|
await aplicarPresetPorMediaId(
|
|
'eq_preset:${PresetEcualizador.pop.nombre}',
|
|
presets: PresetEcualizador.presets,
|
|
uuidActual: null,
|
|
clavesPorEmisora: () async => <String>{},
|
|
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<String, Emisora> porUuidResultado})
|
|
: _porUuidResultado = porUuidResultado;
|
|
|
|
final Map<String, Emisora> _porUuidResultado;
|
|
|
|
@override
|
|
Future<List<Emisora>> favoritos() async => const [];
|
|
|
|
@override
|
|
Future<List<Emisora>> misEmisoras() async => const [];
|
|
|
|
@override
|
|
Future<List<Emisora>> todas() async => const [];
|
|
|
|
@override
|
|
Future<Emisora?> porUuid(String uuid) async => _porUuidResultado[uuid];
|
|
|
|
@override
|
|
Future<List<GrupoFavoritos>> grupos() async => const [];
|
|
|
|
@override
|
|
void actualizarSnapshot({
|
|
List<Emisora>? favoritos,
|
|
List<Emisora>? misEmisoras,
|
|
List<Emisora>? todas,
|
|
List<GrupoFavoritos>? grupos,
|
|
}) {}
|
|
}
|
|
|
|
class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
|
|
_FakeFuenteMusicaLocalAuto({
|
|
bool configurada = true,
|
|
Map<String, List<NodoLocal>>? hijosPorDocId,
|
|
Map<String, String?>? uriPorDocId,
|
|
Object? errorEnHijos,
|
|
Object? errorEnUriContenido,
|
|
}) : _configurada = configurada,
|
|
_hijosPorDocId = hijosPorDocId ?? const {},
|
|
_uriPorDocId = uriPorDocId ?? const {},
|
|
_errorEnHijos = errorEnHijos,
|
|
_errorEnUriContenido = errorEnUriContenido;
|
|
|
|
final bool _configurada;
|
|
final Map<String, List<NodoLocal>> _hijosPorDocId;
|
|
final Map<String, String?> _uriPorDocId;
|
|
final Object? _errorEnHijos;
|
|
final Object? _errorEnUriContenido;
|
|
|
|
@override
|
|
Future<bool> hayCarpetaConfigurada() async => _configurada;
|
|
|
|
@override
|
|
Future<List<NodoLocal>> hijos(String documentId) async {
|
|
if (_errorEnHijos != null) throw _errorEnHijos;
|
|
return _hijosPorDocId[documentId] ?? const [];
|
|
}
|
|
|
|
@override
|
|
Future<String?> uriContenidoDePista(String documentId) async {
|
|
if (_errorEnUriContenido != null) throw _errorEnUriContenido;
|
|
return _uriPorDocId[documentId];
|
|
}
|
|
}
|