From 36d7d5f6922a41947fc44ce5bddfbbe35e1c96f7 Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 30 Jul 2026 16:45:29 +0200 Subject: [PATCH] fix(alarma-sonando): date line, additive snooze qualifier Audit 9.4 (t4:419): "Lunes, 3 de agosto" now renders between the schedule pill and the hero time -- purely additive, a new sibling Text touching neither element. New formato_fechas.dart helper fechaLargaConDiaSemana (locale-aware via DateFormat.MMMMEEEEd). Audit 9.10 (t4:433): the highlighted snooze tile gains a "usual" qualifier (new ARB key alarmSnoozeUsualLabel) alongside the original flat label -- resolved DIFFERENTLY than its sibling 9.9 (permanently rejected, id 2525): instead of splitting the flat alarmSnoozeOptionLabel string into two differently-sized Text nodes (which would make it vanish from the render tree the protected dismiss-guard test locates via find.text), a SEPARATE small Text is added alongside it. The original label stays a single, untouched Text node, still inside the same FilledButton the guard taps. Verified against the full ringing-screen test surface (34 tests across 5 files, including the protected dismiss-guard file) -- all green, dismiss-guard file reconfirmed byte-identical to main. --- lib/pantallas/pantalla_alarma_sonando.dart | 49 +++++- .../pantalla_alarma_sonando_tier5_test.dart | 162 ++++++++++++++++++ 2 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 test/pantallas/pantalla_alarma_sonando_tier5_test.dart diff --git a/lib/pantallas/pantalla_alarma_sonando.dart b/lib/pantallas/pantalla_alarma_sonando.dart index ff3bb48..cf62ff1 100644 --- a/lib/pantallas/pantalla_alarma_sonando.dart +++ b/lib/pantallas/pantalla_alarma_sonando.dart @@ -225,6 +225,25 @@ class _PantallaAlarmaSonandoState extends State { tokens: tokens, ), const SizedBox(height: 22), + // Audit 9.4 (t4 line 419): "Lunes, 3 de agosto" between + // the pill and the hero time -- never rendered before. + // Purely additive: a new sibling Text, touching neither + // the pill above nor the hero time below. + Text( + fechaLargaConDiaSemana( + Localizations.localeOf(context).toString(), + DateTime.now(), + ), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + const SizedBox(height: 6), FittedBox( fit: BoxFit.scaleDown, child: Text( @@ -689,6 +708,20 @@ class _FilaSnoozeFija extends StatelessWidget { final forma = RoundedRectangleBorder( borderRadius: BorderRadius.circular(tokens.radiusMd), ); + // Audit 9.10 (t4 line 433): the prototype stacks a big NUMBER over a + // small "min · habitual" unit — the SAME text-splitting conflict as + // 9.9 (permanently rejected, Engram id 2525): this flat string is + // exactly what the protected dismiss-guard test locates via + // `find.text(l10n.alarmSnoozeOptionLabel(N))` in four places, and + // splitting it into two differently-styled Text nodes would make + // that flat value vanish from the render tree. + // + // Resolved differently here than 9.9: rather than splitting THIS + // string, an entirely SEPARATE small qualifier Text is added + // alongside it (only on the destacado tile) — the original flat + // label stays a single, untouched, unstyled-differently Text node, + // still the exact widget the guard finds and taps. This delivers + // the "habitual" qualifier without the conflict 9.9 hit. final etiqueta = Text(l10n.alarmSnoozeOptionLabel(minutos)); return Expanded( flex: esDestacado ? 3 : 2, @@ -703,7 +736,21 @@ class _FilaSnoozeFija extends StatelessWidget { foregroundColor: tokens.deepViolet, shape: forma, ), - child: etiqueta, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + etiqueta, + Text( + l10n.alarmSnoozeUsualLabel, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w800, + color: tokens.deepViolet.withValues(alpha: 0.75), + ), + ), + ], + ), ) : OutlinedButton( onPressed: () => onPosponer(minutos), diff --git a/test/pantallas/pantalla_alarma_sonando_tier5_test.dart b/test/pantallas/pantalla_alarma_sonando_tier5_test.dart new file mode 100644 index 0000000..b241773 --- /dev/null +++ b/test/pantallas/pantalla_alarma_sonando_tier5_test.dart @@ -0,0 +1,162 @@ +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/formato_fechas.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:pluriwave/tema/pluriwave_theme.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +/// Closing batch (items 9.4, 9.10, audit id 2521) -- the ringing screen. +/// Mirrors `pantalla_alarma_sonando_tier4_test.dart`'s own harness (kept as +/// a local copy, not a shared import, matching this file's established +/// precedent of small helper duplication over cross-test-file coupling). +Future _montarPantalla(WidgetTester tester) 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( + audio: audio, + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + iniciarAutomaticamente: false, + ); + addTearDown(radio.dispose); + + final android = FakePuertoAlarmasAndroid(); + final estadoAlarmas = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 7, 0)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estadoAlarmas.dispose); + addTearDown(android.dispose); + await estadoAlarmas.guardarAlarma( + const AlarmaMusical( + id: 'tier5-1', + nombre: 'Despertar', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + snoozeMinutos: 10, + emisora: Emisora( + uuid: 'e1', + nombre: 'Radio Uno', + url: 'https://radio.example/stream', + ), + ), + ); + + 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, + theme: PluriWaveTheme.dark(), + home: const SizedBox.shrink(), + ), + ), + ); + final navigator = tester.state(find.byType(Navigator)); + unawaited( + navigator.push( + MaterialPageRoute( + builder: + (_) => PantallaAlarmaSonando(alarma: estadoAlarmas.alarmas.single), + fullscreenDialog: true, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('visual fidelity (audit 9.4): the date line renders between the ' + 'schedule pill and the hero time (t4:419)', (tester) async { + await _montarPantalla(tester); + + final localeTag = + Localizations.localeOf( + tester.element(find.byType(PantallaAlarmaSonando)), + ).toString(); + final esperado = fechaLargaConDiaSemana(localeTag, DateTime.now()); + + expect(find.text(esperado), findsOneWidget); + + // Order: pill above the date line, date line above the hero time. + final pillY = + tester + .getBottomLeft(find.byKey(const ValueKey('ringing-schedule-pill'))) + .dy; + final dateY = tester.getTopLeft(find.text(esperado)).dy; + final timeY = + tester.getTopLeft(find.byKey(const ValueKey('ringing-hero-time'))).dy; + expect(pillY <= dateY, isTrue); + expect(dateY <= timeY, isTrue); + + // Regression guard: pumpAndSettle must still complete (purely + // additive static text, no new animation). + await tester.pumpAndSettle(); + }); + + testWidgets( + 'visual fidelity (audit 9.10): the highlighted snooze tile carries a ' + '"usual" qualifier ADDITIVELY -- the original flat label the dismiss ' + 'guard finds by text stays fully intact', + (tester) async { + await _montarPantalla(tester); + + final l10n = AppLocalizations.of( + tester.element(find.byType(PantallaAlarmaSonando)), + ); + + // The ORIGINAL flat label must still resolve to exactly one Text -- + // this IS the dismiss-guard's own finder contract. + expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget); + // The additive qualifier renders too, but only on the destacado tile. + expect(find.text(l10n.alarmSnoozeUsualLabel), findsOneWidget); + expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget); + expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget); + + // Regression guard: the exact hazard 9.9/9.10 share -- pumpAndSettle + // must still complete, and the flat label must remain the SAME + // widget the dismiss guard taps (still inside a FilledButton). + expect( + find.ancestor( + of: find.text(l10n.alarmSnoozeOptionLabel(10)), + matching: find.byType(FilledButton), + ), + findsOneWidget, + ); + await tester.pumpAndSettle(); + }, + ); +}