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:
@@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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>', () {
|
||||
|
||||
Reference in New Issue
Block a user