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.value(value: radio), ChangeNotifierProvider.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.value(value: radio), ChangeNotifierProvider.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( 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), 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(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( 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( 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( 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), 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); }, ); }); }