Files
pluriwave/test/pantallas/pantalla_alarmas_editor_test.dart
T
FreeTLab d81fabbe27 refactor(iap): make esPremium a required constructor parameter
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.
2026-08-10 22:06:36 +02:00

592 lines
19 KiB
Dart

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/pantallas/pantalla_alarmas.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:pluriwave/widgets/editor_hora_inline.dart';
import 'package:pluriwave/widgets/pluri_push_scaffold.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});
final EstadoAlarmas estadoAlarmas;
}
class _EntornoDosAlarmas {
_EntornoDosAlarmas({required this.estadoAlarmas, required this.android});
final EstadoAlarmas estadoAlarmas;
final FakePuertoAlarmasAndroid android;
}
/// WU8 fixture: two DAILY alarms ('r1' 07:00, 'r2' 08:00) under a FIXED clock
/// (06:00, before both) so 'r1' is deterministically the featured/soonest
/// alarm regardless of the wall-clock time the suite happens to run at.
Future<_EntornoDosAlarmas> _montarDosAlarmas(WidgetTester tester) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final radio = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
addTearDown(radio.dispose);
final android = FakePuertoAlarmasAndroid();
final estadoAlarmas = EstadoAlarmas(
esPremium: () => true,
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'r1',
nombre: 'Primera',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'r2',
nombre: 'Segunda',
hora: 8,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
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 Scaffold(body: PantallaAlarmas()),
),
),
);
await tester.pumpAndSettle();
return _EntornoDosAlarmas(estadoAlarmas: estadoAlarmas, android: android);
}
Future<_Entorno> _abrirEditor(WidgetTester tester) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final favoritos = FakeServicioFavoritos();
await favoritos.agregar(emisoraDemo(uuid: 'alfa', nombre: 'Alfa FM'));
await favoritos.agregar(emisoraDemo(uuid: 'beta', nombre: 'Beta FM'));
final radio = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
addTearDown(radio.dispose);
await radio.cargarFavoritos();
final android = FakePuertoAlarmasAndroid();
final estadoAlarmas = EstadoAlarmas(
esPremium: () => true,
servicio: ServicioAlarmas(reloj: DateTime.now),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'ed1',
nombre: 'Semanal',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diasSemana,
diasSemana: [DateTime.monday],
),
);
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 Scaffold(body: PantallaAlarmas()),
),
),
);
await tester.pumpAndSettle();
// WU8: the always-visible "Edit" button is gone — tapping the simplified
// card itself now opens the editor.
final tarjeta = find.byKey(const ValueKey('tarjeta-alarma-ed1'));
await tester.ensureVisible(tarjeta);
await tester.pumpAndSettle();
await tester.tap(tarjeta);
await tester.pumpAndSettle();
return _Entorno(estadoAlarmas: estadoAlarmas);
}
String _textoPreview(WidgetTester tester) {
final texto = tester.widget<Text>(
find.descendant(
of: find.byKey(const ValueKey('next-trigger-preview')),
matching: find.byType(Text),
),
);
return texto.data ?? '';
}
void main() {
final l10n = lookupAppLocalizations(const Locale('es'));
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets(
'muestra la proxima ejecucion y la actualiza al cambiar la recurrencia '
'(S2-R8)',
(tester) async {
await _abrirEditor(tester);
expect(
find.byKey(const ValueKey('next-trigger-preview')),
findsOneWidget,
);
final antes = _textoPreview(tester);
expect(antes, isNot(l10n.alarmNoNextExecution));
// Lunes -> Martes: la fecha calculada SIEMPRE cambia, sea cual sea hoy.
//
// Item 5: the alarm CARD underneath now also renders the real day
// abbreviation ("Lun") for a diasSemana alarm, so a bare
// `find.text(...)` for a weekday letter is ambiguous while the
// editor sheet is open on top of the list — scope to the sheet's own
// BottomSheet subtree to target the day-picker circle specifically.
final hojaEditor = find.byType(BottomSheet);
await tester.tap(
find.descendant(
of: hojaEditor,
matching: find.text(l10n.weekdayShortTuesday),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: hojaEditor,
matching: find.text(l10n.weekdayShortMonday),
),
);
await tester.pumpAndSettle();
final despues = _textoPreview(tester);
expect(despues, isNot(l10n.alarmNoNextExecution));
expect(despues, isNot(antes));
},
);
testWidgets(
'el selector de emisora abre un bottom sheet con buscador (S2-R9)',
(tester) async {
await _abrirEditor(tester);
await tester.ensureVisible(
find.byKey(const ValueKey('alarm-station-field')),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('alarm-station-field')));
await tester.pumpAndSettle();
expect(find.byType(SearchBar), findsOneWidget);
final lista = find.byType(ListView).last;
expect(
find.descendant(of: lista, matching: find.text('Alfa FM')),
findsOneWidget,
);
expect(
find.descendant(of: lista, matching: find.text('Beta FM')),
findsOneWidget,
);
await tester.enterText(find.byType(TextField).last, 'beta');
await tester.pumpAndSettle();
expect(
find.descendant(of: lista, matching: find.text('Alfa FM')),
findsNothing,
);
expect(
find.descendant(of: lista, matching: find.text('Beta FM')),
findsOneWidget,
);
},
);
testWidgets(
'tambien existe un selector para la emisora de respaldo (S2-R9)',
(tester) async {
await _abrirEditor(tester);
// WU10 correction: the fallback-station field moved into the
// collapsed "Advanced" section (native-alarms delta — Alarm Editor
// Preserves Date, Fallback Station, and Sound Fields). It must be
// expanded first — a collapsed `ExpansionTile` does not build its
// children, so `find.byKey` would otherwise find nothing.
await tester.ensureVisible(find.text(l10n.alarmAdvancedSectionTitle));
await tester.tap(find.text(l10n.alarmAdvancedSectionTitle));
await tester.pumpAndSettle();
await tester.ensureVisible(
find.byKey(const ValueKey('alarm-fallback-station-field')),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('alarm-fallback-station-field')),
);
await tester.pumpAndSettle();
expect(find.byType(SearchBar), findsOneWidget);
},
);
testWidgets('permite configurar la duracion del snooze (S2-R10)', (
tester,
) async {
final entorno = await _abrirEditor(tester);
await tester.ensureVisible(find.text(l10n.alarmSnoozeDurationTitle));
await tester.pumpAndSettle();
expect(find.byType(SegmentedButton<int>), findsWidgets);
await tester.tap(find.text(l10n.alarmSnoozeOptionLabel(10)));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text(l10n.saveAlarmAction));
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.saveAlarmAction));
await tester.pumpAndSettle();
expect(entorno.estadoAlarmas.alarmas.single.snoozeMinutos, 10);
});
testWidgets('el slider de volumen permite bajar hasta 0.0 (S2-R11)', (
tester,
) async {
await _abrirEditor(tester);
final sliders = tester.widgetList<Slider>(find.byType(Slider));
final volumen = sliders.firstWhere((slider) => slider.max == 1.0);
expect(volumen.min, 0.0);
});
group('visual fidelity (audit 8.1/8.3/8.4, item 20)', () {
testWidgets(
'8.1: the sheet is an opaque, bottom-anchored surface (radius on the '
'top corners only) with a grab handle',
(tester) async {
await _abrirEditor(tester);
final surface = tester.widget<DecoratedBox>(
find.byKey(const ValueKey('alarm-editor-sheet-surface')),
);
final decoracion = surface.decoration as BoxDecoration;
expect(
decoracion.color,
const Color(0xFF0D1B24),
reason: 't4 line 373: background:#0D1B24 (opaque, not glass)',
);
expect(
decoracion.borderRadius,
const BorderRadius.vertical(top: Radius.circular(30)),
reason:
't4 line 373: border-radius:30px 30px 0 0 — top corners '
'only, unlike a floating dialog',
);
final asa = tester.widget<Container>(
find.byKey(const ValueKey('alarm-editor-grab-handle')),
);
expect(asa.constraints?.maxWidth, 44);
expect(asa.constraints?.maxHeight, 4);
final asaDecoracion = asa.decoration as BoxDecoration;
expect(
asaDecoracion.borderRadius,
BorderRadius.circular(2),
reason: 't4 line 374: width:44px;height:4px;border-radius:2px',
);
},
);
testWidgets('8.3: the hour editor sits inside a framed card', (
tester,
) async {
await _abrirEditor(tester);
final tarjeta = tester.widget<DecoratedBox>(
find.ancestor(
of: find.byType(EditorHoraInline),
matching: find.byKey(const ValueKey('alarm-editor-hour-card')),
),
);
final decoracion = tarjeta.decoration as BoxDecoration;
expect(
decoracion.borderRadius,
BorderRadius.circular(22),
reason: 't4 line 378: border-radius:22px',
);
expect(
decoracion.color,
const Color(0xFF102532),
reason: 't4 line 378: background:#102532 (== listSurface token)',
);
});
testWidgets(
'8.4: the weekday selector is exactly 7 circular buttons, not chips',
(tester) async {
await _abrirEditor(tester);
final fila = find.byKey(const ValueKey('alarm-weekday-circles'));
expect(fila, findsOneWidget);
expect(
find.descendant(of: fila, matching: find.byType(FilterChip)),
findsNothing,
reason: 't4 lines 383-390: 7 circles, not FilterChips',
);
final circulos = find.descendant(
of: fila,
matching: find.byWidgetPredicate(
(w) => w is Material && w.shape is CircleBorder,
),
);
expect(circulos, findsNWidgets(7));
},
);
testWidgets(
'capacity guard: the date field, the fallback-station picker and the '
'sound dropdown all stay reachable behind Advanced',
(tester) async {
await _abrirEditor(tester);
await tester.ensureVisible(find.text(l10n.alarmAdvancedSectionTitle));
await tester.tap(find.text(l10n.alarmAdvancedSectionTitle));
await tester.pumpAndSettle();
expect(find.text(l10n.dateField), findsOneWidget);
expect(
find.byKey(const ValueKey('alarm-fallback-station-field')),
findsOneWidget,
);
expect(
find.byType(DropdownButtonFormField<SonidoInternoAlarma>),
findsOneWidget,
);
},
);
testWidgets('8.5: the "REPETIR" eyebrow sits above the weekday circles', (
tester,
) async {
await _abrirEditor(tester);
expect(find.text(l10n.alarmRepeatSectionLabel), findsOneWidget);
final eyebrowY =
tester.getBottomLeft(find.text(l10n.alarmRepeatSectionLabel)).dy;
final circlesY =
tester
.getTopLeft(find.byKey(const ValueKey('alarm-weekday-circles')))
.dy;
expect(eyebrowY <= circlesY, isTrue);
});
testWidgets(
'8.6: the station field, volume, fade-in and vacation toggle share '
'ONE grouped card',
(tester) async {
await _abrirEditor(tester);
final tarjeta = find.byKey(const ValueKey('alarm-editor-grouped-card'));
expect(tarjeta, findsOneWidget);
expect(
find.descendant(
of: tarjeta,
matching: find.byKey(const ValueKey('alarm-station-field')),
),
findsOneWidget,
);
expect(
find.descendant(of: tarjeta, matching: find.byType(Slider)),
findsNWidgets(2),
reason: 'volume + fade-in sliders both live inside the card',
);
expect(
find.descendant(of: tarjeta, matching: find.byType(SwitchListTile)),
findsOneWidget,
reason: 'the vacation toggle lives inside the card',
);
// Snooze duration is a deliberate EXTRA (audit 8.8), kept OUTSIDE
// the prototype-mandated 4-row card.
expect(
find.descendant(
of: tarjeta,
matching: find.text(l10n.alarmSnoozeDurationTitle),
),
findsNothing,
);
},
);
testWidgets('8.7: the volume row shows a live percentage label next to a '
'compact track', (tester) async {
await _abrirEditor(tester);
expect(find.text(l10n.alarmVolumeLabel), findsOneWidget);
expect(find.text('85%'), findsOneWidget);
});
});
group('WU8 — tarjeta de alarma simplificada', () {
testWidgets(
'tocar la tarjeta abre el editor precargado con los datos de esa '
'alarma',
(tester) async {
await _montarDosAlarmas(tester);
await tester.tap(find.byKey(const ValueKey('tarjeta-alarma-r2')));
await tester.pumpAndSettle();
expect(find.text(l10n.editAlarmTitle), findsOneWidget);
expect(find.text('Segunda'), findsOneWidget);
},
);
testWidgets('deslizar la tarjeta pide confirmacion; cancelar la conserva y '
'confirmar la elimina', (tester) async {
final entorno = await _montarDosAlarmas(tester);
// Cancelar: la tarjeta se conserva.
await tester.drag(
find.byKey(const ValueKey('tarjeta-alarma-r2')),
const Offset(-600, 0),
);
await tester.pumpAndSettle();
expect(find.text(l10n.alarmDeleteConfirmTitle), findsOneWidget);
await tester.tap(find.text(l10n.cancelAction));
await tester.pumpAndSettle();
expect(entorno.estadoAlarmas.alarmas.length, 2);
expect(find.byKey(const ValueKey('tarjeta-alarma-r2')), findsOneWidget);
// Confirmar: la tarjeta se elimina.
await tester.drag(
find.byKey(const ValueKey('tarjeta-alarma-r2')),
const Offset(-600, 0),
);
await tester.pumpAndSettle();
expect(find.text(l10n.alarmDeleteConfirmTitle), findsOneWidget);
await tester.tap(find.text(l10n.deleteAction));
await tester.pumpAndSettle();
expect(entorno.estadoAlarmas.alarmas.length, 1);
expect(find.byKey(const ValueKey('tarjeta-alarma-r2')), findsNothing);
});
testWidgets(
'el banner destacado permite saltar la alarma mas proxima (accion '
'"Saltar")',
(tester) async {
final entorno = await _montarDosAlarmas(tester);
final proximaAntes = entorno.estadoAlarmas.proximaAlarma;
expect(proximaAntes?.id, 'r1');
await tester.tap(find.byKey(const ValueKey('hero-skip-next')));
await tester.pumpAndSettle();
expect(find.byType(SnackBar), findsOneWidget);
final r1 = entorno.estadoAlarmas.alarmas.firstWhere(
(a) => a.id == 'r1',
);
expect(r1.proximaEjecucion, isNot(proximaAntes!.proximaEjecucion));
},
);
testWidgets('la fila resumen de vacaciones empuja una pantalla al tocarla '
'(destino provisorio; WU9 lo reemplaza)', (tester) async {
await _montarDosAlarmas(tester);
await tester.tap(find.byKey(const ValueKey('vacaciones-resumen')));
await tester.pumpAndSettle();
expect(find.byType(PluriPushScaffold), findsOneWidget);
});
testWidgets(
'guardia de capacidad: editar (tap), saltar (hero) y eliminar (swipe) '
'siguen siendo alcanzables tras la simplificacion de la tarjeta',
(tester) async {
final entorno = await _montarDosAlarmas(tester);
// 1) Saltar, desde el banner destacado.
await tester.tap(find.byKey(const ValueKey('hero-skip-next')));
await tester.pumpAndSettle();
expect(find.byType(SnackBar), findsOneWidget);
// 2) Editar, tocando la tarjeta.
await tester.tap(find.byKey(const ValueKey('tarjeta-alarma-r2')));
await tester.pumpAndSettle();
expect(find.text(l10n.editAlarmTitle), findsOneWidget);
await tester.tap(find.byIcon(Icons.close_rounded));
await tester.pumpAndSettle();
// 3) Eliminar, deslizando y confirmando.
await tester.drag(
find.byKey(const ValueKey('tarjeta-alarma-r2')),
const Offset(-600, 0),
);
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.deleteAction));
await tester.pumpAndSettle();
expect(entorno.estadoAlarmas.alarmas.length, 1);
},
);
});
}