- MainActivity: onListen re-emits the current active device and registers the audio device callback idempotently, so recreated activities resync instead of freezing the active-device id on a disconnected device. - servicio_dispositivo_audio: resubscribir() re-opens the event channel; estado_ecualizador exposes refrescarDispositivoActual() with an in-flight guard, invoked on app resume and when opening advanced EQ options, clearing stale green-dot device selections. - navegacion_auto/servicio_audio: new 'Personalizado' browse tree in Android Auto (5 band folders, 13 gain steps each) applied live via setBanda; preset and gain taps persist at device level when multi-device EQ is active and respect station/matrix overrides, with apply-before-persist ordering and children-changed notifications. - l10n: regenerate stale generated localizations; add rxdart as direct dependency for the subscribeToChildren override.
3798 lines
117 KiB
Dart
3798 lines
117 KiB
Dart
import 'dart:math' show Random;
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:pluriwave/modelos/dispositivo_audio.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';
|
|
import 'package:pluriwave/servicios/servicio_ecualizador.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(
|
|
'esCarpetaLocalReproducirMediaId / esCarpetaLocalAleatorioMediaId '
|
|
'(Design ADR-5, Phase 3)',
|
|
() {
|
|
test(
|
|
'reconocen sus prefijos, no colisionan entre sí ni con NINGUNO de '
|
|
'los 8 prefijos/ids existentes (Spec "New Action Media-IDs Are '
|
|
'Collision-Free")',
|
|
() {
|
|
final builder = ConstructorArbolAuto();
|
|
|
|
expect(
|
|
builder.esCarpetaLocalReproducirMediaId(
|
|
'carpeta_local_reproducir:doc1',
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
builder.esCarpetaLocalAleatorioMediaId(
|
|
'carpeta_local_aleatorio:doc1',
|
|
),
|
|
isTrue,
|
|
);
|
|
|
|
final existentes = <String>[
|
|
'emisora:x',
|
|
'grupo:g1',
|
|
'eq_preset:Rock',
|
|
'carpeta_local:doc1',
|
|
'carpeta_local_pag:0:doc1',
|
|
'carpeta_local_ord:calidad:0:doc1',
|
|
'carpeta_local_bucket:0:0:doc1',
|
|
'pista:doc1',
|
|
];
|
|
for (final id in existentes) {
|
|
expect(
|
|
builder.esCarpetaLocalReproducirMediaId(id),
|
|
isFalse,
|
|
reason: 'esCarpetaLocalReproducirMediaId($id) debería ser '
|
|
'false',
|
|
);
|
|
expect(
|
|
builder.esCarpetaLocalAleatorioMediaId(id),
|
|
isFalse,
|
|
reason: 'esCarpetaLocalAleatorioMediaId($id) debería ser '
|
|
'false',
|
|
);
|
|
}
|
|
|
|
// Y en la dirección inversa: ninguno de los 8 predicados
|
|
// existentes reconoce las 2 nuevas acciones.
|
|
const reproducir = 'carpeta_local_reproducir:doc1';
|
|
const aleatorio = 'carpeta_local_aleatorio:doc1';
|
|
expect(builder.esCarpetaLocalMediaId(reproducir), isFalse);
|
|
expect(builder.esCarpetaLocalMediaId(aleatorio), isFalse);
|
|
expect(
|
|
builder.esCarpetaLocalPaginadaMediaId(reproducir),
|
|
isFalse,
|
|
);
|
|
expect(builder.esCarpetaLocalOrdMediaId(reproducir), isFalse);
|
|
expect(builder.esCarpetaLocalBucketMediaId(reproducir), isFalse);
|
|
expect(esPistaMediaId(reproducir), isFalse);
|
|
expect(esPresetMediaId(reproducir), isFalse);
|
|
|
|
// Divergencia en el índice 14 (justo después de "carpeta_local_"):
|
|
// 'r' (reproducir) / 'a' (aleatorio) vs 'p' (_pag) / 'o' (_ord) /
|
|
// 'b' (_bucket) — prueba de colisión de Design ADR-5.
|
|
expect(reproducir[14], 'r');
|
|
expect(aleatorio[14], 'a');
|
|
expect('carpeta_local_pag:0:doc1'[14], 'p');
|
|
expect('carpeta_local_ord:calidad:0:doc1'[14], 'o');
|
|
expect('carpeta_local_bucket:0:0:doc1'[14], 'b');
|
|
// 'carpeta_local:' diverge en el índice 13 (':' vs '_').
|
|
expect('carpeta_local:doc1'[13], ':');
|
|
expect(reproducir[13], '_');
|
|
},
|
|
);
|
|
|
|
test(
|
|
'idCarpetaLocalReproducirDesde / idCarpetaLocalAleatorioDesde '
|
|
'recortan por longitud, preservando un documentId con ":" y "/" '
|
|
'verbatim (Spec "Raw documentIds with :/ survive round-trip")',
|
|
() {
|
|
final builder = ConstructorArbolAuto();
|
|
const docId = 'primary:Music/Local Folder';
|
|
|
|
expect(
|
|
builder.idCarpetaLocalReproducirDesde(
|
|
'carpeta_local_reproducir:$docId',
|
|
),
|
|
docId,
|
|
);
|
|
expect(
|
|
builder.idCarpetaLocalAleatorioDesde(
|
|
'carpeta_local_aleatorio:$docId',
|
|
),
|
|
docId,
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'tail vacío (empty tail) hace round-trip a la raíz local',
|
|
() {
|
|
final builder = ConstructorArbolAuto();
|
|
|
|
expect(
|
|
builder.idCarpetaLocalReproducirDesde('carpeta_local_reproducir:'),
|
|
'',
|
|
);
|
|
expect(
|
|
builder.idCarpetaLocalAleatorioDesde('carpeta_local_aleatorio:'),
|
|
'',
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
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',
|
|
() 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<String, MetadatosPista> _) {
|
|
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<String>? idsRecibidos;
|
|
|
|
Future<Map<String, MetadatosPista>> espia(List<String> 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<String>? 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<String> ids) async => const <String, MetadatosPista>{},
|
|
(List<String> 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). totalPistas
|
|
// > 0 también antepone las 2 acciones "Reproducir carpeta"/
|
|
// "Reproducir aleatorio" (Design ADR-5, Phase 3) -> 2 + 1 + 50 = 53.
|
|
final pistas = items.where((i) => i.id.startsWith('pista:')).toList();
|
|
expect(items, hasLength(53));
|
|
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; y las 2
|
|
// acciones "Reproducir carpeta"/"Reproducir aleatorio" (Design
|
|
// ADR-5, Phase 3) -- todo prepended antes de las 50 pistas + "Más…"
|
|
// (Design ADR-4). 2 + 1 + 4 + 50 + 1 = 58.
|
|
final pistas0 = pagina0.where((i) => i.id.startsWith('pista:')).toList();
|
|
expect(pagina0, hasLength(58));
|
|
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 = <String>{};
|
|
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:<n+1>:<docId>, 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:<id> 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:<id> 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('pistasEnOrdenNombre', () {
|
|
test('excluye directorios y ordena por nombre (mismo comparador que '
|
|
'itemsLocales usa para la vista página 0)', () {
|
|
final nodos = [
|
|
const NodoLocal(documentId: 'd-c', nombre: 'c.mp3', esDirectorio: false),
|
|
const NodoLocal(
|
|
documentId: 'd-carpeta',
|
|
nombre: 'AAA Carpeta',
|
|
esDirectorio: true,
|
|
),
|
|
const NodoLocal(documentId: 'd-a', nombre: 'a.mp3', esDirectorio: false),
|
|
const NodoLocal(documentId: 'd-b', nombre: 'b.mp3', esDirectorio: false),
|
|
];
|
|
|
|
final ordenados = pistasEnOrdenNombre(nodos);
|
|
|
|
expect(
|
|
ordenados.map((n) => n.documentId).toList(),
|
|
['d-a', 'd-b', 'd-c'],
|
|
);
|
|
});
|
|
|
|
test('no muta la lista original', () {
|
|
final original = [
|
|
const NodoLocal(documentId: 'd-b', nombre: 'b.mp3', esDirectorio: false),
|
|
const NodoLocal(documentId: 'd-a', nombre: 'a.mp3', esDirectorio: false),
|
|
];
|
|
final copia = List<NodoLocal>.from(original);
|
|
|
|
pistasEnOrdenNombre(original);
|
|
|
|
expect(
|
|
original.map((n) => n.documentId).toList(),
|
|
copia.map((n) => n.documentId).toList(),
|
|
);
|
|
});
|
|
|
|
test('lista vacía devuelve lista vacía, sin lanzar', () {
|
|
expect(pistasEnOrdenNombre(const []), isEmpty);
|
|
});
|
|
});
|
|
|
|
group('mezclarFisherYates / pistasEnOrdenAleatorio (Design ADR-6)', () {
|
|
List<NodoLocal> nodosDePrueba(int n) => List.generate(
|
|
n,
|
|
(i) => NodoLocal(
|
|
documentId: 'd$i',
|
|
nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3',
|
|
esDirectorio: false,
|
|
),
|
|
);
|
|
|
|
test('determinismo bajo semilla fija: Random(42) dos veces produce el '
|
|
'MISMO orden', () {
|
|
final nodos = nodosDePrueba(10);
|
|
|
|
final orden1 = mezclarFisherYates(nodos, Random(42));
|
|
final orden2 = mezclarFisherYates(nodos, Random(42));
|
|
|
|
expect(
|
|
orden1.map((n) => n.documentId).toList(),
|
|
orden2.map((n) => n.documentId).toList(),
|
|
);
|
|
});
|
|
|
|
test('no muta la lista original y devuelve una lista distinta', () {
|
|
final original = nodosDePrueba(5);
|
|
final copia = List<NodoLocal>.from(original);
|
|
|
|
final mezclado = mezclarFisherYates(original, Random(1));
|
|
|
|
expect(
|
|
original.map((n) => n.documentId).toList(),
|
|
copia.map((n) => n.documentId).toList(),
|
|
);
|
|
expect(mezclado, isNot(same(original)));
|
|
});
|
|
|
|
test('sanidad de distribución: en 1000 corridas sobre una lista de 5, '
|
|
'cada item aparece en cada posición al menos una vez', () {
|
|
final nodos = nodosDePrueba(5);
|
|
final posicionesVistas = List.generate(5, (_) => <String>{});
|
|
|
|
for (var corrida = 0; corrida < 1000; corrida++) {
|
|
final mezclado = mezclarFisherYates(nodos, Random(corrida));
|
|
for (var pos = 0; pos < mezclado.length; pos++) {
|
|
posicionesVistas[pos].add(mezclado[pos].documentId);
|
|
}
|
|
}
|
|
|
|
for (final vistos in posicionesVistas) {
|
|
expect(vistos, hasLength(5), reason: 'cada posición debería haber '
|
|
'visto los 5 documentIds posibles a lo largo de 1000 corridas');
|
|
}
|
|
});
|
|
|
|
test('lista vacía devuelve lista vacía, sin lanzar', () {
|
|
expect(mezclarFisherYates(const [], Random(1)), isEmpty);
|
|
});
|
|
|
|
test('un solo elemento devuelve una lista de un elemento, sin lanzar', () {
|
|
final nodos = nodosDePrueba(1);
|
|
expect(
|
|
mezclarFisherYates(nodos, Random(1)).map((n) => n.documentId),
|
|
['d0'],
|
|
);
|
|
});
|
|
|
|
test('pistasEnOrdenAleatorio mezcla SOBRE el orden canónico por nombre '
|
|
'(excluye directorios primero, luego mezcla)', () {
|
|
final nodos = [
|
|
const NodoLocal(
|
|
documentId: 'd-carpeta',
|
|
nombre: 'AAA Carpeta',
|
|
esDirectorio: true,
|
|
),
|
|
...nodosDePrueba(5),
|
|
];
|
|
|
|
final resultado = pistasEnOrdenAleatorio(nodos, Random(7));
|
|
|
|
expect(resultado, hasLength(5));
|
|
expect(resultado.every((n) => !n.esDirectorio), isTrue);
|
|
});
|
|
|
|
test('pistasEnOrdenAleatorio es determinístico bajo la misma semilla', () {
|
|
final nodos = nodosDePrueba(8);
|
|
|
|
final r1 = pistasEnOrdenAleatorio(nodos, Random(99));
|
|
final r2 = pistasEnOrdenAleatorio(nodos, Random(99));
|
|
|
|
expect(
|
|
r1.map((n) => n.documentId).toList(),
|
|
r2.map((n) => n.documentId).toList(),
|
|
);
|
|
});
|
|
});
|
|
|
|
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<NodoLocal>) 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 = <String>[
|
|
'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 2 items son
|
|
// las acciones de reproducción (Design ADR-5, Phase 3, PLAYABLE),
|
|
// seguidas por los 5 items modo+buckets (no playable, no pistas).
|
|
expect(pagina0[0].id, startsWith('carpeta_local_reproducir:'));
|
|
expect(pagina0[0].playable, isTrue);
|
|
expect(pagina0[1].id, startsWith('carpeta_local_aleatorio:'));
|
|
expect(pagina0[1].playable, isTrue);
|
|
for (var i = 2; i < 7; 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.itemsLocales: page-0 folder-play actions '
|
|
'(Design ADR-5, Phase 3 task 4.1)',
|
|
() {
|
|
test(
|
|
'folder con >=1 pista de audio directa antepone "Reproducir '
|
|
'carpeta" + "Reproducir aleatorio", ambas playable:true, con id '
|
|
'prefijado (Spec "Folder has tracks")',
|
|
() async {
|
|
final nodos = [
|
|
const NodoLocal(
|
|
documentId: 'd-a',
|
|
nombre: 'a.mp3',
|
|
esDirectorio: false,
|
|
),
|
|
];
|
|
|
|
final pagina0 = await ConstructorArbolAuto().itemsLocales(
|
|
nodos,
|
|
documentIdPadre: 'padre1',
|
|
metadatosDe: _metadatosVacio,
|
|
);
|
|
|
|
final reproducir = pagina0.where(
|
|
(i) => i.id.startsWith('carpeta_local_reproducir:'),
|
|
);
|
|
final aleatorio = pagina0.where(
|
|
(i) => i.id.startsWith('carpeta_local_aleatorio:'),
|
|
);
|
|
expect(reproducir, hasLength(1));
|
|
expect(aleatorio, hasLength(1));
|
|
expect(reproducir.single.id, 'carpeta_local_reproducir:padre1');
|
|
expect(reproducir.single.playable, isTrue);
|
|
expect(aleatorio.single.id, 'carpeta_local_aleatorio:padre1');
|
|
expect(aleatorio.single.playable, isTrue);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'folder con 0 pistas de audio directas (solo subcarpetas) NO '
|
|
'antepone ninguna de las 2 acciones (Spec "Folder has no tracks", '
|
|
'edge case de carpeta vacía)',
|
|
() async {
|
|
final nodos = [
|
|
const NodoLocal(
|
|
documentId: 'd-sub',
|
|
nombre: 'Subcarpeta',
|
|
esDirectorio: true,
|
|
),
|
|
];
|
|
|
|
final pagina0 = await ConstructorArbolAuto().itemsLocales(
|
|
nodos,
|
|
documentIdPadre: 'padre1',
|
|
metadatosDe: _metadatosVacio,
|
|
);
|
|
|
|
expect(
|
|
pagina0.where((i) => i.id.startsWith('carpeta_local_reproducir:')),
|
|
isEmpty,
|
|
);
|
|
expect(
|
|
pagina0.where((i) => i.id.startsWith('carpeta_local_aleatorio:')),
|
|
isEmpty,
|
|
);
|
|
},
|
|
);
|
|
|
|
test('las acciones solo aparecen en página 0, nunca en páginas '
|
|
'siguientes', () async {
|
|
final nodos = List.generate(
|
|
60,
|
|
(i) => NodoLocal(
|
|
documentId: 'd$i',
|
|
nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3',
|
|
esDirectorio: false,
|
|
),
|
|
);
|
|
final builder = ConstructorArbolAuto();
|
|
|
|
final pagina1 = await builder.itemsLocales(
|
|
nodos,
|
|
documentIdPadre: 'padre1',
|
|
pagina: 1,
|
|
metadatosDe: _metadatosVacio,
|
|
);
|
|
|
|
expect(
|
|
pagina1.where((i) => i.id.startsWith('carpeta_local_reproducir:')),
|
|
isEmpty,
|
|
);
|
|
expect(
|
|
pagina1.where((i) => i.id.startsWith('carpeta_local_aleatorio:')),
|
|
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<String>? 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<String>? 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:<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!.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:<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);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'un id carpeta_local_ord:calidad:<pagina>:<docId> 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:<idx>:<pagina>:<docId> 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 = <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('reproducirCarpetaLocal (Design ADR-5/ADR-6, Phase 3 task 4.2)', () {
|
|
List<NodoLocal> nodosDePrueba() => const [
|
|
NodoLocal(documentId: 'd-c', nombre: 'c.mp3', esDirectorio: false),
|
|
NodoLocal(documentId: 'd-a', nombre: 'a.mp3', esDirectorio: false),
|
|
NodoLocal(documentId: 'd-b', nombre: 'b.mp3', esDirectorio: false),
|
|
NodoLocal(
|
|
documentId: 'd-carpeta',
|
|
nombre: 'AAA Carpeta',
|
|
esDirectorio: true,
|
|
),
|
|
];
|
|
|
|
test(
|
|
'secuencial (aleatorio: false): filtra directorios, ordena por '
|
|
'nombre y llama iniciarCola con esa lista',
|
|
() async {
|
|
final fuente = _FakeFuenteMusicaLocalAuto(
|
|
hijosPorDocId: {'carpeta1': nodosDePrueba()},
|
|
);
|
|
List<NodoLocal>? recibidas;
|
|
|
|
await reproducirCarpetaLocal(
|
|
'carpeta_local_reproducir:carpeta1',
|
|
aleatorio: false,
|
|
fuente: fuente,
|
|
iniciarCola: (pistas) async => recibidas = pistas,
|
|
);
|
|
|
|
expect(recibidas, isNotNull);
|
|
expect(
|
|
recibidas!.map((n) => n.documentId).toList(),
|
|
['d-a', 'd-b', 'd-c'],
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'aleatorio (aleatorio: true): filtra directorios y llama iniciarCola '
|
|
'con el orden Fisher-Yates producido por el rng inyectado',
|
|
() async {
|
|
final fuente = _FakeFuenteMusicaLocalAuto(
|
|
hijosPorDocId: {'carpeta1': nodosDePrueba()},
|
|
);
|
|
List<NodoLocal>? recibidas;
|
|
|
|
await reproducirCarpetaLocal(
|
|
'carpeta_local_aleatorio:carpeta1',
|
|
aleatorio: true,
|
|
fuente: fuente,
|
|
rng: Random(42),
|
|
iniciarCola: (pistas) async => recibidas = pistas,
|
|
);
|
|
|
|
expect(recibidas, isNotNull);
|
|
expect(recibidas, hasLength(3));
|
|
expect(recibidas!.every((n) => !n.esDirectorio), isTrue);
|
|
expect(
|
|
recibidas!.map((n) => n.documentId).toList(),
|
|
pistasEnOrdenAleatorio(nodosDePrueba(), Random(42))
|
|
.map((n) => n.documentId)
|
|
.toList(),
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'carpeta vacía (sin hijos) es un no-op: iniciarCola nunca se llama',
|
|
() async {
|
|
final fuente = _FakeFuenteMusicaLocalAuto();
|
|
var llamadas = 0;
|
|
|
|
await reproducirCarpetaLocal(
|
|
'carpeta_local_reproducir:vacia',
|
|
aleatorio: false,
|
|
fuente: fuente,
|
|
iniciarCola: (pistas) async => llamadas++,
|
|
);
|
|
|
|
expect(llamadas, 0);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'carpeta con solo subcarpetas (sin pistas de audio directas) es un '
|
|
'no-op: iniciarCola nunca se llama',
|
|
() async {
|
|
final fuente = _FakeFuenteMusicaLocalAuto(
|
|
hijosPorDocId: {
|
|
'carpeta1': const [
|
|
NodoLocal(
|
|
documentId: 'd-sub',
|
|
nombre: 'Subcarpeta',
|
|
esDirectorio: true,
|
|
),
|
|
],
|
|
},
|
|
);
|
|
var llamadas = 0;
|
|
|
|
await reproducirCarpetaLocal(
|
|
'carpeta_local_reproducir:carpeta1',
|
|
aleatorio: false,
|
|
fuente: fuente,
|
|
iniciarCola: (pistas) async => llamadas++,
|
|
);
|
|
|
|
expect(llamadas, 0);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'carpeta irresoluble (fuente.hijos lanza) es un no-op, sin propagar '
|
|
'la excepción',
|
|
() async {
|
|
final fuente = _FakeFuenteMusicaLocalAuto(
|
|
errorEnHijos: Exception('permiso revocado'),
|
|
);
|
|
var llamadas = 0;
|
|
|
|
await reproducirCarpetaLocal(
|
|
'carpeta_local_reproducir:carpeta1',
|
|
aleatorio: false,
|
|
fuente: fuente,
|
|
iniciarCola: (pistas) async => llamadas++,
|
|
);
|
|
|
|
expect(llamadas, 0);
|
|
},
|
|
);
|
|
|
|
test('id sin ninguno de los 2 prefijos es un no-op', () async {
|
|
final fuente = _FakeFuenteMusicaLocalAuto(
|
|
hijosPorDocId: {'carpeta1': nodosDePrueba()},
|
|
);
|
|
var llamadas = 0;
|
|
|
|
await reproducirCarpetaLocal(
|
|
'carpeta_local:carpeta1',
|
|
aleatorio: false,
|
|
fuente: fuente,
|
|
iniciarCola: (pistas) async => llamadas++,
|
|
);
|
|
|
|
expect(llamadas, 0);
|
|
});
|
|
|
|
test(
|
|
'strip por longitud preserva un documentId con ":" verbatim (round '
|
|
'trip), consultando fuente.hijos con el documentId correcto',
|
|
() async {
|
|
const docId = 'primary:Music/Local Folder';
|
|
final fuente = _FakeFuenteMusicaLocalAuto(
|
|
hijosPorDocId: {docId: nodosDePrueba()},
|
|
);
|
|
var llamadas = 0;
|
|
|
|
await reproducirCarpetaLocal(
|
|
'carpeta_local_reproducir:$docId',
|
|
aleatorio: false,
|
|
fuente: fuente,
|
|
iniciarCola: (pistas) async => llamadas++,
|
|
);
|
|
|
|
expect(llamadas, 1);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('construirMediaItemColaLocal', () {
|
|
test(
|
|
'resuelve el content uri y construye un MediaItem con el título '
|
|
'derivado del documentId',
|
|
() async {
|
|
final fuente = _FakeFuenteMusicaLocalAuto(
|
|
uriPorDocId: const {'doc1': 'content://provider/doc1'},
|
|
);
|
|
const nodo = NodoLocal(
|
|
documentId: 'doc1',
|
|
nombre: 'ignorado.mp3',
|
|
esDirectorio: false,
|
|
);
|
|
|
|
final item = await construirMediaItemColaLocal(nodo, fuente: fuente);
|
|
|
|
expect(item, isNotNull);
|
|
expect(item!.id, 'content://provider/doc1');
|
|
},
|
|
);
|
|
|
|
test(
|
|
'documentId irresoluble (uriContenidoDePista devuelve null) '
|
|
'devuelve null, sin lanzar',
|
|
() async {
|
|
final fuente = _FakeFuenteMusicaLocalAuto();
|
|
const nodo = NodoLocal(
|
|
documentId: 'doc-fantasma',
|
|
nombre: 'x.mp3',
|
|
esDirectorio: false,
|
|
);
|
|
|
|
final item = await construirMediaItemColaLocal(nodo, fuente: fuente);
|
|
|
|
expect(item, isNull);
|
|
},
|
|
);
|
|
});
|
|
|
|
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);
|
|
},
|
|
);
|
|
});
|
|
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
// EQ gráfico personalizado en Android Auto (feature auto-custom-eq)
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
|
|
group('ConstructorArbolAuto.itemEqPersonalizado', () {
|
|
test('carpeta no reproducible con id eq_custom y título Personalizado', () {
|
|
final item = ConstructorArbolAuto().itemEqPersonalizado();
|
|
|
|
expect(item.id, ConstructorArbolAuto.idEqPersonalizado);
|
|
expect(item.id, 'eq_custom');
|
|
expect(item.title, 'Personalizado');
|
|
expect(item.playable, isFalse);
|
|
});
|
|
});
|
|
|
|
group('formatearGananciaEq', () {
|
|
test('positivos con signo +, cero sin signo, negativos con -', () {
|
|
expect(formatearGananciaEq(4), '+4 dB');
|
|
expect(formatearGananciaEq(0), '0 dB');
|
|
expect(formatearGananciaEq(-6), '-6 dB');
|
|
expect(formatearGananciaEq(12), '+12 dB');
|
|
expect(formatearGananciaEq(-12), '-12 dB');
|
|
});
|
|
|
|
test('ganancias no enteras (presets de fábrica) muestran un decimal', () {
|
|
expect(formatearGananciaEq(1.5), '+1.5 dB');
|
|
expect(formatearGananciaEq(-1.5), '-1.5 dB');
|
|
});
|
|
});
|
|
|
|
group('ConstructorArbolAuto.bandasEq', () {
|
|
test(
|
|
'5 carpetas eq_banda:<i> con frecuencia y ganancia actual en el título',
|
|
() {
|
|
final preset = PresetEcualizador(
|
|
nombre: 'Personalizado',
|
|
bandas: [3.0, 0.0, -6.0, 1.5, 12.0],
|
|
);
|
|
|
|
final items = ConstructorArbolAuto().bandasEq(preset);
|
|
|
|
expect(items, hasLength(5));
|
|
expect(items.map((i) => i.id), [
|
|
'eq_banda:0',
|
|
'eq_banda:1',
|
|
'eq_banda:2',
|
|
'eq_banda:3',
|
|
'eq_banda:4',
|
|
]);
|
|
expect(items[0].title, '60 Hz · +3 dB');
|
|
expect(items[1].title, '250 Hz · 0 dB');
|
|
expect(items[2].title, '1 kHz · -6 dB');
|
|
expect(items[3].title, '4 kHz · +1.5 dB');
|
|
expect(items[4].title, '16 kHz · +12 dB');
|
|
expect(items.every((i) => i.playable == false), isTrue);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('ConstructorArbolAuto.gananciasBandaEq', () {
|
|
test(
|
|
'13 items reproducibles eq_gain:<i>:<g> de -12 a +12 en pasos de 2',
|
|
() {
|
|
final items = ConstructorArbolAuto().gananciasBandaEq(
|
|
1,
|
|
PresetEcualizador.flat,
|
|
);
|
|
|
|
expect(items, hasLength(13));
|
|
expect(items.first.id, 'eq_gain:1:-12');
|
|
expect(items.last.id, 'eq_gain:1:12');
|
|
expect(items.every((i) => i.playable == true), isTrue);
|
|
expect(items.first.title, '-12 dB');
|
|
expect(items.last.title, '+12 dB');
|
|
},
|
|
);
|
|
|
|
test('marca la ganancia actualmente seleccionada con "● "', () {
|
|
final preset = PresetEcualizador(
|
|
nombre: 'Personalizado',
|
|
bandas: [0.0, 4.0, 0.0, 0.0, 0.0],
|
|
);
|
|
|
|
final items = ConstructorArbolAuto().gananciasBandaEq(1, preset);
|
|
|
|
final marcados = items.where((i) => i.title.startsWith('● ')).toList();
|
|
expect(marcados, hasLength(1));
|
|
expect(marcados.single.id, 'eq_gain:1:4');
|
|
expect(marcados.single.title, '● +4 dB');
|
|
});
|
|
|
|
test(
|
|
'ganancia actual fuera de la retícula (impar) no marca ningún item',
|
|
() {
|
|
// Rock: bandas [2.0, 1.0, -1.0, 2.0, 3.0] — banda 1 vale 1.0.
|
|
final items = ConstructorArbolAuto().gananciasBandaEq(
|
|
1,
|
|
PresetEcualizador.rock,
|
|
);
|
|
|
|
expect(items.where((i) => i.title.startsWith('● ')), isEmpty);
|
|
},
|
|
);
|
|
|
|
test('índice fuera de rango devuelve lista vacía, nunca lanza', () {
|
|
final constructor = ConstructorArbolAuto();
|
|
expect(constructor.gananciasBandaEq(-1, PresetEcualizador.flat), isEmpty);
|
|
expect(constructor.gananciasBandaEq(5, PresetEcualizador.flat), isEmpty);
|
|
});
|
|
});
|
|
|
|
group('esBandaEqMediaId / indiceBandaEqDesde', () {
|
|
test('reconoce ids eq_banda: y extrae el índice', () {
|
|
expect(esBandaEqMediaId('eq_banda:0'), isTrue);
|
|
expect(esBandaEqMediaId('eq_banda:4'), isTrue);
|
|
expect(esBandaEqMediaId('eq_custom'), isFalse);
|
|
expect(esBandaEqMediaId('eq_gain:0:2'), isFalse);
|
|
|
|
expect(indiceBandaEqDesde('eq_banda:0'), 0);
|
|
expect(indiceBandaEqDesde('eq_banda:4'), 4);
|
|
});
|
|
|
|
test('ids malformados o fuera de rango devuelven null, nunca lanzan', () {
|
|
expect(indiceBandaEqDesde('eq_banda:'), isNull);
|
|
expect(indiceBandaEqDesde('eq_banda:x'), isNull);
|
|
expect(indiceBandaEqDesde('eq_banda:5'), isNull);
|
|
expect(indiceBandaEqDesde('eq_banda:-1'), isNull);
|
|
expect(indiceBandaEqDesde('emisora:x'), isNull);
|
|
});
|
|
});
|
|
|
|
group('esGananciaEqMediaId / gananciaEqDesde', () {
|
|
test('reconoce ids eq_gain: y extrae (índice, dB)', () {
|
|
expect(esGananciaEqMediaId('eq_gain:0:2'), isTrue);
|
|
expect(esGananciaEqMediaId('eq_banda:0'), isFalse);
|
|
|
|
expect(gananciaEqDesde('eq_gain:0:2'), (0, 2.0));
|
|
expect(gananciaEqDesde('eq_gain:4:-12'), (4, -12.0));
|
|
expect(gananciaEqDesde('eq_gain:2:0'), (2, 0.0));
|
|
});
|
|
|
|
test('ids malformados o fuera de rango devuelven null, nunca lanzan', () {
|
|
expect(gananciaEqDesde('eq_gain:'), isNull);
|
|
expect(gananciaEqDesde('eq_gain:0'), isNull);
|
|
expect(gananciaEqDesde('eq_gain:0:'), isNull);
|
|
expect(gananciaEqDesde('eq_gain:x:2'), isNull);
|
|
expect(gananciaEqDesde('eq_gain:0:x'), isNull);
|
|
expect(gananciaEqDesde('eq_gain:5:2'), isNull);
|
|
expect(gananciaEqDesde('eq_gain:0:14'), isNull);
|
|
expect(gananciaEqDesde('eq_gain:0:-14'), isNull);
|
|
expect(gananciaEqDesde('emisora:x'), isNull);
|
|
});
|
|
});
|
|
|
|
group('dispositivoDestinoEq', () {
|
|
const btDevice = DispositivoAudio(
|
|
id: 'bt_a2dp:AA:BB:CC:DD:EE:FF',
|
|
tipo: TipoDispositivo.bluetoothA2dp,
|
|
nombre: 'BT Speaker',
|
|
);
|
|
const builtin = DispositivoAudio(
|
|
id: 'builtin_speaker',
|
|
tipo: TipoDispositivo.altavozInterno,
|
|
nombre: 'Speaker',
|
|
);
|
|
const placeholder = DispositivoAudio(
|
|
id: 'bt_a2dp:name:My Speaker',
|
|
tipo: TipoDispositivo.bluetoothA2dp,
|
|
nombre: 'My Speaker',
|
|
);
|
|
|
|
test('dispositivo BT real con multi-device activo → su id', () {
|
|
expect(
|
|
dispositivoDestinoEq(multiDeviceEnabled: true, dispositivo: btDevice),
|
|
btDevice.id,
|
|
);
|
|
});
|
|
|
|
test('multi-device apagado → null (persistencia global)', () {
|
|
expect(
|
|
dispositivoDestinoEq(multiDeviceEnabled: false, dispositivo: btDevice),
|
|
isNull,
|
|
);
|
|
});
|
|
|
|
test('altavoz interno → null (nunca entrada por dispositivo)', () {
|
|
expect(
|
|
dispositivoDestinoEq(multiDeviceEnabled: true, dispositivo: builtin),
|
|
isNull,
|
|
);
|
|
});
|
|
|
|
test('placeholder compuesto bt_a2dp:name: → null (ADR-6)', () {
|
|
expect(
|
|
dispositivoDestinoEq(
|
|
multiDeviceEnabled: true,
|
|
dispositivo: placeholder,
|
|
),
|
|
isNull,
|
|
);
|
|
});
|
|
|
|
test('dispositivo desconocido (consulta fallida) → null', () {
|
|
expect(
|
|
dispositivoDestinoEq(multiDeviceEnabled: true, dispositivo: null),
|
|
isNull,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('presetPersonalizadoEfectivo', () {
|
|
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
|
|
|
|
test('entrada por dispositivo gana cuando multi-device está activo', () {
|
|
final config = ConfiguracionEcualizador(
|
|
principal: PresetEcualizador.pop,
|
|
porEmisora: const {},
|
|
eqMultiDeviceEnabled: true,
|
|
presetsDispositivo: {deviceId: PresetEcualizador.jazz},
|
|
);
|
|
|
|
expect(
|
|
presetPersonalizadoEfectivo(config: config, deviceId: deviceId),
|
|
PresetEcualizador.jazz,
|
|
);
|
|
});
|
|
|
|
test('sin entrada por dispositivo cae al principal', () {
|
|
final config = ConfiguracionEcualizador(
|
|
principal: PresetEcualizador.pop,
|
|
porEmisora: const {},
|
|
eqMultiDeviceEnabled: true,
|
|
);
|
|
|
|
expect(
|
|
presetPersonalizadoEfectivo(config: config, deviceId: deviceId),
|
|
PresetEcualizador.pop,
|
|
);
|
|
});
|
|
|
|
test('multi-device apagado ignora la entrada por dispositivo', () {
|
|
final config = ConfiguracionEcualizador(
|
|
principal: PresetEcualizador.pop,
|
|
porEmisora: const {},
|
|
eqMultiDeviceEnabled: false,
|
|
presetsDispositivo: {deviceId: PresetEcualizador.jazz},
|
|
);
|
|
|
|
expect(
|
|
presetPersonalizadoEfectivo(config: config, deviceId: deviceId),
|
|
PresetEcualizador.pop,
|
|
);
|
|
});
|
|
|
|
test('deviceId null (destino global) cae al principal', () {
|
|
final config = ConfiguracionEcualizador(
|
|
principal: PresetEcualizador.pop,
|
|
porEmisora: const {},
|
|
eqMultiDeviceEnabled: true,
|
|
presetsDispositivo: {deviceId: PresetEcualizador.jazz},
|
|
);
|
|
|
|
expect(
|
|
presetPersonalizadoEfectivo(config: config, deviceId: null),
|
|
PresetEcualizador.pop,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('debeAplicarSeleccionAhora', () {
|
|
const deviceId = 'bt_a2dp:AA:BB';
|
|
|
|
test('true sin estación actual', () {
|
|
expect(
|
|
debeAplicarSeleccionAhora(
|
|
uuidActual: null,
|
|
clavesPorEmisora: const {'uuid-otra'},
|
|
clavesMatriz: const {},
|
|
deviceIdDestino: deviceId,
|
|
),
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test('false cuando la estación actual tiene override por-emisora', () {
|
|
expect(
|
|
debeAplicarSeleccionAhora(
|
|
uuidActual: 'uuid-con-override',
|
|
clavesPorEmisora: const {'uuid-con-override'},
|
|
clavesMatriz: const {},
|
|
deviceIdDestino: deviceId,
|
|
),
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
test(
|
|
'false cuando existe entrada de matriz estación:dispositivo destino',
|
|
() {
|
|
expect(
|
|
debeAplicarSeleccionAhora(
|
|
uuidActual: 'uuid-1',
|
|
clavesPorEmisora: const {},
|
|
clavesMatriz: const {'uuid-1:$deviceId'},
|
|
deviceIdDestino: deviceId,
|
|
),
|
|
isFalse,
|
|
);
|
|
},
|
|
);
|
|
|
|
test('true cuando la matriz solo cubre otro dispositivo', () {
|
|
expect(
|
|
debeAplicarSeleccionAhora(
|
|
uuidActual: 'uuid-1',
|
|
clavesPorEmisora: const {},
|
|
clavesMatriz: const {'uuid-1:otro-dispositivo'},
|
|
deviceIdDestino: deviceId,
|
|
),
|
|
isTrue,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('aplicarPresetPorMediaId — persistencia dirigida por dispositivo', () {
|
|
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
|
|
|
|
test(
|
|
'con destino de dispositivo persiste a nivel dispositivo, nunca al '
|
|
'principal, y aplica en vivo cuando no hay overrides',
|
|
() async {
|
|
final persistidosDispositivo = <(String, PresetEcualizador)>[];
|
|
var persistirPrincipalLlamadas = 0;
|
|
PresetEcualizador? aplicado;
|
|
|
|
await aplicarPresetPorMediaId(
|
|
'eq_preset:${PresetEcualizador.rock.nombre}',
|
|
presets: PresetEcualizador.presets,
|
|
uuidActual: null,
|
|
clavesPorEmisora: () async => <String>{},
|
|
clavesMatriz: () async => <String>{},
|
|
dispositivoDestino: () async => deviceId,
|
|
persistirDispositivo: (id, preset) async {
|
|
persistidosDispositivo.add((id, preset));
|
|
},
|
|
persistirPrincipal: (preset) async {
|
|
persistirPrincipalLlamadas++;
|
|
},
|
|
aplicar: (preset) async {
|
|
aplicado = preset;
|
|
},
|
|
);
|
|
|
|
expect(persistidosDispositivo, [(deviceId, PresetEcualizador.rock)]);
|
|
expect(persistirPrincipalLlamadas, 0);
|
|
expect(aplicado, PresetEcualizador.rock);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'con destino null (builtin/apagado/error) cae a la persistencia '
|
|
'principal como hasta ahora',
|
|
() async {
|
|
PresetEcualizador? persistidoPrincipal;
|
|
var persistirDispositivoLlamadas = 0;
|
|
|
|
await aplicarPresetPorMediaId(
|
|
'eq_preset:${PresetEcualizador.jazz.nombre}',
|
|
presets: PresetEcualizador.presets,
|
|
uuidActual: null,
|
|
clavesPorEmisora: () async => <String>{},
|
|
clavesMatriz: () async => <String>{},
|
|
dispositivoDestino: () async => null,
|
|
persistirDispositivo: (id, preset) async {
|
|
persistirDispositivoLlamadas++;
|
|
},
|
|
persistirPrincipal: (preset) async {
|
|
persistidoPrincipal = preset;
|
|
},
|
|
aplicar: (preset) async {},
|
|
);
|
|
|
|
expect(persistidoPrincipal, PresetEcualizador.jazz);
|
|
expect(persistirDispositivoLlamadas, 0);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'persiste a dispositivo pero NO aplica en vivo cuando la matriz '
|
|
'estación:dispositivo hace sombra',
|
|
() async {
|
|
var persistirDispositivoLlamadas = 0;
|
|
var aplicarLlamadas = 0;
|
|
|
|
await aplicarPresetPorMediaId(
|
|
'eq_preset:${PresetEcualizador.rock.nombre}',
|
|
presets: PresetEcualizador.presets,
|
|
uuidActual: 'uuid-1',
|
|
clavesPorEmisora: () async => <String>{},
|
|
clavesMatriz: () async => {'uuid-1:$deviceId'},
|
|
dispositivoDestino: () async => deviceId,
|
|
persistirDispositivo: (id, preset) async {
|
|
persistirDispositivoLlamadas++;
|
|
},
|
|
persistirPrincipal: (preset) async {},
|
|
aplicar: (preset) async {
|
|
aplicarLlamadas++;
|
|
},
|
|
);
|
|
|
|
expect(persistirDispositivoLlamadas, 1);
|
|
expect(aplicarLlamadas, 0);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('aplicarGananciaPorMediaId', () {
|
|
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
|
|
|
|
ConfiguracionEcualizador configCon({
|
|
PresetEcualizador? principal,
|
|
Map<String, PresetEcualizador>? presetsDispositivo,
|
|
bool multiDevice = true,
|
|
}) => ConfiguracionEcualizador(
|
|
principal: principal ?? PresetEcualizador.flat,
|
|
porEmisora: const {},
|
|
eqMultiDeviceEnabled: multiDevice,
|
|
presetsDispositivo: presetsDispositivo ?? const {},
|
|
);
|
|
|
|
test(
|
|
'con destino de dispositivo: modifica solo la banda, persiste como '
|
|
'Personalizado a nivel dispositivo y aplica la banda en vivo',
|
|
() async {
|
|
final base = PresetEcualizador.jazz; // [3, -1, -1.5, 2, 4]
|
|
final persistidosDispositivo = <(String, PresetEcualizador)>[];
|
|
var persistirPrincipalLlamadas = 0;
|
|
final bandasAplicadas = <(int, double)>[];
|
|
|
|
await aplicarGananciaPorMediaId(
|
|
'eq_gain:1:4',
|
|
cargarConfig: () async =>
|
|
configCon(presetsDispositivo: {deviceId: base}),
|
|
dispositivoDestino: () async => deviceId,
|
|
persistirDispositivo: (id, preset) async {
|
|
persistidosDispositivo.add((id, preset));
|
|
},
|
|
persistirPrincipal: (preset) async {
|
|
persistirPrincipalLlamadas++;
|
|
},
|
|
aplicarBanda: (indice, db) async {
|
|
bandasAplicadas.add((indice, db));
|
|
},
|
|
);
|
|
|
|
expect(persistirPrincipalLlamadas, 0);
|
|
expect(persistidosDispositivo, hasLength(1));
|
|
final (idPersistido, presetPersistido) = persistidosDispositivo.single;
|
|
expect(idPersistido, deviceId);
|
|
expect(presetPersistido.nombre, 'Personalizado');
|
|
expect(presetPersistido.bandas, [3.0, 4.0, -1.5, 2.0, 4.0]);
|
|
expect(bandasAplicadas, [(1, 4.0)]);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'con destino null persiste al principal partiendo de sus bandas',
|
|
() async {
|
|
PresetEcualizador? persistidoPrincipal;
|
|
var persistirDispositivoLlamadas = 0;
|
|
|
|
await aplicarGananciaPorMediaId(
|
|
'eq_gain:0:-2',
|
|
cargarConfig: () async =>
|
|
configCon(principal: PresetEcualizador.pop, multiDevice: false),
|
|
dispositivoDestino: () async => null,
|
|
persistirDispositivo: (id, preset) async {
|
|
persistirDispositivoLlamadas++;
|
|
},
|
|
persistirPrincipal: (preset) async {
|
|
persistidoPrincipal = preset;
|
|
},
|
|
aplicarBanda: (indice, db) async {},
|
|
);
|
|
|
|
expect(persistirDispositivoLlamadas, 0);
|
|
expect(persistidoPrincipal, isNotNull);
|
|
expect(persistidoPrincipal!.nombre, 'Personalizado');
|
|
// Pop: [1.0, 1.5, 0.5, 1.0, 1.5] con banda 0 → -2.
|
|
expect(persistidoPrincipal!.bandas, [-2.0, 1.5, 0.5, 1.0, 1.5]);
|
|
},
|
|
);
|
|
|
|
test('id malformado: no persiste, no aplica, no lanza', () async {
|
|
var llamadas = 0;
|
|
|
|
await aplicarGananciaPorMediaId(
|
|
'eq_gain:9:99',
|
|
cargarConfig: () async => configCon(),
|
|
dispositivoDestino: () async => deviceId,
|
|
persistirDispositivo: (id, preset) async {
|
|
llamadas++;
|
|
},
|
|
persistirPrincipal: (preset) async {
|
|
llamadas++;
|
|
},
|
|
aplicarBanda: (indice, db) async {
|
|
llamadas++;
|
|
},
|
|
);
|
|
|
|
expect(llamadas, 0);
|
|
});
|
|
|
|
test(
|
|
'cargarConfig que lanza degrada a no-op sin propagar la excepción',
|
|
() async {
|
|
var llamadas = 0;
|
|
|
|
await aplicarGananciaPorMediaId(
|
|
'eq_gain:0:2',
|
|
cargarConfig: () async => throw Exception('prefs rotas'),
|
|
dispositivoDestino: () async => deviceId,
|
|
persistirDispositivo: (id, preset) async {
|
|
llamadas++;
|
|
},
|
|
persistirPrincipal: (preset) async {
|
|
llamadas++;
|
|
},
|
|
aplicarBanda: (indice, db) async {
|
|
llamadas++;
|
|
},
|
|
);
|
|
|
|
expect(llamadas, 0);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'con override por-emisora de la estación actual persiste pero NO '
|
|
'aplica la banda en vivo',
|
|
() async {
|
|
final persistidosDispositivo = <(String, PresetEcualizador)>[];
|
|
final bandasAplicadas = <(int, double)>[];
|
|
|
|
await aplicarGananciaPorMediaId(
|
|
'eq_gain:1:4',
|
|
cargarConfig: () async => configCon(),
|
|
dispositivoDestino: () async => deviceId,
|
|
persistirDispositivo: (id, preset) async {
|
|
persistidosDispositivo.add((id, preset));
|
|
},
|
|
persistirPrincipal: (preset) async {},
|
|
aplicarBanda: (indice, db) async {
|
|
bandasAplicadas.add((indice, db));
|
|
},
|
|
uuidActual: 'uuid-1',
|
|
clavesPorEmisora: () async => {'uuid-1'},
|
|
clavesMatriz: () async => <String>{},
|
|
);
|
|
|
|
expect(persistidosDispositivo, hasLength(1));
|
|
expect(bandasAplicadas, isEmpty);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'con entrada de matriz estación:dispositivo destino persiste pero NO '
|
|
'aplica la banda en vivo',
|
|
() async {
|
|
final persistidosDispositivo = <(String, PresetEcualizador)>[];
|
|
final bandasAplicadas = <(int, double)>[];
|
|
|
|
await aplicarGananciaPorMediaId(
|
|
'eq_gain:1:4',
|
|
cargarConfig: () async => configCon(),
|
|
dispositivoDestino: () async => deviceId,
|
|
persistirDispositivo: (id, preset) async {
|
|
persistidosDispositivo.add((id, preset));
|
|
},
|
|
persistirPrincipal: (preset) async {},
|
|
aplicarBanda: (indice, db) async {
|
|
bandasAplicadas.add((indice, db));
|
|
},
|
|
uuidActual: 'uuid-1',
|
|
clavesPorEmisora: () async => <String>{},
|
|
clavesMatriz: () async => {'uuid-1:$deviceId'},
|
|
);
|
|
|
|
expect(persistidosDispositivo, hasLength(1));
|
|
expect(bandasAplicadas, isEmpty);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'con estación actual sin overrides sí aplica la banda en vivo',
|
|
() async {
|
|
final bandasAplicadas = <(int, double)>[];
|
|
|
|
await aplicarGananciaPorMediaId(
|
|
'eq_gain:1:4',
|
|
cargarConfig: () async => configCon(),
|
|
dispositivoDestino: () async => deviceId,
|
|
persistirDispositivo: (id, preset) async {},
|
|
persistirPrincipal: (preset) async {},
|
|
aplicarBanda: (indice, db) async {
|
|
bandasAplicadas.add((indice, db));
|
|
},
|
|
uuidActual: 'uuid-1',
|
|
clavesPorEmisora: () async => <String>{},
|
|
clavesMatriz: () async => <String>{},
|
|
);
|
|
|
|
expect(bandasAplicadas, [(1, 4.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<Map<String, MetadatosPista>> _metadatosVacio(List<String> 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<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,
|
|
Map<String, MetadatosPista>? 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<String, List<NodoLocal>> _hijosPorDocId;
|
|
final Map<String, String?> _uriPorDocId;
|
|
final Map<String, MetadatosPista> _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<List<String>> llamadasMetadatosDe = [];
|
|
|
|
@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];
|
|
}
|
|
|
|
@override
|
|
Future<Map<String, MetadatosPista>> metadatosDe(
|
|
List<String> documentIds,
|
|
) async {
|
|
if (_errorEnMetadatosDe != null) throw _errorEnMetadatosDe;
|
|
llamadasMetadatosDe.add(List<String>.from(documentIds));
|
|
final resultado = <String, MetadatosPista>{};
|
|
for (final id in documentIds) {
|
|
final metadatos = _metadatosPorDocId[id];
|
|
if (metadatos != null) resultado[id] = metadatos;
|
|
}
|
|
return resultado;
|
|
}
|
|
}
|