Files
pluriwave/test/servicios/navegacion_auto_test.dart
T
FreeTLab 3449e2cb79
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m23s
fix: corregir ecualizador desincronizado, musica local en Android Auto y bloqueo del paywall
Tres fallos reportados en uso real, con sus causas raiz verificadas en codigo.

1. Ecualizador: el estado no tenia dueño unico

El handler arrancaba con `_ecualizadorActivo = true` a fuego. El valor
persistido solo llegaba por EstadoEcualizador.cargarPersistido(), alcanzable
unicamente desde el arbol de widgets, que un arranque headless de Android Auto
nunca construye. Resultado: el coche reproducia con el EQ forzado a ON mientras
disco e interfaz decian OFF.

Ahora registrarHandler siembra el flag desde disco en todos los motores y
setEcualizadorActivo persiste por su cuenta, asi que un toggle desde el coche o
la notificacion sobrevive sin EstadoEcualizador. _resincronizarConHandler pasa
a ser adopcion pura de interfaz.

Ademas mapearGananciaNativa enviaba 0 dB al punto MEDIO del rango nativo. Con
un getBandLevelRange() asimetrico, un preset plano metia varios dB de boost
real: la causa del "suena muy alto con el boton apagado". Reescrito para
escalar cada lado contra su propio extremo, de modo que 0 dB es siempre 0.

El dispatch de customAction no tenia ningun test. Se extrae decidirToggleEq y
se cubre contra el handler real. El efecto nativo se re-asierta al reactivarse
el reproductor, porque AudioEffect.setEnabled de just_audio es un no-op
mientras la plataforma esta desacoplada.

2. Musica Local no aparecia en el arbol de Android Auto

hayCarpetaConfigurada() consultaba MethodChannel('pluriwave/file_actions'),
registrado solo en MainActivity.configureFlutterEngine. Sin Activity no hay
handler, invokeMethod lanza MissingPluginException y el catch la confundia con
"permiso revocado", omitiendo el nodo. No dependia del entitlement.

La logica SAF sale a packages/pluriwave_file_actions, un paquete plugin local.
El motor headless que crea audio_service ejecuta GeneratedPluginRegistrant en
su constructor, asi que el canal queda registrado en ambos motores. Repuntar el
manifest a una subclase de AudioService no era viable: AudioServicePlugin
enlaza por ComponentName explicito y la app perderia el audio.

EstadoCarpetaLocal de tres valores separa "sin carpeta" de "canal no
disponible"; la raiz decide por la URI persistida y el subarbol muestra un item
explicativo en vez de una carpeta vacia. La invalidacion del arbol cacheado se
dispara al reanudar con el coche ya suscrito; el guardia anterior miraba
View.maybeOf, que bajo runApp siempre existe, por lo que se gastaba en el
arranque headless y no volvia a dispararse.

3. El paywall bloqueaba las compras

restorePurchases() de in_app_purchase_android emite siempre, y con lista vacia
si no hay nada que restaurar. El `if (compras.isEmpty) return;` se la tragaba,
noEncontrada nunca se emitia y la rama que limpia _compraEnCurso estaba muerta
en produccion. Como comprar y restaurar comparten ese flag, un usuario sin
compras que pulsaba restaurar se quedaba sin poder comprar.

Suite completa: 1366 pasan, 2 omitidos. 17 tests nuevos, todos nacidos rojos y
verificados por mutacion. flutter analyze mantiene los 5 avisos preexistentes.
2026-08-31 14:34:49 +02:00

