Files
pluriwave/test/pantallas/pantalla_alarma_sonando_test.dart
T
FreeTLab 2e64740b26
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m41s
fix(alarm): anchor the fade at alarm time and defer the override to first audio
On-device logcat from the latest test showed two defects the previous
design created. The fade-in was gated on the station reaching
`reproduciendo`, and the stream took 18.7 seconds to buffer: the ring
sat frozen at 5% the whole time and the configured fade seconds only
started counting afterwards. And the stream override was raised during
pre-start, so the ExoPlayer AudioTrack spin-up — which runs at gain 1.0
for an instant before the player gain lands — blasted at the configured
ring level, heard as "starts directly at the alarm volume".

The ramp is now anchored at alarm time: it starts when the screen
starts, buffering just joins it at the elapsed level, and the fade
duration means seconds-from-alarm. _iniciarFadeIn is single-start so
the handoff confirmation and fallback paths can no longer restart an
in-progress ramp from 5%. The stream override moved from the app-side
pre-start into the screen and is raised only when audio is actually
about to flow (first `reproduciendo`, the already-playing branch, or
right before the fallback WAV plays), so track spin-up happens under
the user's original low volume and the blast is physically impossible.
Exit teardown restores the device stream before resetting the player
gain, removing the brief exit blip seen in the capture.
2026-07-12 00:35:29 +02:00

