feat(streaming): buffer resilience and automatic reconnection
- Construct the audio player with an enlarged live-stream buffer (15-50s forward cushion, 2.5s to start, 5s after rebuffer) so short network drops play through silently - Add reconnect-on-stall state machine with bounded exponential backoff (1/2/4/8/16s, ~90s total window, 5 attempts) that re-prepares to the live edge; backoff/decision logic extracted to controlador_reconexion.dart as pure testable code - Surface a new reconnecting playback state in the mini player and full player (localized in all 13 locales) instead of error dialogs during the retry window; a single friendly error appears only after exhaustion - Guard interplay: user pause/stop cancels retries, audio interruptions cancel reconnect, alarm wake-up path keeps precedence, recording fails cleanly during drops - Reset retry budget on station change; route stream timeouts through the network-error class - 10 new tests (99 total green), flutter analyze clean
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/controlador_reconexion.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
/// Fake timer injected through the controller's timer factory so backoff
|
||||
/// scheduling is observable without real delays or platform channels.
|
||||
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 disparar() {
|
||||
if (!cancelado) callback();
|
||||
}
|
||||
}
|
||||
|
||||
ControladorReconexion _controlador({
|
||||
int maxReintentos = 5,
|
||||
Duration retrasoMaximo = const Duration(seconds: 30),
|
||||
required List<_TemporizadorFalso> temporizadores,
|
||||
}) {
|
||||
return ControladorReconexion(
|
||||
maxReintentos: maxReintentos,
|
||||
retrasoMaximo: retrasoMaximo,
|
||||
crearTemporizador: (duracion, callback) {
|
||||
final timer = _TemporizadorFalso(duracion, callback);
|
||||
temporizadores.add(timer);
|
||||
return timer;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('ControladorReconexion — backoff (S7-R2-D, S7-R7)', () {
|
||||
test('secuencia de retrasos 1-5 es [1s, 2s, 4s, 8s, 16s]', () {
|
||||
final controlador = ControladorReconexion();
|
||||
expect(
|
||||
[for (var i = 1; i <= 5; i++) controlador.retrasoParaIntento(i)],
|
||||
const [
|
||||
Duration(seconds: 1),
|
||||
Duration(seconds: 2),
|
||||
Duration(seconds: 4),
|
||||
Duration(seconds: 8),
|
||||
Duration(seconds: 16),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('el retraso queda limitado por retrasoMaximo', () {
|
||||
final controlador = ControladorReconexion(
|
||||
retrasoMaximo: Duration(seconds: 10),
|
||||
);
|
||||
expect(
|
||||
controlador.retrasoParaIntento(5),
|
||||
const Duration(seconds: 10),
|
||||
reason: '16s sin limite, pero el maximo configurado es 10s',
|
||||
);
|
||||
expect(
|
||||
ControladorReconexion().retrasoParaIntento(6),
|
||||
const Duration(seconds: 30),
|
||||
reason: '32s supera el maximo por defecto de 30s',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('ControladorReconexion — decision de reintento (S7-R2, S7-R7)', () {
|
||||
test('intencion=true + fallo => reintento programado con backoff', () {
|
||||
final temporizadores = <_TemporizadorFalso>[];
|
||||
final controlador = _controlador(temporizadores: temporizadores);
|
||||
var reintentos = 0;
|
||||
|
||||
final decision = controlador.registrarFallo(
|
||||
intencionReproducir: true,
|
||||
alReintentar: () => reintentos++,
|
||||
);
|
||||
|
||||
expect(decision, DecisionReconexion.reintentar);
|
||||
expect(controlador.intentos, 1);
|
||||
expect(controlador.reintentoPendiente, isTrue);
|
||||
expect(temporizadores, hasLength(1));
|
||||
expect(temporizadores.single.duracion, const Duration(seconds: 1));
|
||||
|
||||
temporizadores.single.disparar();
|
||||
expect(reintentos, 1, reason: 'el callback corre al vencer el backoff');
|
||||
});
|
||||
|
||||
test('intencion=false + fallo => NO se programa reintento (S7-R2-B)', () {
|
||||
final temporizadores = <_TemporizadorFalso>[];
|
||||
final controlador = _controlador(temporizadores: temporizadores);
|
||||
|
||||
final decision = controlador.registrarFallo(
|
||||
intencionReproducir: false,
|
||||
alReintentar: () => fail('no debe reintentar con intencion=false'),
|
||||
);
|
||||
|
||||
expect(decision, DecisionReconexion.ignorar);
|
||||
expect(controlador.intentos, 0);
|
||||
expect(controlador.reintentoPendiente, isFalse);
|
||||
expect(temporizadores, isEmpty);
|
||||
});
|
||||
|
||||
test('tras maxReintentos agotados => agotado, sin mas reintentos '
|
||||
'(S7-R2-C)', () {
|
||||
final temporizadores = <_TemporizadorFalso>[];
|
||||
final controlador = _controlador(
|
||||
maxReintentos: 2,
|
||||
temporizadores: temporizadores,
|
||||
);
|
||||
|
||||
for (var i = 0; i < 2; i++) {
|
||||
expect(
|
||||
controlador.registrarFallo(
|
||||
intencionReproducir: true,
|
||||
alReintentar: () {},
|
||||
),
|
||||
DecisionReconexion.reintentar,
|
||||
);
|
||||
}
|
||||
final decision = controlador.registrarFallo(
|
||||
intencionReproducir: true,
|
||||
alReintentar: () => fail('no debe programar tras agotar reintentos'),
|
||||
);
|
||||
|
||||
expect(decision, DecisionReconexion.agotado);
|
||||
expect(controlador.reintentoPendiente, isFalse);
|
||||
expect(temporizadores, hasLength(2));
|
||||
});
|
||||
|
||||
test('una reconexion exitosa restablece el contador (S7-R7)', () {
|
||||
final temporizadores = <_TemporizadorFalso>[];
|
||||
final controlador = _controlador(temporizadores: temporizadores);
|
||||
|
||||
controlador.registrarFallo(
|
||||
intencionReproducir: true,
|
||||
alReintentar: () {},
|
||||
);
|
||||
controlador.registrarFallo(
|
||||
intencionReproducir: true,
|
||||
alReintentar: () {},
|
||||
);
|
||||
expect(controlador.intentos, 2);
|
||||
|
||||
controlador.restablecer();
|
||||
|
||||
expect(controlador.intentos, 0);
|
||||
expect(controlador.reintentoPendiente, isFalse);
|
||||
controlador.registrarFallo(
|
||||
intencionReproducir: true,
|
||||
alReintentar: () {},
|
||||
);
|
||||
expect(
|
||||
temporizadores.last.duracion,
|
||||
const Duration(seconds: 1),
|
||||
reason: 'tras restablecer, el backoff arranca de nuevo en la base',
|
||||
);
|
||||
});
|
||||
|
||||
test('stop del usuario durante el stall cancela el reintento (S7-R6)', () {
|
||||
final temporizadores = <_TemporizadorFalso>[];
|
||||
final controlador = _controlador(temporizadores: temporizadores);
|
||||
var reintentos = 0;
|
||||
|
||||
controlador.registrarFallo(
|
||||
intencionReproducir: true,
|
||||
alReintentar: () => reintentos++,
|
||||
);
|
||||
expect(controlador.reintentoPendiente, isTrue);
|
||||
|
||||
controlador.cancelar();
|
||||
|
||||
expect(controlador.reintentoPendiente, isFalse);
|
||||
expect(temporizadores.single.cancelado, isTrue);
|
||||
temporizadores.single.disparar();
|
||||
expect(reintentos, 0, reason: 'un timer cancelado nunca reintenta');
|
||||
});
|
||||
});
|
||||
|
||||
group('Buffer de stream en vivo (S7-R1)', () {
|
||||
test('la configuracion Android usa los valores del diseno 7.1', () {
|
||||
const config = PluriWaveAudioHandler.configuracionCargaAndroid;
|
||||
final control = config.androidLoadControl;
|
||||
|
||||
expect(control, isNotNull);
|
||||
expect(control!.minBufferDuration, const Duration(seconds: 15));
|
||||
expect(control.maxBufferDuration, const Duration(seconds: 50));
|
||||
expect(
|
||||
control.bufferForPlaybackDuration,
|
||||
const Duration(milliseconds: 2500),
|
||||
);
|
||||
expect(
|
||||
control.bufferForPlaybackAfterRebufferDuration,
|
||||
const Duration(seconds: 5),
|
||||
);
|
||||
expect(control.prioritizeTimeOverSizeThresholds, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:pluriwave/widgets/mini_reproductor.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
EstadoRadio _estadoRadio(FakeServicioAudio audio) {
|
||||
return EstadoRadio(
|
||||
audio: audio,
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// S7-R3-A: while the handler is reconnecting, the UI shows a loading
|
||||
/// indicator — never an error dialog or snackbar per retry attempt.
|
||||
void main() {
|
||||
testWidgets(
|
||||
'estado reconectando muestra indicador de carga, sin dialogo ni snackbar',
|
||||
(tester) async {
|
||||
final audio = FakeServicioAudio();
|
||||
final estado = _estadoRadio(audio);
|
||||
addTearDown(estado.dispose);
|
||||
await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno'));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<EstadoRadio>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: MiniReproductor()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
audio.emitirEstado(EstadoReproduccion.reconectando);
|
||||
// Two pumps: one delivers the stream event, one rebuilds the frame.
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
expect(
|
||||
find.byType(CircularProgressIndicator),
|
||||
findsOneWidget,
|
||||
reason: 'reconectando se presenta como carga, no como error',
|
||||
);
|
||||
expect(
|
||||
find.byIcon(Icons.refresh_rounded),
|
||||
findsNothing,
|
||||
reason: 'el boton de reintento manual es solo para el estado error',
|
||||
);
|
||||
expect(find.text('Reconectando...'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('el estado error si muestra el boton de reintento manual', (
|
||||
tester,
|
||||
) async {
|
||||
final audio = FakeServicioAudio();
|
||||
final estado = _estadoRadio(audio);
|
||||
addTearDown(estado.dispose);
|
||||
await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno'));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<EstadoRadio>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: MiniReproductor()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
audio.emitirEstado(EstadoReproduccion.error);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(find.byIcon(Icons.refresh_rounded), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user