3766 lines
115 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/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/modelos/pista_local.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/musica_local_auto.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
void main() {
group('faviconUsable', () {
test(
'rechaza favicons malformados o no http(s), acepta http(s) válidos',
() {
final casos = <String?, bool>{
null: false,
'': false,
' ': false,
'ftp://cdn.example.com/logo.png': false,
'cdn.example.com/logo.png': false,
'http://': false,
'/relative/path/logo.png': false,
'not a url at all \$\$\$ ///': false,
'http://cdn.example.com/logo.png': true,
'https://cdn.example.com/logo.png': true,
};
casos.forEach((entrada, esperado) {
expect(
faviconUsable(entrada),
esperado,
reason:
'faviconUsable(${entrada.toString()}) debería ser $esperado',
);
});
},
);
});
group('indiceArtePara', () {
test('reproduce la misma fórmula que _fallbackArtFor', () {
const seeds = ['', 'a', 'uuid-1234-abcd-real-looking'];
for (final seed in seeds) {
final esperado = seed.codeUnits.fold<int>(0, (a, b) => a + b) % 4;
expect(indiceArtePara(seed), esperado);
}
});
test('el orden canónico de nombres es aurora, cosmic, pulse, nova '
'(verificado vía artUriPara con seeds que cubren los 4 índices)', () {
// Seeds de una sola letra cuyo codeUnit % 4 cubre los 4 índices, en
// el mismo orden que tarjeta_emisora.dart's _fallbackArtFor:
// 'd'(100)->índice 0 aurora, 'a'(97)->índice 1 cosmic,
// 'b'(98)->índice 2 pulse, 'c'(99)->índice 3 nova.
const indicesEsperados = {'d': 0, 'a': 1, 'b': 2, 'c': 3};
const nombresPorIndice = ['aurora', 'cosmic', 'pulse', 'nova'];
indicesEsperados.forEach((seed, indiceEsperado) {
expect(indiceArtePara(seed), indiceEsperado);
final emisora = _emisora(uuid: seed, nombre: 'Radio $seed');
expect(
artUriPara(emisora),
'android.resource://es.freetimelab.pluriwave/drawable/station_art_'
'${nombresPorIndice[indiceEsperado]}',
);
});
});
});
group('artUriPara', () {
String nombreEsperadoPara(String uuid) {
final indice = indiceArtePara(uuid);
const nombres = ['aurora', 'cosmic', 'pulse', 'nova'];
return nombres[indice];
}
test('favicon https válido se usa como artUri', () {
final emisora = _emisora(
uuid: 'uuid-logo',
nombre: 'Radio Con Logo',
favicon: 'https://cdn.example.com/logo.png',
);
expect(artUriPara(emisora), emisora.favicon);
});
test('favicon null cae al drawable rotativo correspondiente', () {
final emisora = _emisora(uuid: 'uuid-null', nombre: 'Radio Sin Logo');
final nombre = nombreEsperadoPara(emisora.uuid);
expect(
artUriPara(emisora),
'android.resource://es.freetimelab.pluriwave/drawable/station_art_$nombre',
);
});
test('favicon vacío cae al mismo comportamiento rotativo que null', () {
final emisora = _emisora(
uuid: 'uuid-vacio',
nombre: 'Radio Logo Vacio',
favicon: '',
);
final nombre = nombreEsperadoPara(emisora.uuid);
expect(
artUriPara(emisora),
'android.resource://es.freetimelab.pluriwave/drawable/station_art_$nombre',
);
});
test('favicon malformado cae al mismo comportamiento rotativo', () {
final malformados = [
_emisora(uuid: 'uuid-m1', nombre: 'M1', favicon: 'ftp://x/y.png'),
_emisora(uuid: 'uuid-m2', nombre: 'M2', favicon: 'not a url'),
];
for (final emisora in malformados) {
final nombre = nombreEsperadoPara(emisora.uuid);
expect(
artUriPara(emisora),
'android.resource://es.freetimelab.pluriwave/drawable/station_art_$nombre',
);
}
});
test('uuids distintos que cayeron en índices distintos resuelven a '
'artUriPara distintos', () {
final a = _emisora(uuid: 'uuid-a', nombre: 'A');
final b = _emisora(uuid: 'uuid-bbbb', nombre: 'B');
// Solo tiene sentido si de hecho hashean a índices distintos.
if (indiceArtePara(a.uuid) != indiceArtePara(b.uuid)) {
expect(artUriPara(a), isNot(equals(artUriPara(b))));
}
});
test('el mismo uuid llamado dos veces da resultado idéntico', () {
final emisora = _emisora(uuid: 'uuid-determinista', nombre: 'Det');
expect(artUriPara(emisora), artUriPara(emisora));
});
});
group('subtituloCalidad', () {
test('matriz de codec/bitrate conocidos y desconocidos', () {
expect(
subtituloCalidad(
_emisora(uuid: 'u1', nombre: 'N', codec: 'mp3', bitrate: 128),
),
'128 kbps · MP3',
);
expect(
subtituloCalidad(
_emisora(uuid: 'u2', nombre: 'N', codec: null, bitrate: 128),
),
'128 kbps',
);
expect(
subtituloCalidad(
_emisora(uuid: 'u3', nombre: 'N', codec: 'mp3', bitrate: null),
),
'MP3',
);
expect(
subtituloCalidad(
_emisora(uuid: 'u4', nombre: 'N', codec: 'mp3', bitrate: 0),
),
'MP3',
);
expect(
subtituloCalidad(
_emisora(uuid: 'u5', nombre: 'N', codec: null, bitrate: null),
),
isNull,
);
expect(
subtituloCalidad(
_emisora(uuid: 'u6', nombre: 'N', codec: null, bitrate: 0),
),
isNull,
);
expect(
subtituloCalidad(
_emisora(uuid: 'u7', nombre: 'N', codec: ' ', bitrate: null),
),
isNull,
);
expect(
subtituloCalidad(
_emisora(uuid: 'u8', nombre: 'N', codec: ' mp3 ', bitrate: 128),
),
'128 kbps · MP3',
);
});
test('ningún resultado contiene la subcadena "null"', () {
final casos = [
_emisora(uuid: 'u1', nombre: 'N', codec: 'mp3', bitrate: 128),
_emisora(uuid: 'u2', nombre: 'N', codec: null, bitrate: 128),
_emisora(uuid: 'u3', nombre: 'N', codec: 'mp3', bitrate: null),
_emisora(uuid: 'u4', nombre: 'N', codec: null, bitrate: null),
];
for (final emisora in casos) {
final resultado = subtituloCalidad(emisora);
expect(resultado?.contains('null') ?? false, isFalse);
}
});
});
group('parity: phone/auto art order', () {
test('el orden de nombres de arte coincide con tarjeta_emisora.dart', () {
// Orden canónico hardcodeado una única vez aquí (mismo patrón que
// tarjeta_emisora.dart:363-367's _fallbackArtFor); debe mantenerse
// sincronizado manualmente si esa lista cambia. _nombresArte es
// privado en navegacion_auto.dart, así que este test verifica el
// orden indirectamente vía artUriPara con seeds que cubren los 4
// índices (mismo patrón que el segundo test de 'indiceArtePara').
const ordenCanonico = ['aurora', 'cosmic', 'pulse', 'nova'];
const seedsPorIndice = ['d', 'a', 'b', 'c'];
for (var indice = 0; indice < ordenCanonico.length; indice++) {
final emisora = _emisora(
uuid: seedsPorIndice[indice],
nombre: 'Radio ${seedsPorIndice[indice]}',
);
expect(indiceArtePara(emisora.uuid), indice);
expect(
artUriPara(emisora),
'android.resource://es.freetimelab.pluriwave/drawable/station_art_'
'${ordenCanonico[indice]}',
);
}
});
});
group('ConstructorArbolAuto.raiz', () {
test('con incluirMusicaLocal: true devuelve exactamente 4 carpetas no '
'reproducibles con los ids esperados', () {
final raiz = ConstructorArbolAuto().raiz(
incluirMusicaLocal: true,
premium: true,
);
expect(raiz, hasLength(4));
final ids = raiz.map((item) => item.id).toSet();
expect(
ids,
equals({
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
}),
);
for (final item in raiz) {
expect(item.playable, isFalse);
expect(item.title, isNotEmpty);
}
expect(raiz.last.id, ConstructorArbolAuto.idMusicaLocal);
});
test('con incluirMusicaLocal: false devuelve exactamente 3 carpetas — '
'Música Local queda OCULTA, no vacía', () {
final raiz = ConstructorArbolAuto().raiz(
incluirMusicaLocal: false,
premium: true,
);
expect(raiz, hasLength(3));
final ids = raiz.map((item) => item.id).toSet();
expect(
ids,
equals({
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
}),
);
expect(ids, isNot(contains(ConstructorArbolAuto.idMusicaLocal)));
});
test('la raíz NO ofrece la carpeta de ecualizador -- decisión owner '
'tras conducir con ella: on/off en pantalla es el único control de '
'ecualizador que pertenece en el coche, la selección de preset se '
'queda en el móvil. Esto REEMPLAZA deliberadamente al test homónimo '
'previo que afirmaba lo contrario (que a su vez había reemplazado la '
'ausencia original en `2403da3`) -- no es una regresión, es la '
'segunda vuelta de la misma decisión con evidencia real de uso', () {
final ids =
ConstructorArbolAuto()
.raiz(incluirMusicaLocal: true, premium: true)
.map((item) => item.id)
.toList();
expect(ids, isNot(contains(ConstructorArbolAuto.idEcualizador)));
// The id constant itself is NOT deleted: getChildren still answers it
// transitionally for a head unit with a stale cached browse tree. See
// servicio_audio.dart's getChildren for that courtesy branch.
expect(ConstructorArbolAuto.idEcualizador, 'ecualizador');
});
});
group('ConstructorArbolAuto EQ preset media-ids (decision '
'`auto/ecualizador-diseno`)', () {
test('esPresetEqMediaId reconoce el prefijo eq_preset: (presets y '
'Desactivar), no colisiona con emisora:/grupo:/carpeta_local:/'
'pista:/los ids de carpeta fijos', () {
final builder = ConstructorArbolAuto();
expect(builder.esPresetEqMediaId('eq_preset:Rock'), isTrue);
expect(
builder.esPresetEqMediaId(ConstructorArbolAuto.idDesactivarEq),
isTrue,
);
final otros = <String>[
'emisora:x',
'grupo:g1',
'carpeta_local:doc1',
'carpeta_local_pag:0:doc1',
'pista:doc1',
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
ConstructorArbolAuto.idEcualizador,
'',
];
for (final id in otros) {
expect(
builder.esPresetEqMediaId(id),
isFalse,
reason: 'esPresetEqMediaId($id) debería ser false',
);
}
});
test('esDesactivarEqMediaId SOLO reconoce el sentinel de Desactivar, '
'nunca un preset real', () {
final builder = ConstructorArbolAuto();
expect(
builder.esDesactivarEqMediaId(ConstructorArbolAuto.idDesactivarEq),
isTrue,
);
for (final preset in PresetEcualizador.presets) {
expect(
builder.esDesactivarEqMediaId(builder.idPresetEq(preset.nombre)),
isFalse,
reason:
'esDesactivarEqMediaId no debería reconocer un preset '
'real (${preset.nombre})',
);
}
});
test(
'idPresetEq construye eq_preset:<nombre> y resolverPresetEq '
'resuelve cada uno de los 6 presets de fábrica por su nombre crudo',
() {
final builder = ConstructorArbolAuto();
for (final preset in PresetEcualizador.presets) {
final id = builder.idPresetEq(preset.nombre);
expect(id, 'eq_preset:${preset.nombre}');
expect(builder.resolverPresetEq(id), preset);
}
},
);
test('resolverPresetEq devuelve null para el sentinel de Desactivar, un '
'nombre desconocido, o un id que no matchea esPresetEqMediaId -- '
'nunca lanza', () {
final builder = ConstructorArbolAuto();
expect(
builder.resolverPresetEq(ConstructorArbolAuto.idDesactivarEq),
isNull,
);
expect(builder.resolverPresetEq('eq_preset:Preset Inexistente'), isNull);
expect(builder.resolverPresetEq('emisora:x'), isNull);
expect(builder.resolverPresetEq(''), isNull);
});
test('idDesactivarEq es estable y vive bajo el mismo prefijo '
'eq_preset:', () {
expect(ConstructorArbolAuto.idDesactivarEq, startsWith('eq_preset:'));
});
});
group(
'seleccionarPresetEqPorMediaId (decision `auto/ecualizador-diseno`)',
() {
test(
'Desactivar llama activarEcualizador(false), nunca aplicarPreset',
() async {
final activarLlamadoCon = <bool>[];
var aplicarLlamado = false;
await seleccionarPresetEqPorMediaId(
ConstructorArbolAuto.idDesactivarEq,
activo: true,
aplicarPreset: (_) async => aplicarLlamado = true,
activarEcualizador: (v) async => activarLlamadoCon.add(v),
);
expect(activarLlamadoCon, [false]);
expect(aplicarLlamado, isFalse);
},
);
test('seleccionar un preset mientras el ecualizador está DESACTIVADO lo '
'activa Y aplica ese preset (spec "selecting a preset while '
'disabled enables it and applies it")', () async {
PresetEcualizador? presetAplicado;
final activarLlamadoCon = <bool>[];
final builder = ConstructorArbolAuto();
await seleccionarPresetEqPorMediaId(
builder.idPresetEq(PresetEcualizador.rock.nombre),
activo: false,
aplicarPreset: (p) async => presetAplicado = p,
activarEcualizador: (v) async => activarLlamadoCon.add(v),
);
expect(presetAplicado, PresetEcualizador.rock);
expect(activarLlamadoCon, [true]);
});
test('seleccionar un preset mientras el ecualizador YA está activo solo '
'aplica el preset, sin volver a tocar activarEcualizador', () async {
PresetEcualizador? presetAplicado;
final activarLlamadoCon = <bool>[];
final builder = ConstructorArbolAuto();
await seleccionarPresetEqPorMediaId(
builder.idPresetEq(PresetEcualizador.jazz.nombre),
activo: true,
aplicarPreset: (p) async => presetAplicado = p,
activarEcualizador: (v) async => activarLlamadoCon.add(v),
);
expect(presetAplicado, PresetEcualizador.jazz);
expect(activarLlamadoCon, isEmpty);
});
test(
'un id desconocido o que no matchea el prefijo eq_preset: es un '
'no-op -- ninguno de los dos callbacks se invoca, sin lanzar',
() async {
var llamadas = 0;
for (final id in ['emisora:x', 'eq_preset:Preset Inexistente', '']) {
await seleccionarPresetEqPorMediaId(
id,
activo: true,
aplicarPreset: (_) async => llamadas++,
activarEcualizador: (_) async => llamadas++,
);
}
expect(llamadas, 0);
},
);
},
);
group('esCarpetaLocalMediaId / esPistaMediaId', () {
test('reconocen sus prefijos, rechazan el resto y no colisionan entre sí '
'ni con emisora:/grupo:/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',
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
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',
'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);
// 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:/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',
'carpeta_local:doc1',
'pista:doc1',
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
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',
'carpeta_local:doc1',
'carpeta_local_pag:1:doc1',
'pista:doc1',
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
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);
});
/// fix/android-auto-musica-local: la raíz ya no oculta el nodo cuando
/// el canal nativo no está disponible, así que el subárbol tiene que
/// EXPLICAR el problema en vez de abrirse vacío (una carpeta vacía se
/// lee como «no tengo música», que es justo la conclusión equivocada).
///
/// La etiqueta va en castellano hardcodeado, como TODAS las etiquetas
/// del árbol del coche en `navegacion_auto.dart` (ver
/// `itemPremiumBloqueado`): convención establecida, nunca `AppLocalizations`.
test('canalNoDisponible y carpeta vacía: la raíz local devuelve un item '
'explicativo NO reproducible, no una carpeta vacía', () async {
final fuente = _FakeFuenteMusicaLocalAuto(
estado: EstadoCarpetaLocal.canalNoDisponible,
);
final items = await hijosMusicaLocal(
ConstructorArbolAuto.idMusicaLocal,
fuente: fuente,
);
expect(items, isNotNull);
expect(items!.map((i) => i.id), [ConstructorArbolAuto.idLocalNoLista]);
expect(items.single.playable, isFalse);
expect(items.single.title, isNotEmpty);
});
test('canalNoDisponible pero CON pistas resueltas: no se entromete, se '
'listan las pistas normalmente', () async {
final fuente = _FakeFuenteMusicaLocalAuto(
estado: EstadoCarpetaLocal.canalNoDisponible,
hijosPorDocId: {
'': const [
NodoLocal(
documentId: 'd1',
nombre: 'Cancion.mp3',
esDirectorio: false,
),
],
},
);
final items = await hijosMusicaLocal(
ConstructorArbolAuto.idMusicaLocal,
fuente: fuente,
);
expect(items, isNotNull);
expect(
items!.map((i) => i.id),
isNot(contains(ConstructorArbolAuto.idLocalNoLista)),
);
expect(items.any((i) => i.id == 'pista:d1'), isTrue);
});
test('carpeta genuinamente vacía con el canal SÍ disponible: sigue '
'devolviendo lista vacía, sin item explicativo', () async {
final fuente = _FakeFuenteMusicaLocalAuto();
final items = await hijosMusicaLocal(
ConstructorArbolAuto.idMusicaLocal,
fuente: fuente,
);
expect(items, isNotNull);
expect(items, isEmpty);
});
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('item 3: el MediaItem de una pista local tocada directamente usa el '
'fallback de marca (artUriLocal), no queda sin artUri', () 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!.artUri, isNotNull);
expect(recibido!.artUri.toString(), artUriLocal('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), y esa '
'subcarpeta TAMBIÉN está vacía incluso recursivamente, 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('item 2: carpeta con SOLO una subcarpeta que sí tiene pistas -- las '
'recolecta RECURSIVAMENTE y llama iniciarCola con ellas (antes era '
'un no-op)', () async {
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {
'carpeta1': const [
NodoLocal(
documentId: 'd-sub',
nombre: 'Subcarpeta',
esDirectorio: true,
),
],
'd-sub': const [
NodoLocal(
documentId: 'd-honda',
nombre: 'honda.mp3',
esDirectorio: false,
),
],
},
);
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-honda']);
});
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);
});
test('item 3: el MediaItem de una pista en cola de "Reproducir carpeta" '
'usa el fallback de marca (artUriLocal), no queda sin artUri (antes '
'una pista en cola no tenía NINGÚN arte)', () 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!.artUri, isNotNull);
expect(item.artUri.toString(), artUriLocal('doc1'));
});
});
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 PRESERVA el orden recibido — item 2 '
'(android-auto-orden fix): ya no reordena por calidad. Cada llamador '
'(Favoritos/Todas/Mis emisoras) empuja el snapshot ya en el orden que '
'el teléfono usa (orden manual o el ajuste global ordenListas), así '
'que reordenar aquí volvería a descartarlo', () {
// bitrate ascendente a propósito: si hijos() todavía reordenara por
// calidad (bitrate descendente), el primero sería uuid-59, no
// uuid-0 — esta es la diferencia que distingue "preserva el orden"
// de "vuelve a ordenar".
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));
expect(hijos.first.id, 'emisora:uuid-0');
expect(hijos.last.id, 'emisora:uuid-49');
});
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, PRESERVA el orden recibido (ya no '
'reordena por calidad — item 2 android-auto-orden fix) 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-0');
expect(hijos.last.id, 'emisora:uuid-49');
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('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);
},
);
test('item 3: emisora SIN favicon usable -- el MediaItem "en reproducción" '
'usa el fallback de marca (artUriPara), no queda con artUri null '
'(antes se perdía el arte al reproducir desde el auto)', () async {
final emisora = _emisora(
uuid: 'uuid-sin-arte',
nombre: 'Radio sin logo',
favicon: null,
);
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!.artUri, isNotNull);
expect(recibido!.artUri.toString(), artUriPara(emisora));
});
test(
'item 3: emisora CON favicon http(s) usable -- el MediaItem "en '
'reproducción" sigue usando ese favicon real, no el fallback',
() async {
final emisora = _emisora(
uuid: 'uuid-con-arte',
nombre: 'Radio con logo',
favicon: 'https://cdn.example.com/logo.png',
);
final fuente = _FakeFuenteEmisorasAuto(
porUuidResultado: {emisora.uuid: emisora},
);
MediaItem? recibido;
await reproducirPorMediaId(
'emisora:${emisora.uuid}',
fuente: fuente,
reproducir: (item) async {
recibido = item;
},
);
expect(recibido!.artUri.toString(), emisora.favicon);
},
);
});
// ── Item 1: Android Auto subfolder visibility ───────────────────────────
// `itemsLocales` used to sort a folder's children by name ONLY, mixing
// directories and files -- a subfolder whose name sorted alphabetically
// after enough tracks landed on a later "Más…" page, making it invisible
// until the driver paged through every track first. Fix: directories
// before files, then by name within each group (standard file-browser
// convention) -- subfolders always land on the first page.
group(
'compararNodoLocalParaNavegacion (Design "Directories before files")',
() {
test('a directory always sorts before a file, regardless of name', () {
const archivo = NodoLocal(
documentId: 'f1',
nombre: 'AAA archivo.mp3',
esDirectorio: false,
);
const carpeta = NodoLocal(
documentId: 'd1',
nombre: 'ZZZ carpeta',
esDirectorio: true,
);
expect(compararNodoLocalParaNavegacion(carpeta, archivo), lessThan(0));
expect(
compararNodoLocalParaNavegacion(archivo, carpeta),
greaterThan(0),
);
});
test('two directories sort alphabetically by name', () {
const a = NodoLocal(
documentId: 'd1',
nombre: 'Alpha',
esDirectorio: true,
);
const b = NodoLocal(
documentId: 'd2',
nombre: 'Beta',
esDirectorio: true,
);
expect(compararNodoLocalParaNavegacion(a, b), lessThan(0));
expect(compararNodoLocalParaNavegacion(b, a), greaterThan(0));
});
test('two files sort alphabetically by name', () {
const a = NodoLocal(
documentId: 'f1',
nombre: 'Alpha.mp3',
esDirectorio: false,
);
const b = NodoLocal(
documentId: 'f2',
nombre: 'Beta.mp3',
esDirectorio: false,
);
expect(compararNodoLocalParaNavegacion(a, b), lessThan(0));
});
test('equal esDirectorio and equal name compares equal', () {
const a = NodoLocal(
documentId: 'f1',
nombre: 'Same.mp3',
esDirectorio: false,
);
const b = NodoLocal(
documentId: 'f2',
nombre: 'Same.mp3',
esDirectorio: false,
);
expect(compararNodoLocalParaNavegacion(a, b), 0);
});
},
);
group('ConstructorArbolAuto.itemsLocales: subfolders sort before files '
'(Android Auto subfolder-visibility bug)', () {
test(
'a subfolder whose name sorts AFTER every one of 80 numbered '
'tracks (e.g. "Live") still lands on page 0 -- pure alphabetical '
'mixing used to push it to a later page, making it unreachable '
'without paging through every track first (the reported bug)',
() async {
final nodos = [
for (var i = 1; i <= 80; i++)
NodoLocal(
documentId: 'track-$i',
nombre: '${i.toString().padLeft(3, '0')} Track.mp3',
esDirectorio: false,
),
const NodoLocal(
documentId: 'folder-live',
nombre: 'Live',
esDirectorio: true,
),
];
final pagina0 = await ConstructorArbolAuto().itemsLocales(
nodos,
documentIdPadre: 'root',
metadatosDe: _metadatosVacio,
);
expect(
pagina0.where((i) => i.id == 'carpeta_local:folder-live'),
hasLength(1),
reason:
'the "Live" subfolder must be visible on the FIRST page, '
'not hidden behind 80 tracks on a later page',
);
},
);
test('mixed directories and files: ALL directories precede ALL files, '
'each group still alphabetical by name', () async {
final nodos = [
const NodoLocal(
documentId: 'f-z',
nombre: 'zzz.mp3',
esDirectorio: false,
),
const NodoLocal(
documentId: 'd-z',
nombre: 'ZZZ Carpeta',
esDirectorio: true,
),
const NodoLocal(
documentId: 'f-a',
nombre: 'aaa.mp3',
esDirectorio: false,
),
const NodoLocal(
documentId: 'd-a',
nombre: 'AAA Carpeta',
esDirectorio: true,
),
];
final items = await ConstructorArbolAuto().itemsLocales(
nodos,
documentIdPadre: 'root',
metadatosDe: _metadatosVacio,
);
final soloArbol =
items
.where(
(i) =>
i.id.startsWith('carpeta_local:') ||
i.id.startsWith('pista:'),
)
.toList();
expect(soloArbol.map((i) => i.id).toList(), [
'carpeta_local:d-a',
'carpeta_local:d-z',
'pista:f-a',
'pista:f-z',
]);
});
});
// ── Item 2: Android Auto recursive folder play ─────────────────────────
// totalPistas used to count only DIRECT audio children, so "Reproducir
// carpeta"/"Aleatorio" were hidden for a folder that contains only
// subfolders, and playing a folder queued only its direct tracks. Fix: a
// bounded recursive walk collects every track beneath a folder, and the
// play actions are offered whenever that recursive count is > 0.
group('pistasRecursivas (Design "recursive folder play", item 2)', () {
test('flat folder (no subfolders): same tracks as the direct-children-'
'only view, sorted by name', () async {
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {
'root': 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),
],
},
);
final resultado = await pistasRecursivas('root', fuente: fuente);
expect(resultado.map((n) => n.documentId).toList(), [
'd-a',
'd-b',
'd-c',
]);
});
test('a folder that contains ONLY subfolders: recursively collects the '
'tracks from every one of them', () async {
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {
'root': const [
NodoLocal(documentId: 'sub-a', nombre: 'A', esDirectorio: true),
NodoLocal(documentId: 'sub-b', nombre: 'B', esDirectorio: true),
],
'sub-a': const [
NodoLocal(documentId: 't1', nombre: 'uno.mp3', esDirectorio: false),
],
'sub-b': const [
NodoLocal(documentId: 't2', nombre: 'dos.mp3', esDirectorio: false),
],
},
);
final resultado = await pistasRecursivas('root', fuente: fuente);
expect(resultado.map((n) => n.documentId).toSet(), {'t1', 't2'});
});
test('a nested tree deeper than one level: finds a track 3 levels below '
'the tapped folder', () async {
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {
'root': const [
NodoLocal(
documentId: 'nivel1',
nombre: 'Nivel1',
esDirectorio: true,
),
],
'nivel1': const [
NodoLocal(
documentId: 'nivel2',
nombre: 'Nivel2',
esDirectorio: true,
),
],
'nivel2': const [
NodoLocal(
documentId: 'nivel3',
nombre: 'Nivel3',
esDirectorio: true,
),
],
'nivel3': const [
NodoLocal(
documentId: 't-hondo',
nombre: 'hondo.mp3',
esDirectorio: false,
),
],
},
);
final resultado = await pistasRecursivas('root', fuente: fuente);
expect(resultado.map((n) => n.documentId).toList(), ['t-hondo']);
});
test('depth bound: a track beyond profundidadMaxima levels is NOT '
'collected', () async {
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {
'root': const [
NodoLocal(documentId: 'n1', nombre: 'N1', esDirectorio: true),
],
'n1': const [
NodoLocal(documentId: 'n2', nombre: 'N2', esDirectorio: true),
],
'n2': const [
NodoLocal(
documentId: 't-hondo',
nombre: 'hondo.mp3',
esDirectorio: false,
),
],
},
);
// profundidadMaxima: 1 allows root -> n1 (1 hop) but NOT n1 -> n2
// (a 2nd hop), so the track inside n2 is never reached.
final resultado = await pistasRecursivas(
'root',
fuente: fuente,
profundidadMaxima: 1,
);
expect(resultado, isEmpty);
});
test('count bound: stops collecting once limite tracks are gathered, '
'even if more exist', () async {
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {
'root': List.generate(
10,
(i) => NodoLocal(
documentId: 't$i',
nombre: 'cancion_$i.mp3',
esDirectorio: false,
),
),
},
);
final resultado = await pistasRecursivas(
'root',
fuente: fuente,
limite: 3,
);
expect(resultado, hasLength(3));
});
test('a subfolder that fails to resolve (revoked permission) is skipped; '
'sibling subfolders are still visited, never throws', () async {
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {
'root': const [
NodoLocal(
documentId: 'sub-mala',
nombre: 'A-Mala',
esDirectorio: true,
),
NodoLocal(
documentId: 'sub-buena',
nombre: 'B-Buena',
esDirectorio: true,
),
],
'sub-buena': const [
NodoLocal(
documentId: 't-ok',
nombre: 'ok.mp3',
esDirectorio: false,
),
],
},
idsConErrorEnHijos: const {'sub-mala'},
);
final resultado = await pistasRecursivas('root', fuente: fuente);
expect(resultado.map((n) => n.documentId).toList(), ['t-ok']);
});
test(
'carpeta vacía (sin hijos en absoluto) devuelve lista vacía',
() async {
final fuente = _FakeFuenteMusicaLocalAuto();
final resultado = await pistasRecursivas('root', fuente: fuente);
expect(resultado, isEmpty);
},
);
});
group('ConstructorArbolAuto.itemsLocales: recursive folder-play gate '
'(item 2)', () {
test('folder with ONLY subfolders, but a subfolder recursively '
'contains a track (fuente injected): DOES prepend both play '
'actions', () async {
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {
'sub1': const [
NodoLocal(
documentId: 't-hondo',
nombre: 'hondo.mp3',
esDirectorio: false,
),
],
},
);
final nodos = [
const NodoLocal(
documentId: 'sub1',
nombre: 'Subcarpeta',
esDirectorio: true,
),
];
final pagina0 = await ConstructorArbolAuto().itemsLocales(
nodos,
documentIdPadre: 'padre1',
metadatosDe: _metadatosVacio,
fuente: fuente,
);
expect(
pagina0.where((i) => i.id.startsWith('carpeta_local_reproducir:')),
hasLength(1),
);
expect(
pagina0.where((i) => i.id.startsWith('carpeta_local_aleatorio:')),
hasLength(1),
);
});
test('folder with only subfolders, nested 2 levels deep, track only '
'at the deepest level (fuente injected): still prepends both '
'actions', () async {
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {
'nivel1': const [
NodoLocal(documentId: 'nivel2', nombre: 'N2', esDirectorio: true),
],
'nivel2': const [
NodoLocal(
documentId: 't-hondo',
nombre: 'hondo.mp3',
esDirectorio: false,
),
],
},
);
final nodos = [
const NodoLocal(
documentId: 'nivel1',
nombre: 'Nivel1',
esDirectorio: true,
),
];
final pagina0 = await ConstructorArbolAuto().itemsLocales(
nodos,
documentIdPadre: 'padre1',
metadatosDe: _metadatosVacio,
fuente: fuente,
);
expect(
pagina0.where((i) => i.id.startsWith('carpeta_local_reproducir:')),
hasLength(1),
);
});
test('folder with only subfolders that are genuinely empty even '
'recursively (fuente injected): still does NOT prepend', () async {
final fuente = _FakeFuenteMusicaLocalAuto();
final nodos = [
const NodoLocal(
documentId: 'd-sub',
nombre: 'Subcarpeta',
esDirectorio: true,
),
];
final pagina0 = await ConstructorArbolAuto().itemsLocales(
nodos,
documentIdPadre: 'padre1',
metadatosDe: _metadatosVacio,
fuente: fuente,
);
expect(
pagina0.where((i) => i.id.startsWith('carpeta_local_reproducir:')),
isEmpty,
);
expect(
pagina0.where((i) => i.id.startsWith('carpeta_local_aleatorio:')),
isEmpty,
);
});
test('without a fuente (omitted): falls back to direct-count-only '
'gating -- folder with only subfolders never prepends, matching '
'the pre-item-2 behaviour', () 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,
);
});
});
}
/// 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({
EstadoCarpetaLocal estado = EstadoCarpetaLocal.configurada,
Map<String, List<NodoLocal>>? hijosPorDocId,
Map<String, String?>? uriPorDocId,
Map<String, MetadatosPista>? metadatosPorDocId,
Object? errorEnHijos,
Object? errorEnUriContenido,
Object? errorEnMetadatosDe,
Set<String>? idsConErrorEnHijos,
}) : _estado = estado,
_hijosPorDocId = hijosPorDocId ?? const {},
_uriPorDocId = uriPorDocId ?? const {},
_metadatosPorDocId = metadatosPorDocId ?? const {},
_errorEnHijos = errorEnHijos,
_errorEnUriContenido = errorEnUriContenido,
_errorEnMetadatosDe = errorEnMetadatosDe,
_idsConErrorEnHijos = idsConErrorEnHijos ?? const {};
final EstadoCarpetaLocal _estado;
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;
/// Item 2 (recursive folder play): documentIds whose `hijos` call throws
/// -- used to prove a single unresolvable subfolder does not abort the
/// whole recursive walk, unlike [_errorEnHijos] (which fails EVERY call).
final Set<String> _idsConErrorEnHijos;
/// 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<EstadoCarpetaLocal> estadoCarpeta() async => _estado;
@override
Future<List<NodoLocal>> hijos(String documentId) async {
if (_errorEnHijos != null) throw _errorEnHijos;
if (_idsConErrorEnHijos.contains(documentId)) {
throw Exception('permiso revocado para $documentId');
}
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;
}
}