EstadoAlarmas, EstadoGrabacion and EstadoRadio defaulted `esPremium` to `() => true`, so any construction site that forgot to wire entitlement compiled fine and silently ran ungated — failing OPEN to premium and disabling the paywall with no test able to catch it. The parameter is now required with no default. Production wiring in app.dart was already correct and is unchanged; the 184 pre-existing test call sites now pass `() => true` explicitly, which is exactly the old implicit default, so every assertion is untouched. EstadoRadio has no gate of its own but constructs EstadoGrabacion, so it inherits the same contract. The one test that existed to pin the old default is renamed to describe what it still covers (the premium path through iniciar() with no duracion); its assertions are unchanged.
523 lines
18 KiB
Dart
523 lines
18 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_alarmas_android.dart';
|
|
import 'package:pluriwave/servicios/servicio_audio.dart';
|
|
import 'package:pluriwave/tema/pluriwave_tokens.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(
|
|
esPremium: () => true,
|
|
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(
|
|
esPremium: () => true,
|
|
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,
|
|
// A real Scaffold (not a bare SizedBox) is required: the
|
|
// ScaffoldMessenger only displays a SnackBar through a currently
|
|
// registered ScaffoldState, and the ringing screen's own Scaffold
|
|
// pops off the tree by the time the SS-3a force-stop SnackBar shows
|
|
// (mirrors pantalla_alarma_sonando_dismiss_guard_test.dart).
|
|
home: const Scaffold(body: 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({});
|
|
});
|
|
|
|
group('WU11 — 3 tiles de posponer fijos (3/5/10 min)', () {
|
|
testWidgets(
|
|
'siempre son exactamente 3 tiles fijos, incluso con un snoozeMinutos '
|
|
'personalizado que no es 3/5/10 (S2-R1-A/C, restilizado)',
|
|
(tester) async {
|
|
// WU11 correction: the ringing screen's snooze row is no longer a
|
|
// variable-length Wrap that grows for a custom value — the mockup's
|
|
// "3 fixed tiles" replaces it. A custom snoozeMinutos (7 here, same
|
|
// fixture as before WU11) still configures the ALARM's own default
|
|
// elsewhere (the editor), but no longer grows a 4th tile on this
|
|
// screen specifically.
|
|
await _montarPantalla(tester, snoozeMinutos: 7);
|
|
|
|
expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget);
|
|
expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget);
|
|
expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget);
|
|
expect(find.text(l10n.alarmSnoozeOptionLabel(7)), findsNothing);
|
|
expect(find.text(l10n.stopAlarmAction), findsOneWidget);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'sigue habiendo exactamente 3 tiles 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(
|
|
'el tile que coincide con snoozeMinutos es el destacado (FilledButton); '
|
|
'los otros dos son OutlinedButton',
|
|
(tester) async {
|
|
await _montarPantalla(tester, snoozeMinutos: 5);
|
|
|
|
expect(
|
|
find.ancestor(
|
|
of: find.text(l10n.alarmSnoozeOptionLabel(5)),
|
|
matching: find.byType(FilledButton),
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
find.ancestor(
|
|
of: find.text(l10n.alarmSnoozeOptionLabel(3)),
|
|
matching: find.byType(OutlinedButton),
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
find.ancestor(
|
|
of: find.text(l10n.alarmSnoozeOptionLabel(10)),
|
|
matching: find.byType(OutlinedButton),
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'design decision: cuando snoozeMinutos no es 3/5/10, el destacado '
|
|
'por defecto es 10 (el valor "habitual" del mockup)',
|
|
(tester) async {
|
|
await _montarPantalla(tester, snoozeMinutos: 7);
|
|
|
|
expect(
|
|
find.ancestor(
|
|
of: find.text(l10n.alarmSnoozeOptionLabel(10)),
|
|
matching: find.byType(FilledButton),
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
find.ancestor(
|
|
of: find.text(l10n.alarmSnoozeOptionLabel(3)),
|
|
matching: find.byType(OutlinedButton),
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
find.ancestor(
|
|
of: find.text(l10n.alarmSnoozeOptionLabel(5)),
|
|
matching: find.byType(OutlinedButton),
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('WU11 — pildora de Detener a todo lo ancho', () {
|
|
testWidgets('el boton de Detener ocupa todo el ancho disponible', (
|
|
tester,
|
|
) async {
|
|
await _montarPantalla(tester);
|
|
|
|
// The test viewport is 1440 logical px wide (physicalSize / ratio set
|
|
// in `_montarPantalla`); a normal wrap-content button would be well
|
|
// under 300px. This is a "clearly full-bleed, not auto-sized"
|
|
// assertion rather than a pixel-perfect one — the exact horizontal
|
|
// padding is a cosmetic layout detail, not a contract.
|
|
final tamano = tester.getSize(
|
|
find.byKey(const ValueKey('ringing-stop-button')),
|
|
);
|
|
expect(tamano.width, greaterThan(1000));
|
|
});
|
|
});
|
|
|
|
group('visual fidelity (audit 9.11, proto t4 line 434): Detener es una '
|
|
'superficie neutra translucida, no cian', () {
|
|
testWidgets(
|
|
'fondo blanco 8%, borde blanco 16%, radio 24 — no colorScheme.primary/'
|
|
'radiusLg',
|
|
(tester) async {
|
|
await _montarPantalla(tester);
|
|
|
|
final elemento = find.byKey(const ValueKey('ringing-stop-button'));
|
|
final boton = tester.widget<FilledButton>(elemento);
|
|
final contexto = tester.element(elemento);
|
|
final primary = Theme.of(contexto).colorScheme.primary;
|
|
|
|
final fondo = boton.style?.backgroundColor?.resolve(<WidgetState>{});
|
|
final borde = boton.style?.side?.resolve(<WidgetState>{});
|
|
final forma =
|
|
boton.style?.shape?.resolve(<WidgetState>{})
|
|
as RoundedRectangleBorder?;
|
|
|
|
expect(
|
|
fondo,
|
|
Colors.white.withValues(alpha: 0.08),
|
|
reason:
|
|
'prototype t4 line 434: rgba(255,255,255,.08) — a neutral '
|
|
'translucent surface',
|
|
);
|
|
expect(
|
|
fondo,
|
|
isNot(primary),
|
|
reason:
|
|
'the largest element on a full-screen surface must not stay '
|
|
'the brand cyan colorScheme.primary (PluriWaveTokens.brand)',
|
|
);
|
|
expect(
|
|
borde?.color,
|
|
Colors.white.withValues(alpha: 0.16),
|
|
reason: 'prototype t4 line 434 border: rgba(255,255,255,.16)',
|
|
);
|
|
expect(
|
|
forma?.borderRadius,
|
|
BorderRadius.circular(24),
|
|
reason: 'prototype t4 line 434: radius 24 (was radiusLg — 30)',
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('visual fidelity (audit 9.3, proto t4 line 415): pildora de '
|
|
'programacion', () {
|
|
testWidgets('alarma diaria: pildora "Cada manana" con icono alarm y acento '
|
|
'warmCoral', (tester) async {
|
|
await _montarPantalla(tester); // tipoProgramacion: diaria (fixture)
|
|
|
|
final pildora = find.byKey(const ValueKey('ringing-schedule-pill'));
|
|
expect(pildora, findsOneWidget);
|
|
expect(
|
|
find.descendant(
|
|
of: pildora,
|
|
matching: find.text(l10n.alarmScheduleDaily),
|
|
),
|
|
findsOneWidget,
|
|
reason: 'prototype t4 line 415: "CADA MAÑANA"',
|
|
);
|
|
expect(
|
|
find.descendant(of: pildora, matching: find.byIcon(Icons.alarm)),
|
|
findsOneWidget,
|
|
);
|
|
|
|
final caja = tester.widget<DecoratedBox>(pildora);
|
|
final decoracion = caja.decoration as BoxDecoration;
|
|
const acento = PluriWaveTokens.dark;
|
|
expect(
|
|
decoracion.color,
|
|
acento.warmCoral.withValues(alpha: 0.16),
|
|
reason: 'prototype t4 line 415: rgba(244,184,96,.16)',
|
|
);
|
|
expect(
|
|
decoracion.borderRadius,
|
|
BorderRadius.circular(999),
|
|
reason: 'prototype t4 line 415: border-radius:999px (pill)',
|
|
);
|
|
expect(
|
|
(decoracion.border as Border).top.color,
|
|
acento.warmCoral.withValues(alpha: 0.45),
|
|
reason: 'prototype t4 line 415: border rgba(244,184,96,.45)',
|
|
);
|
|
});
|
|
});
|
|
|
|
group('visual fidelity (audit 9.7, proto t4 line 423): nombre de emisora '
|
|
'20/w800', () {
|
|
testWidgets(
|
|
'el nombre de la emisora es 20px/w800/ls-.3, no cardTitle (14.5/w700)',
|
|
(tester) async {
|
|
await _montarPantalla(tester);
|
|
|
|
final texto = tester.widget<Text>(find.text('Radio Uno'));
|
|
expect(texto.style?.fontSize, 20);
|
|
expect(texto.style?.fontWeight, FontWeight.w800);
|
|
expect(texto.style?.letterSpacing, -0.3);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('WU11 — estado estatico de subida de volumen (resolucion 4, sin '
|
|
'contador en vivo)', () {
|
|
testWidgets(
|
|
'con fadeInSegundos > 0 muestra la etiqueta estatica, sin sufijo '
|
|
'numerico',
|
|
(tester) async {
|
|
await _montarPantalla(tester, fadeInSegundos: 20);
|
|
|
|
final texto = tester.widget<Text>(
|
|
find.descendant(
|
|
of: find.byKey(const ValueKey('estado-subida-volumen')),
|
|
matching: find.byType(Text),
|
|
),
|
|
);
|
|
// Exact-equality (not `contains`) is what proves there is no
|
|
// interpolated/changing suffix at all — `alarmVolumeRisingStatus`
|
|
// carries no ARB placeholder, so this can never silently grow a
|
|
// live counter later without a deliberate key change.
|
|
expect(texto.data, l10n.alarmVolumeRisingStatus);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'con fadeInSegundos == 0 (por defecto) no muestra la etiqueta en '
|
|
'absoluto',
|
|
(tester) async {
|
|
await _montarPantalla(tester);
|
|
|
|
expect(
|
|
find.byKey(const ValueKey('estado-subida-volumen')),
|
|
findsNothing,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
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('detener y el force-stop de fallback', () {
|
|
testWidgets(
|
|
'detener fallido NO cierra la pantalla y muestra el banner de forzar '
|
|
'detencion; forzar detencion con exito si la cierra (SS-3a/SS-3b)',
|
|
(tester) async {
|
|
final entorno = await _montarPantalla(tester);
|
|
entorno.android.fallaDetener = true;
|
|
|
|
await tester.tap(find.text(l10n.stopAlarmAction));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Verified stop failure (Finding A): the alarm is still ringing, so
|
|
// the ringing screen must stay up — dismissing here would hide the
|
|
// only retry affordance while the native ring keeps sounding.
|
|
expect(find.byType(PantallaAlarmaSonando), findsOneWidget);
|
|
expect(find.text(l10n.alarmStopFailedMessage), findsOneWidget);
|
|
expect(find.text(l10n.alarmForceStopAction), findsOneWidget);
|
|
|
|
// Retry via the banner's own action succeeds this time (SS-3b).
|
|
entorno.android.fallaDetener = false;
|
|
await tester.tap(find.text(l10n.alarmForceStopAction));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
|
},
|
|
);
|
|
|
|
testWidgets('detener confirmado no muestra el banner de fallo (SS-3c)', (
|
|
tester,
|
|
) async {
|
|
final entorno = await _montarPantalla(tester);
|
|
|
|
await tester.tap(find.text(l10n.stopAlarmAction));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
|
expect(find.text(l10n.alarmStopFailedMessage), findsNothing);
|
|
expect(entorno.android.detencionesActivas, isNotEmpty);
|
|
});
|
|
|
|
testWidgets(
|
|
'forzar detencion: invocacion superpuesta es no-op y tras un fallo '
|
|
'el boton sigue funcionando (RES-2)',
|
|
(tester) async {
|
|
final entorno = await _montarPantalla(tester);
|
|
entorno.android.fallaDetener = true;
|
|
await tester.tap(find.text(l10n.stopAlarmAction));
|
|
await tester.pumpAndSettle();
|
|
final llamadasPrevias = entorno.android.detencionesActivas.length;
|
|
|
|
entorno.android.detenerActivoGate = Completer<void>();
|
|
await tester.tap(find.text(l10n.alarmForceStopAction));
|
|
await tester.pump();
|
|
await tester.tap(find.text(l10n.alarmForceStopAction));
|
|
await tester.pump();
|
|
expect(
|
|
entorno.android.detencionesActivas.length,
|
|
llamadasPrevias + 1,
|
|
reason: 'la segunda invocacion superpuesta debe ser no-op',
|
|
);
|
|
entorno.android.detenerActivoGate!.complete();
|
|
await tester.pumpAndSettle();
|
|
|
|
// Fallo confirmado: el banner sigue y el guard debe haberse
|
|
// reseteado para permitir un reintento.
|
|
expect(find.byType(PantallaAlarmaSonando), findsOneWidget);
|
|
entorno.android.fallaDetener = false;
|
|
await tester.tap(find.text(l10n.alarmForceStopAction));
|
|
await tester.pumpAndSettle();
|
|
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('reconciliacion de fin de ring externo (RES-1)', () {
|
|
testWidgets(
|
|
'si la alarma se registra como perdida externamente, la pantalla se '
|
|
'auto-cierra',
|
|
(tester) async {
|
|
final entorno = await _montarPantalla(tester);
|
|
|
|
entorno.android.emitirEvento(
|
|
EventoAlarmaAndroid(
|
|
alarmaId: 'ring1',
|
|
titulo: 'Despertar',
|
|
accion: EventoAlarmaAndroid.accionMissed,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(PantallaAlarmaSonando), findsNothing);
|
|
},
|
|
);
|
|
});
|
|
|
|
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)',
|
|
);
|
|
},
|
|
);
|
|
});
|
|
}
|