Files
pluriwave/test/servicios/navegacion_auto_test.dart
T
FreeTLab 241f81e535 fix: cumplir las guias de calidad de Android Auto y localizar el arbol del coche
Google Play devolvio "Approved with Issues" en el codigo 157: "clicking on
stop button makes the entire app useless", citado contra las Android for Cars
App Quality Guidelines. La causa no era el boton de parar.

Maquina de estados del transporte

_cambiarFuente publicaba mediaItem y loading ANTES de su primer await y solo
comprobaba su revision despues de que _recrearPlayer retornase. Los cambios de
fuente se encolan incrementando la revision al encolar, no al ejecutar, asi
que tocar una emisora, tocar otra antes de que cargue y pulsar Stop dejaba que
las entradas obsoletas reescribieran loading sobre el idle que stop() acababa
de publicar. Estado final: loading para siempre sobre una sesion que
audio_service ya habia desactivado. Ahora la guarda de revision es la primera
sentencia del metodo.

pause() no invalidaba una carga en vuelo, asi que la emisora arrancaba igual
despues de pulsar pausa; se revalida la intencion antes de llamar a play().
Se anade un suelo de estado que cierra cualquier loading o buffering sin carga
viva, exento cuando el reproductor ya entrego audio y solo esta rebufferando,
para no convertir un tunel en un error. El presupuesto hasta el primer mensaje
baja a menos de diez segundos y los reintentos ya no borran el mensaje visible.

Tier gratuito en el coche

El arbol devolvia una unica fila no reproducible para cualquier carpeta cuando
no habia premium, y un revisor con instalacion limpia siempre es tier
gratuito. Ademas skipToNext, skipToPrevious, playFromSearch y playFromMediaId
retornaban en silencio. La raiz gratuita pasa a ofrecer una sola carpeta con
emisoras reales y reproducibles, compiladas en el binario para que existan en
frio, y la puerta de entitlement acota contenido en vez de bloquear acciones.
Se elimina la fila "Funcion Premium". Una consulta de voz vacia arranca la
ultima emisora, que fallaba tambien a los clientes de pago.

Localizacion

El locale del handler solo lo fijaba un widget que el motor headless nunca
construye, asi que todo error del coche salia en castellano. Se resuelve desde
el locale de plataforma. Se traducen las once etiquetas del arbol que estaban
a fuego y se retira la convencion que lo justificaba. Un test nuevo falla si
vuelve a aparecer texto visible fuera del sistema de traduccion.

Suite completa: 1455 pasan, 2 omitidos. Los mecanismos se verificaron por
mutacion: borrar cada uno pone la suite en rojo. flutter analyze mantiene los
5 avisos preexistentes.
2026-09-02 22:56:10 +02:00

3768 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 sale de `EtiquetasArbolAuto.musicaLocalNoDisponible`,
/// como TODAS las etiquetas legibles del árbol del coche: todo lo que
/// un usuario lee se traduce. Este test solo comprueba que hay UNA
/// etiqueta no vacía; el idioma concreto lo cubre
/// `navegacion_auto_localizacion_test.dart`.
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;
}
}