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.
152 lines
5.4 KiB
Dart
152 lines
5.4 KiB
Dart
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);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|