310 lines
11 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/pantallas/pantalla_alarma_sonando.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
class _Entorno {
_Entorno({
required this.estadoAlarmas,
required this.android,
required this.audio,
});
final EstadoAlarmas estadoAlarmas;
final FakePuertoAlarmasAndroid android;
final FakeServicioAudio audio;
}
Future<_Entorno> _montarPantalla(
WidgetTester tester, {
int snoozeMinutos = 5,
int fadeInSegundos = 0,
// Slice 3 (fade-in dedup): existing callers rely on the radio already
// being "reproduciendo" by mount time, which cancels the fallback timer
// synchronously and leaves nothing to observe mid-handoff. Fade-in-gate
// tests need a live `_estadoSub` subscriber instead, so they set this to
// false and emit `reproduciendo` themselves after the widget mounts.
bool audioYaReproduciendo = true,
}) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final audio = FakeServicioAudio();
if (audioYaReproduciendo) {
audio.emitirEstado(EstadoReproduccion.reproduciendo);
}
final radio = EstadoRadio(
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
addTearDown(radio.dispose);
final android = FakePuertoAlarmasAndroid();
final ahora = DateTime(2026, 6, 11, 7, 0);
final estadoAlarmas = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(
AlarmaMusical(
id: 'ring1',
nombre: 'Despertar',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
snoozeMinutos: snoozeMinutos,
fadeInSegundos: fadeInSegundos,
emisora: const Emisora(
uuid: 'e1',
nombre: 'Radio Uno',
url: 'https://radio.example/stream',
),
),
);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: radio),
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const SizedBox.shrink(),
),
),
);
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
unawaited(
navigator.push(
MaterialPageRoute<void>(
builder:
(_) => PantallaAlarmaSonando(
alarma: estadoAlarmas.alarmas.single,
audioPrearrancado: true,
),
fullscreenDialog: true,
),
),
);
await tester.pumpAndSettle();
return _Entorno(estadoAlarmas: estadoAlarmas, android: android, audio: audio);
}
void main() {
final l10n = lookupAppLocalizations(const Locale('es'));
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets(
'muestra botones de posponer 3/5/10 mas el personalizado (S2-R1-A/C)',
(tester) async {
await _montarPantalla(tester, snoozeMinutos: 7);
expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget);
expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget);
expect(find.text(l10n.alarmSnoozeOptionLabel(7)), findsOneWidget);
expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget);
expect(find.text(l10n.stopAlarmAction), findsOneWidget);
},
);
testWidgets(
'no duplica el boton cuando snoozeMinutos coincide con una opcion fija',
(tester) async {
await _montarPantalla(tester, snoozeMinutos: 5);
expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget);
expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget);
expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget);
},
);
testWidgets(
'posponer 5 min detiene el audio local, pospone y cierra (S2-R1-B)',
(tester) async {
final entorno = await _montarPantalla(tester, snoozeMinutos: 5);
await tester.tap(find.text(l10n.alarmSnoozeOptionLabel(5)));
await tester.pumpAndSettle();
final alarma = entorno.estadoAlarmas.alarmas.single;
expect(alarma.snoozeHasta, DateTime(2026, 6, 11, 7, 35));
expect(entorno.audio.pausas, greaterThanOrEqualTo(1));
expect(find.byType(PantallaAlarmaSonando), findsNothing);
// posponerAlarma oculta la notificacion nativa (mismo stop path que
// el boton de detener) y reprograma con el snooze.
expect(entorno.android.ocultadas, contains('ring1'));
expect(
entorno.android.programadas.last.snoozeHasta,
DateTime(2026, 6, 11, 7, 35),
);
},
);
group('rampa anclada y override diferido al primer audio', () {
testWidgets('la rampa arranca al montar sin esperar al stream, y el '
'override del volumen del dispositivo espera a "reproduciendo"',
(tester) async {
final entorno = await _montarPantalla(
tester,
audioYaReproduciendo: false,
);
// La rampa esta anclada al inicio de la alarma: con fade 0 salta ya al
// objetivo (1.0) aunque el stream siga bufferizando. Lo que NO debe
// haber ocurrido todavia es el override del stream del dispositivo
// (el spin-up del reproductor debe pasar bajo el volumen original) ni
// el stop nativo del handoff.
expect(entorno.audio.volumenesAplicados, [0.05, 1.0]);
expect(
entorno.android.volumenForzado,
isEmpty,
reason: 'el override debe esperar a que el audio realmente fluya',
);
expect(entorno.android.detenidas, isEmpty);
// La radio llega a "reproduciendo": recien ahi se sube el stream al
// nivel configurado y se confirma el handoff (stop nativo).
entorno.audio.emitirEstado(EstadoReproduccion.reproduciendo);
await tester.pumpAndSettle();
expect(entorno.android.volumenForzado, [0.85]);
expect(entorno.android.detenidas, contains('ring1'));
});
testWidgets('si confirmar el audio con el nativo falla, el fade-in de Dart '
'arranca igual (el nativo esta muerto o nunca corrio)', (tester) async {
final entorno = await _montarPantalla(
tester,
audioYaReproduciendo: false,
);
entorno.android.fallaConfirmarAudioFlutter = true;
entorno.audio.emitirEstado(EstadoReproduccion.reproduciendo);
await tester.pumpAndSettle();
// La confirmacion nativa fallo (el fake lanza antes de registrar en
// `detenidas`), pero el fade-in de Dart debe arrancar de todas
// formas: si el lado nativo esta muerto o nunca corrio, Dart es la
// unica fuente audible, y el ring no debe quedar pegado en
// _volumenInicialFadeIn para siempre.
expect(entorno.android.detenidas, isEmpty);
expect(entorno.audio.volumenesAplicados, [0.05, 1.0]);
});
});
group('handoff con audio prearrancado ya reproduciendo (regresion)', () {
testWidgets('si la radio ya esta reproduciendo al montar, la confirmacion '
'del handoff y el fade-in arrancan igual', (tester) async {
// Real production path: app.dart pre-starts the station BEFORE the
// screen mounts, so `reproduciendo` is emitted before the state
// listener subscribes and no further state event ever arrives. The
// handoff confirmation (native stop) and the Dart ramp must not
// depend on catching that already-missed event.
final entorno = await _montarPantalla(tester);
expect(
entorno.android.detenidas,
contains('ring1'),
reason: 'el nativo debe recibir el stop del handoff aunque '
'"reproduciendo" haya llegado antes del mount',
);
expect(
entorno.audio.volumenesAplicados,
[0.05, 1.0],
reason: 'el fade-in debe arrancar tambien en el camino ya-sonando',
);
});
});
group('salidas del ring fuera de los botones', () {
testWidgets('el boton atras del sistema se comporta como Detener: para la '
'radio, finaliza y cierra', (tester) async {
final entorno = await _montarPantalla(tester);
// Simulate the SYSTEM back (routes through PopScope via the binding),
// not a direct Navigator.pop: a plain pop only runs dispose(), which
// leaves the shared radio player ringing with no alarm UI left to
// stop it.
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byType(PantallaAlarmaSonando), findsNothing);
expect(
entorno.audio.pausas,
greaterThanOrEqualTo(1),
reason: 'atras debe pausar la radio como lo hace Detener',
);
expect(
entorno.android.ocultadas,
contains('ring1'),
reason: 'atras debe finalizar la ejecucion (mismo camino que Detener)',
);
});
testWidgets('salir a mitad de rampa restaura la ganancia del reproductor '
'a 1.0 para la radio normal', (tester) async {
final entorno = await _montarPantalla(tester, fadeInSegundos: 30);
// Let the ramp advance a few steps (250ms per step) so the shared
// handler gain sits at a partial value well below 1.0.
await tester.pump(const Duration(seconds: 2));
expect(entorno.audio.volumenesAplicados.last, lessThan(0.2));
await tester.tap(find.text(l10n.stopAlarmAction));
await tester.pumpAndSettle();
expect(
entorno.audio.volumenesAplicados.last,
1.0,
reason: 'la salida del ring no debe dejar la radio normal al nivel '
'parcial de la rampa',
);
});
});
group('restore de volumen de medios con dispose como unico llamador', () {
testWidgets('desmontar la pantalla sin detener ni posponer restaura el '
'volumen exactamente una vez', (tester) async {
final entorno = await _montarPantalla(tester);
// Teardown that bypasses _detener()/_posponer() entirely: dispose()
// must work as a restore path on its own. Reading the BuildContext
// inside dispose() throws (the element is already defunct), so the
// port reference has to be captured while the widget is mounted.
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
expect(entorno.android.volumenRestaurado, 1);
});
});
}