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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user