Files
pluriwave/test/pantallas/pantalla_alarma_sonando_test.dart
T
FreeTLab 79f6f8ef38
Build & Deploy PluriWave / Análisis de código (push) Successful in 43s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m52s
fix(alarm): capture alarm state before dispose so its volume restore works
_restaurarVolumenMediaUnaVez() read the BuildContext to reach the
alarm port, but dispose() runs after the element is defunct, so the
lookup always threw (caught and logged) and the dispose safety-net
never actually restored the media volume when it was the sole exit
path. The state is now captured once in initState and the restore
helper uses the field. Adds the missing dispose-as-sole-caller
regression test.
2026-07-11 10:16:25 +02:00

248 lines
8.8 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,
// 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,
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('fade-in dedup en el handoff (Slice 3)', () {
testWidgets('el fade-in de Dart se retiene hasta que el nativo confirma el '
'handoff, y arranca justo despues (camino radio)', (tester) async {
final entorno = await _montarPantalla(
tester,
audioYaReproduciendo: false,
);
entorno.android.puertaConfirmarAudioFlutter = Completer<void>();
// Antes de que la radio confirme "reproduciendo", solo debe existir
// el volumen de arranque previo (0.05): el ramp real hacia
// alarma.volumen todavia NO debe haber arrancado.
expect(entorno.android.detenidas, isEmpty);
expect(entorno.audio.volumenesAplicados, [0.05]);
// La radio confirma que esta reproduciendo -> dispara
// _confirmarAudioFlutterListo(), que queda bloqueado en la puerta
// (todavia no hay confirmacion nativa real).
entorno.audio.emitirEstado(EstadoReproduccion.reproduciendo);
await tester.pumpAndSettle();
expect(
entorno.android.detenidas,
isEmpty,
reason: 'confirmarAudioFlutter sigue bloqueado en la puerta de prueba',
);
expect(
entorno.audio.volumenesAplicados,
[0.05],
reason: 'el fade-in de Dart no debe arrancar antes del handoff',
);
// Se libera la puerta: recien ahi "confirma" el nativo, y solo
// entonces debe arrancar el fade-in de Dart (una unica rampa
// audible a la vez).
entorno.android.puertaConfirmarAudioFlutter!.complete();
await tester.pumpAndSettle();
expect(entorno.android.detenidas, contains('ring1'));
expect(entorno.audio.volumenesAplicados, [0.05, 0.85]);
});
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, 0.85]);
});
});
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);
});
});
}