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);
});
});
}