Files
pluriwave/test/servicios/controlador_reconexion_local_test.dart
T
FreeTLab dfd40ca937 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.
2026-07-20 01:08:15 +02:00

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