posponerAlarma anchored the snooze to snoozeOrigen ?? proximaEjecucion, but once the native fire path works, the fire-time sync records the handled occurrence and recalculation advances proximaEjecucion to the NEXT day before the user can even tap snooze on the still-ringing screen. "Posponer 3" therefore armed the snooze a full day out (captured on-device: snoozeCountdown remaining=1443 minutes). The bug was invisible before because the broken delivery path never advanced proximaEjecucion while ringing — each fix unmasked the next. The anchor is now the newest occurrence that is not meaningfully in the future (shared 90s imminence window): snoozeOrigen for re-snoozes, proximaEjecucion on the watchdog path where it is still today's just-due occurrence, ultimaEjecucionGestionada on the native-fire path where the sync recorded the ringing occurrence, then now. ServicioAlarmas exposes ahora() so the anchor uses the same injectable clock as the rest of the scheduling math. Test fixtures that snoozed half an hour before the ring — a state the ringing screen can never be in, since it is posponerAlarma's only production caller — now move the clock to ring time, preserving their original expectations.
192 lines
6.3 KiB
Dart
192 lines
6.3 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,
|
|
}) async {
|
|
tester.view.physicalSize = const Size(1440, 3200);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(tester.view.resetPhysicalSize);
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
|
|
final audio = FakeServicioAudio();
|
|
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();
|
|
// Mutable clock: the alarm is saved at 7:00 and the screen mounts at ring
|
|
// time (7:30:10), so snooze anchors to the RINGING occurrence — the only
|
|
// scenario the ringing screen can exist in.
|
|
var 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',
|
|
),
|
|
),
|
|
);
|
|
// The screen mounts at ring time: 10s after the 7:30 occurrence fired.
|
|
ahora = DateTime(2026, 6, 11, 7, 30, 10);
|
|
|
|
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),
|
|
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 pospone la alarma y cierra la pantalla (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(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('salidas del ring fuera de los botones', () {
|
|
testWidgets(
|
|
'el boton atras del sistema se comporta como Detener: finaliza la '
|
|
'ejecucion 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 would leave no alarm UI anywhere to stop the
|
|
// ring (native audio teardown is driven by finalizarEjecucion, the
|
|
// same path Detener uses).
|
|
await tester.binding.handlePopRoute();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
|
expect(
|
|
entorno.android.ocultadas,
|
|
contains('ring1'),
|
|
reason:
|
|
'atras debe finalizar la ejecucion (mismo camino que Detener)',
|
|
);
|
|
},
|
|
);
|
|
});
|
|
}
|