_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.
248 lines
8.8 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|