feat(auto): queue playback and shuffle for local music folders [size:exception]

Adds "Reproducir carpeta" (sequential) and "Reproducir aleatorio"
(Fisher-Yates over the name-sorted order) as folder-scoped playable
actions, with auto-advance on track completion and skip next/prev.

Isolation from live radio is structural, not disciplinary: the
public playMediaItem always clears the local queue on any call, and
a new private _encolarCambioFuente is the only path that can advance
within it. _cambiarFuente, ControladorReconexion, and the reconnect
error path are untouched -- confirmed by a byte-for-byte empty diff
on all 4 pre-existing radio/reconnect regression suites, independently
re-run before and after (21/21 both times).

Handler wiring itself is static-review-only (PluriWaveAudioHandler
can't be unit-instantiated); the isolation/advance/race-guard
decision logic is extracted into cola_local.dart's pure functions,
which are fully unit-tested.
This commit is contained in:
2026-07-20 01:08:15 +02:00
parent 85dd043cd4
commit dfd40ca937
12 changed files with 2434 additions and 10 deletions
+181
View File
@@ -0,0 +1,181 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/pista_local.dart';
import 'package:pluriwave/servicios/cola_local.dart';
/// [PluriWaveAudioHandler] cannot be instantiated in unit tests (its
/// constructor builds a real `just_audio.AudioPlayer` needing platform
/// `MethodChannel`s — confirmed by `servicio_audio_eq_reapply_test.dart` and
/// `servicio_audio_source_switch_test.dart`). `cola_local.dart` extracts the
/// queue-advance DECISION logic into pure, side-effect-free functions —
/// mirroring how `ControladorReconexion` was extracted from
/// `servicio_audio.dart` — so it is fully unit-testable here; the actual
/// field mutation on the handler is static-review-only (Phase 3 tasks).
void main() {
const n0 = NodoLocal(documentId: 'd0', nombre: 'a.mp3', esDirectorio: false);
const n1 = NodoLocal(documentId: 'd1', nombre: 'b.mp3', esDirectorio: false);
const n2 = NodoLocal(documentId: 'd2', nombre: 'c.mp3', esDirectorio: false);
group('ColaLocal', () {
test('cola vacía: hayActual es false, conSiguiente es null', () {
const cola = ColaLocal(pistas: []);
expect(cola.hayActual, isFalse);
expect(cola.conSiguiente(), isNull);
});
test('cola vacía: conAnterior se clampa en 0 sin lanzar', () {
const cola = ColaLocal(pistas: []);
expect(cola.conAnterior().indice, 0);
});
test('un solo item: conSiguiente es null (ya es el único/último)', () {
const cola = ColaLocal(pistas: [n0]);
expect(cola.hayActual, isTrue);
expect(cola.actual, same(n0));
expect(cola.conSiguiente(), isNull);
});
test('un solo item: conAnterior se clampa en 0 (reinicia la pista)', () {
const cola = ColaLocal(pistas: [n0]);
expect(cola.conAnterior().indice, 0);
});
test('medio de la lista: conSiguiente avanza el índice y expone la '
'pista siguiente', () {
const cola = ColaLocal(pistas: [n0, n1, n2], indice: 0);
final siguiente = cola.conSiguiente();
expect(siguiente, isNotNull);
expect(siguiente!.indice, 1);
expect(siguiente.actual, same(n1));
});
test('medio de la lista: conAnterior retrocede el índice', () {
const cola = ColaLocal(pistas: [n0, n1, n2], indice: 2);
final anterior = cola.conAnterior();
expect(anterior.indice, 1);
expect(anterior.actual, same(n1));
});
test('límite: último índice, conSiguiente es null (fin de cola, sin '
'loop — Design ADR-4)', () {
const cola = ColaLocal(pistas: [n0, n1], indice: 1);
expect(cola.conSiguiente(), isNull);
});
test('límite: primer índice, conAnterior se clampa en 0 (no da '
'negativo)', () {
const cola = ColaLocal(pistas: [n0, n1], indice: 0);
expect(cola.conAnterior().indice, 0);
});
test('conSiguiente/conAnterior devuelven una instancia NUEVA, nunca '
'mutan la original', () {
const original = ColaLocal(pistas: [n0, n1, n2], indice: 0);
final siguiente = original.conSiguiente();
expect(original.indice, 0, reason: 'la instancia original no cambia');
expect(siguiente, isNot(same(original)));
});
});
group('decidirAvanceCola', () {
test('colaLocal == null => ninguna (proxy de aislamiento de radio: '
'radio nunca setea _colaLocal)', () {
expect(
decidirAvanceCola(
colaLocal: null,
avanzandoCola: false,
trackCompletado: true,
),
DecisionAvanceCola.ninguna,
);
});
test('trackCompletado == false => ninguna, sin importar el resto '
'(estado buffering/ready no dispara avance)', () {
const cola = ColaLocal(pistas: [n0, n1]);
expect(
decidirAvanceCola(
colaLocal: cola,
avanzandoCola: false,
trackCompletado: false,
),
DecisionAvanceCola.ninguna,
);
});
test('avanzandoCola == true => ninguna (latch de re-entrada: dos '
'emisiones "completado" rápidas producen un solo avance)', () {
const cola = ColaLocal(pistas: [n0, n1]);
final primera = decidirAvanceCola(
colaLocal: cola,
avanzandoCola: false,
trackCompletado: true,
);
expect(primera, DecisionAvanceCola.avanzar);
// Simula una segunda emisión "completado" mientras el primer avance
// sigue resolviendo su URI (el llamador ya seteó el latch en true).
final segunda = decidirAvanceCola(
colaLocal: cola,
avanzandoCola: true,
trackCompletado: true,
);
expect(segunda, DecisionAvanceCola.ninguna);
});
test('conSiguiente() == null => desactivar (fin de cola)', () {
const cola = ColaLocal(pistas: [n0, n1], indice: 1);
expect(
decidirAvanceCola(
colaLocal: cola,
avanzandoCola: false,
trackCompletado: true,
),
DecisionAvanceCola.desactivar,
);
});
test('hay una pista siguiente => avanzar', () {
const cola = ColaLocal(pistas: [n0, n1], indice: 0);
expect(
decidirAvanceCola(
colaLocal: cola,
avanzandoCola: false,
trackCompletado: true,
),
DecisionAvanceCola.avanzar,
);
});
});
group('avanceEsValido', () {
test('la misma instancia => true', () {
const cola = ColaLocal(pistas: [n0]);
expect(avanceEsValido(cola, cola), isTrue);
});
test('instancias distintas aunque estructuralmente iguales => false '
'(guarda contra un swap accidental de == por identical)', () {
// Non-const construction is deliberate: two `const` instances with
// identical field values are canonicalized to the SAME object by
// Dart, which would make this assertion pass for the wrong reason.
// A real mid-await race produces two genuinely distinct instances
// (e.g. one from an auto-advance, one from a user's `playMediaItem`
// during the await), so non-const `ColaLocal(...)` is the accurate
// reproduction.
final a = ColaLocal(pistas: [n0, n1], indice: 1);
final b = ColaLocal(pistas: [n0, n1], indice: 1);
expect(avanceEsValido(a, b), isFalse);
});
test('null vs no-null => false', () {
const cola = ColaLocal(pistas: [n0]);
expect(avanceEsValido(null, cola), isFalse);
expect(avanceEsValido(cola, null), isFalse);
});
test('ambos null => true (ningún lado tiene cola activa)', () {
expect(avanceEsValido(null, null), isTrue);
});
});
}
@@ -0,0 +1,151 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/controlador_reconexion.dart';
/// Local-music design open question (`android-auto-local-music-phase3`
/// design.md "Open Questions"): local-track source errors during
/// queue-advance (a dead/moved/permission-revoked file) enter the SAME
/// `ControladorReconexion` bounded-retry machine live radio uses, unchanged
/// — the design's recommendation is to leave `ControladorReconexion`
/// untouched rather than add queue-awareness to the sensitive error path
/// (Phase 5 task 5.1/5.2).
///
/// [ControladorReconexion.registrarFallo] takes NO source-type parameter
/// (confirmed by reading `controlador_reconexion.dart` — Phase 5 task 5.1's
/// static review), so it structurally CANNOT special-case a local-track
/// error vs a radio error: this file proves the bounded-retry contract
/// directly against the controller (source-agnostic by construction), with
/// the DEFAULT `maxReintentos: 5` this design decision relies on — a
/// "dead local track" retries up to 5 times then fails cleanly, with no 6th
/// retry, no hang, and no crash.
///
/// Kept as a SEPARATE file from `servicio_audio_reconnect_test.dart`
/// (rather than adding a case there) so that protected regression suite's
/// exact pass count stays byte-identical across this change (Phase 5 task
/// 5.4's exact-count re-verification gate).
class _TemporizadorFalso implements Timer {
_TemporizadorFalso(this.duracion, this.callback);
final Duration duracion;
final void Function() callback;
bool cancelado = false;
@override
void cancel() => cancelado = true;
@override
bool get isActive => !cancelado;
@override
int get tick => 0;
}
void main() {
group(
'ControladorReconexion — pista local muerta (design open question, '
'maxReintentos default = 5)',
() {
test(
'5 fallos consecutivos => reintentar x5 con backoff, luego agotado '
'en el 6to, SIN programar un 6to reintento, sin lanzar (no hang, '
'no crash)',
() {
final temporizadores = <_TemporizadorFalso>[];
final controlador = ControladorReconexion(
crearTemporizador: (duracion, callback) {
final timer = _TemporizadorFalso(duracion, callback);
temporizadores.add(timer);
return timer;
},
);
for (var i = 1; i <= 5; i++) {
final decision = controlador.registrarFallo(
intencionReproducir: true,
alReintentar: () {},
);
expect(
decision,
DecisionReconexion.reintentar,
reason: 'el intento $i de 5 debe programar un reintento',
);
}
expect(controlador.intentos, 5);
expect(temporizadores, hasLength(5));
final sexto = controlador.registrarFallo(
intencionReproducir: true,
alReintentar: () =>
fail('no debe programar un 6to reintento tras agotar'),
);
expect(sexto, DecisionReconexion.agotado);
expect(
temporizadores,
hasLength(5),
reason: 'ningún temporizador nuevo se creó para el 6to fallo',
);
expect(
controlador.reintentoPendiente,
isFalse,
reason: 'no queda ningún reintento pendiente tras agotar (sin '
'hang)',
);
},
);
test(
'tras agotar, TODOS los temporizadores (los 5 backoff + el estado '
'final) quedan cancelados — ninguno sigue activo (no hang)',
() {
final temporizadores = <_TemporizadorFalso>[];
final controlador = ControladorReconexion(
crearTemporizador: (duracion, callback) {
final timer = _TemporizadorFalso(duracion, callback);
temporizadores.add(timer);
return timer;
},
);
for (var i = 1; i <= 5; i++) {
controlador.registrarFallo(
intencionReproducir: true,
alReintentar: () {},
);
}
controlador.registrarFallo(
intencionReproducir: true,
alReintentar: () => fail('agotado: no debe reintentar'),
);
expect(
temporizadores.every((t) => t.cancelado),
isTrue,
reason: 'cada temporizador se cancela cuando el siguiente fallo '
'programa uno nuevo, y el último se cancela explícitamente '
'al agotar (ControladorReconexion.cancelar())',
);
},
);
test(
'registrarFallo no recibe ningún parámetro de tipo de fuente — '
'estructuralmente no puede distinguir una pista local de una '
'emisora de radio (Phase 5 task 5.1, revisión estática confirmada '
'por este test de firma)',
() {
// Documentación ejecutable: si esta llamada compila con
// EXACTAMENTE estos 2 named parameters, la firma no tiene (ni
// tuvo) un parámetro de tipo de fuente agregado.
final controlador = ControladorReconexion();
final decision = controlador.registrarFallo(
intencionReproducir: true,
alReintentar: () {},
);
expect(decision, DecisionReconexion.reintentar);
},
);
},
);
}
+582 -8
View File
@@ -1,3 +1,5 @@
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';
@@ -350,6 +352,125 @@ void main() {
);
});
group(
'esCarpetaLocalReproducirMediaId / esCarpetaLocalAleatorioMediaId '
'(Design ADR-5, Phase 3)',
() {
test(
'reconocen sus prefijos, no colisionan entre sí ni con NINGUNO de '
'los 8 prefijos/ids existentes (Spec "New Action Media-IDs Are '
'Collision-Free")',
() {
final builder = ConstructorArbolAuto();
expect(
builder.esCarpetaLocalReproducirMediaId(
'carpeta_local_reproducir:doc1',
),
isTrue,
);
expect(
builder.esCarpetaLocalAleatorioMediaId(
'carpeta_local_aleatorio:doc1',
),
isTrue,
);
final existentes = <String>[
'emisora:x',
'grupo:g1',
'eq_preset:Rock',
'carpeta_local:doc1',
'carpeta_local_pag:0:doc1',
'carpeta_local_ord:calidad:0:doc1',
'carpeta_local_bucket:0:0:doc1',
'pista:doc1',
];
for (final id in existentes) {
expect(
builder.esCarpetaLocalReproducirMediaId(id),
isFalse,
reason: 'esCarpetaLocalReproducirMediaId($id) debería ser '
'false',
);
expect(
builder.esCarpetaLocalAleatorioMediaId(id),
isFalse,
reason: 'esCarpetaLocalAleatorioMediaId($id) debería ser '
'false',
);
}
// Y en la dirección inversa: ninguno de los 8 predicados
// existentes reconoce las 2 nuevas acciones.
const reproducir = 'carpeta_local_reproducir:doc1';
const aleatorio = 'carpeta_local_aleatorio:doc1';
expect(builder.esCarpetaLocalMediaId(reproducir), isFalse);
expect(builder.esCarpetaLocalMediaId(aleatorio), isFalse);
expect(
builder.esCarpetaLocalPaginadaMediaId(reproducir),
isFalse,
);
expect(builder.esCarpetaLocalOrdMediaId(reproducir), isFalse);
expect(builder.esCarpetaLocalBucketMediaId(reproducir), isFalse);
expect(esPistaMediaId(reproducir), isFalse);
expect(esPresetMediaId(reproducir), isFalse);
// Divergencia en el índice 14 (justo después de "carpeta_local_"):
// 'r' (reproducir) / 'a' (aleatorio) vs 'p' (_pag) / 'o' (_ord) /
// 'b' (_bucket) — prueba de colisión de Design ADR-5.
expect(reproducir[14], 'r');
expect(aleatorio[14], 'a');
expect('carpeta_local_pag:0:doc1'[14], 'p');
expect('carpeta_local_ord:calidad:0:doc1'[14], 'o');
expect('carpeta_local_bucket:0:0:doc1'[14], 'b');
// 'carpeta_local:' diverge en el índice 13 (':' vs '_').
expect('carpeta_local:doc1'[13], ':');
expect(reproducir[13], '_');
},
);
test(
'idCarpetaLocalReproducirDesde / idCarpetaLocalAleatorioDesde '
'recortan por longitud, preservando un documentId con ":" y "/" '
'verbatim (Spec "Raw documentIds with :/ survive round-trip")',
() {
final builder = ConstructorArbolAuto();
const docId = 'primary:Music/Local Folder';
expect(
builder.idCarpetaLocalReproducirDesde(
'carpeta_local_reproducir:$docId',
),
docId,
);
expect(
builder.idCarpetaLocalAleatorioDesde(
'carpeta_local_aleatorio:$docId',
),
docId,
);
},
);
test(
'tail vacío (empty tail) hace round-trip a la raíz local',
() {
final builder = ConstructorArbolAuto();
expect(
builder.idCarpetaLocalReproducirDesde('carpeta_local_reproducir:'),
'',
);
expect(
builder.idCarpetaLocalAleatorioDesde('carpeta_local_aleatorio:'),
'',
);
},
);
},
);
group('artUriLocal', () {
test(
'reproduce la misma rotación que artUriPara/indiceArtePara, sembrada '
@@ -713,9 +834,11 @@ void main() {
);
// 50 pistas: <=150 -> entrada de calidad presente; exactamente 50
// NO dispara buckets (umbral es "> 50", Design ADR-4).
// 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(51));
expect(items, hasLength(53));
expect(pistas, hasLength(50));
expect(pistas.first.title, 'cancion_00');
expect(pistas.last.title, 'cancion_49');
@@ -742,10 +865,12 @@ void main() {
documentIdPadre: 'x',
metadatosDe: _metadatosVacio,
);
// 60 pistas: <=150 -> entrada de calidad; >50 -> 4 buckets;
// prepended antes de las 50 pistas + "Más…" (Design ADR-4).
// 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(56));
expect(pagina0, hasLength(58));
expect(pistas0, hasLength(50));
expect(pistas0.first.title, 'cancion_00');
expect(pistas0[49].title, 'cancion_49');
@@ -1069,6 +1194,144 @@ void main() {
});
});
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 '
@@ -1288,9 +1551,14 @@ void main() {
pagina0.where((i) => i.id.startsWith('carpeta_local_bucket:')),
hasLength(4),
);
// Prepended ANTES de la lista ordenada: los primeros 5 items son
// modo+buckets, no pistas.
for (var i = 0; i < 5; i++) {
// 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);
}
@@ -1372,6 +1640,105 @@ void main() {
);
});
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 '
@@ -1908,6 +2275,213 @@ void main() {
);
});
group('reproducirCarpetaLocal (Design ADR-5/ADR-6, Phase 3 task 4.2)', () {
List<NodoLocal> nodosDePrueba() => const [
NodoLocal(documentId: 'd-c', nombre: 'c.mp3', esDirectorio: false),
NodoLocal(documentId: 'd-a', nombre: 'a.mp3', esDirectorio: false),
NodoLocal(documentId: 'd-b', nombre: 'b.mp3', esDirectorio: false),
NodoLocal(
documentId: 'd-carpeta',
nombre: 'AAA Carpeta',
esDirectorio: true,
),
];
test(
'secuencial (aleatorio: false): filtra directorios, ordena por '
'nombre y llama iniciarCola con esa lista',
() async {
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {'carpeta1': nodosDePrueba()},
);
List<NodoLocal>? recibidas;
await reproducirCarpetaLocal(
'carpeta_local_reproducir:carpeta1',
aleatorio: false,
fuente: fuente,
iniciarCola: (pistas) async => recibidas = pistas,
);
expect(recibidas, isNotNull);
expect(
recibidas!.map((n) => n.documentId).toList(),
['d-a', 'd-b', 'd-c'],
);
},
);
test(
'aleatorio (aleatorio: true): filtra directorios y llama iniciarCola '
'con el orden Fisher-Yates producido por el rng inyectado',
() async {
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {'carpeta1': nodosDePrueba()},
);
List<NodoLocal>? recibidas;
await reproducirCarpetaLocal(
'carpeta_local_aleatorio:carpeta1',
aleatorio: true,
fuente: fuente,
rng: Random(42),
iniciarCola: (pistas) async => recibidas = pistas,
);
expect(recibidas, isNotNull);
expect(recibidas, hasLength(3));
expect(recibidas!.every((n) => !n.esDirectorio), isTrue);
expect(
recibidas!.map((n) => n.documentId).toList(),
pistasEnOrdenAleatorio(nodosDePrueba(), Random(42))
.map((n) => n.documentId)
.toList(),
);
},
);
test(
'carpeta vacía (sin hijos) es un no-op: iniciarCola nunca se llama',
() async {
final fuente = _FakeFuenteMusicaLocalAuto();
var llamadas = 0;
await reproducirCarpetaLocal(
'carpeta_local_reproducir:vacia',
aleatorio: false,
fuente: fuente,
iniciarCola: (pistas) async => llamadas++,
);
expect(llamadas, 0);
},
);
test(
'carpeta con solo subcarpetas (sin pistas de audio directas) es un '
'no-op: iniciarCola nunca se llama',
() async {
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {
'carpeta1': const [
NodoLocal(
documentId: 'd-sub',
nombre: 'Subcarpeta',
esDirectorio: true,
),
],
},
);
var llamadas = 0;
await reproducirCarpetaLocal(
'carpeta_local_reproducir:carpeta1',
aleatorio: false,
fuente: fuente,
iniciarCola: (pistas) async => llamadas++,
);
expect(llamadas, 0);
},
);
test(
'carpeta irresoluble (fuente.hijos lanza) es un no-op, sin propagar '
'la excepción',
() async {
final fuente = _FakeFuenteMusicaLocalAuto(
errorEnHijos: Exception('permiso revocado'),
);
var llamadas = 0;
await reproducirCarpetaLocal(
'carpeta_local_reproducir:carpeta1',
aleatorio: false,
fuente: fuente,
iniciarCola: (pistas) async => llamadas++,
);
expect(llamadas, 0);
},
);
test('id sin ninguno de los 2 prefijos es un no-op', () async {
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {'carpeta1': nodosDePrueba()},
);
var llamadas = 0;
await reproducirCarpetaLocal(
'carpeta_local:carpeta1',
aleatorio: false,
fuente: fuente,
iniciarCola: (pistas) async => llamadas++,
);
expect(llamadas, 0);
});
test(
'strip por longitud preserva un documentId con ":" verbatim (round '
'trip), consultando fuente.hijos con el documentId correcto',
() async {
const docId = 'primary:Music/Local Folder';
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {docId: nodosDePrueba()},
);
var llamadas = 0;
await reproducirCarpetaLocal(
'carpeta_local_reproducir:$docId',
aleatorio: false,
fuente: fuente,
iniciarCola: (pistas) async => llamadas++,
);
expect(llamadas, 1);
},
);
});
group('construirMediaItemColaLocal', () {
test(
'resuelve el content uri y construye un MediaItem con el título '
'derivado del documentId',
() async {
final fuente = _FakeFuenteMusicaLocalAuto(
uriPorDocId: const {'doc1': 'content://provider/doc1'},
);
const nodo = NodoLocal(
documentId: 'doc1',
nombre: 'ignorado.mp3',
esDirectorio: false,
);
final item = await construirMediaItemColaLocal(nodo, fuente: fuente);
expect(item, isNotNull);
expect(item!.id, 'content://provider/doc1');
},
);
test(
'documentId irresoluble (uriContenidoDePista devuelve null) '
'devuelve null, sin lanzar',
() async {
final fuente = _FakeFuenteMusicaLocalAuto();
const nodo = NodoLocal(
documentId: 'doc-fantasma',
nombre: 'x.mp3',
esDirectorio: false,
);
final item = await construirMediaItemColaLocal(nodo, fuente: fuente);
expect(item, isNull);
},
);
});
group('ConstructorArbolAuto.itemPresetEq', () {
test('mapea un PresetEcualizador a un item reproducible con id '
'eq_preset:<nombre>', () {