feat(vacaciones): add vacation range manager screen
Add the Vacaciones manager screen per design ADR-6: an active-range
hero (name, days-remaining countdown, determinate progress bar, and a
per-alarm pause-impact line), a "PROGRAMADOS" upcoming-ranges list, an
"Add range" CTA, and a "Rangos pasados" history section. This is the
real destination WU8's Alarmas-root summary row pushes to, replacing
WU8's own temporary placeholder (_PantallaVacacionesTemporal, now
deleted).
EstadoAlarmas gains 4 pure query methods (rangoVacacionesActivo,
vacacionesProximas, vacacionesPasadas, impactoDeRango) -- read-only
over _alarmas/_vacaciones, no writes, no rescheduling, no native
bridge calls. impactoDeRango mirrors ServicioProgramacionAlarmas's own
pause predicate exactly, so the screen never disagrees with the
scheduler about which alarms are paused. ImpactoVacaciones joins
RangoVacaciones in alarma_musical.dart.
The add-range form (_EditorVacacionesSheet, _PickerButton) moved
verbatim from pantalla_alarmas.dart to its one remaining consumer.
estado_alarmas.dart's scheduling/snooze paths and the ringing screen's
dismiss guard are untouched; both test files pass unmodified.
New ARB keys (en/es only; other 11 locales are WU18's job):
vacationImpact{Paused,Continues}Label, vacationUpcomingSectionTitle,
vacationPastSectionTitle, addVacationRangeCta,
vacationNoActiveRangeHint.
This commit is contained in:
@@ -280,67 +280,61 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'eliminarAlarma detiene el audio antes de cancelar cuando esta sonando '
|
||||
'(SS-1c, guardia de regresion)',
|
||||
() async {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'ring3',
|
||||
nombre: 'Sonando',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
android.alarmaSonandoIdValor = 'ring3';
|
||||
test('eliminarAlarma detiene el audio antes de cancelar cuando esta sonando '
|
||||
'(SS-1c, guardia de regresion)', () async {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'ring3',
|
||||
nombre: 'Sonando',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
android.alarmaSonandoIdValor = 'ring3';
|
||||
|
||||
await estado.eliminarAlarma('ring3');
|
||||
await estado.eliminarAlarma('ring3');
|
||||
|
||||
expect(android.detencionesActivas, contains('ring3'));
|
||||
expect(android.canceladas, contains('ring3'));
|
||||
},
|
||||
);
|
||||
expect(android.detencionesActivas, contains('ring3'));
|
||||
expect(android.canceladas, contains('ring3'));
|
||||
});
|
||||
|
||||
test(
|
||||
'eliminarAlarma usa detenerSonidoNativo cuando la consulta de sonando '
|
||||
'falla (fail-toward-silence, regresion de eliminarAlarma)',
|
||||
() async {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'ring4',
|
||||
nombre: 'Sonando',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
android.fallaConsultaSonando = true;
|
||||
test('eliminarAlarma usa detenerSonidoNativo cuando la consulta de sonando '
|
||||
'falla (fail-toward-silence, regresion de eliminarAlarma)', () async {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'ring4',
|
||||
nombre: 'Sonando',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
android.fallaConsultaSonando = true;
|
||||
|
||||
await estado.eliminarAlarma('ring4');
|
||||
await estado.eliminarAlarma('ring4');
|
||||
|
||||
expect(android.detenidas, contains('ring4'));
|
||||
expect(android.canceladas, contains('ring4'));
|
||||
},
|
||||
);
|
||||
expect(android.detenidas, contains('ring4'));
|
||||
expect(android.canceladas, contains('ring4'));
|
||||
});
|
||||
|
||||
test(
|
||||
'guardarAlarma (deshabilitar) usa detenerSonidoNativo cuando la consulta '
|
||||
@@ -412,98 +406,89 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'finalizarEjecucion no registra error cuando el stop nativo se confirma '
|
||||
'(SS-2a)',
|
||||
() async {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'fin1',
|
||||
nombre: 'Sonando',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
android.alarmaSonandoIdValor = 'fin1';
|
||||
test('finalizarEjecucion no registra error cuando el stop nativo se confirma '
|
||||
'(SS-2a)', () async {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'fin1',
|
||||
nombre: 'Sonando',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
android.alarmaSonandoIdValor = 'fin1';
|
||||
|
||||
await estado.finalizarEjecucion('fin1');
|
||||
await estado.finalizarEjecucion('fin1');
|
||||
|
||||
expect(android.detencionesActivas, contains('fin1'));
|
||||
expect(estado.error, isNull);
|
||||
},
|
||||
);
|
||||
expect(android.detencionesActivas, contains('fin1'));
|
||||
expect(estado.error, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'finalizarEjecucion registra error cuando el stop nativo no se confirma '
|
||||
'(SS-2b)',
|
||||
() async {
|
||||
final android = FakePuertoAlarmasAndroid()..fallaDetener = true;
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'fin2',
|
||||
nombre: 'Sonando',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
test('finalizarEjecucion registra error cuando el stop nativo no se confirma '
|
||||
'(SS-2b)', () async {
|
||||
final android = FakePuertoAlarmasAndroid()..fallaDetener = true;
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'fin2',
|
||||
nombre: 'Sonando',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
|
||||
await estado.finalizarEjecucion('fin2');
|
||||
await estado.finalizarEjecucion('fin2');
|
||||
|
||||
expect(estado.error, isNotNull);
|
||||
},
|
||||
);
|
||||
expect(estado.error, isNotNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'forzarDetencion reintenta el stop nativo y limpia el error si tiene '
|
||||
'exito (SS-3b)',
|
||||
() async {
|
||||
final android = FakePuertoAlarmasAndroid()..fallaDetener = true;
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'force1',
|
||||
nombre: 'Forzada',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
await estado.finalizarEjecucion('force1');
|
||||
expect(estado.error, isNotNull);
|
||||
test('forzarDetencion reintenta el stop nativo y limpia el error si tiene '
|
||||
'exito (SS-3b)', () async {
|
||||
final android = FakePuertoAlarmasAndroid()..fallaDetener = true;
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'force1',
|
||||
nombre: 'Forzada',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
await estado.finalizarEjecucion('force1');
|
||||
expect(estado.error, isNotNull);
|
||||
|
||||
android.fallaDetener = false;
|
||||
await estado.forzarDetencion('force1');
|
||||
android.fallaDetener = false;
|
||||
await estado.forzarDetencion('force1');
|
||||
|
||||
expect(estado.error, isNull);
|
||||
expect(android.detencionesActivas.length, 2);
|
||||
},
|
||||
);
|
||||
expect(estado.error, isNull);
|
||||
expect(android.detencionesActivas.length, 2);
|
||||
});
|
||||
|
||||
test(
|
||||
'forzarDetencion mantiene el error si el reintento tambien falla (SS-3b)',
|
||||
@@ -534,49 +519,46 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'evento nativo missed completa la ejecucion (Phase 6)',
|
||||
() async {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(
|
||||
AlarmaMusical(
|
||||
id: 'miss1',
|
||||
nombre: 'Perdida',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
proximaEjecucion: DateTime(2026, 5, 25, 7, 30),
|
||||
),
|
||||
);
|
||||
test('evento nativo missed completa la ejecucion (Phase 6)', () async {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(
|
||||
AlarmaMusical(
|
||||
id: 'miss1',
|
||||
nombre: 'Perdida',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
proximaEjecucion: DateTime(2026, 5, 25, 7, 30),
|
||||
),
|
||||
);
|
||||
|
||||
final notificado = Completer<void>();
|
||||
estado.addListener(() {
|
||||
if (!notificado.isCompleted) notificado.complete();
|
||||
});
|
||||
android.emitirEvento(
|
||||
EventoAlarmaAndroid(
|
||||
alarmaId: 'miss1',
|
||||
titulo: 'Perdida',
|
||||
accion: EventoAlarmaAndroid.accionMissed,
|
||||
occurrenceAtMillis: DateTime(2026, 5, 25, 7, 30).millisecondsSinceEpoch,
|
||||
),
|
||||
);
|
||||
await notificado.future;
|
||||
final notificado = Completer<void>();
|
||||
estado.addListener(() {
|
||||
if (!notificado.isCompleted) notificado.complete();
|
||||
});
|
||||
android.emitirEvento(
|
||||
EventoAlarmaAndroid(
|
||||
alarmaId: 'miss1',
|
||||
titulo: 'Perdida',
|
||||
accion: EventoAlarmaAndroid.accionMissed,
|
||||
occurrenceAtMillis: DateTime(2026, 5, 25, 7, 30).millisecondsSinceEpoch,
|
||||
),
|
||||
);
|
||||
await notificado.future;
|
||||
|
||||
expect(
|
||||
estado.alarmas.single.proximaEjecucion,
|
||||
DateTime(2026, 5, 26, 7, 30),
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(
|
||||
estado.alarmas.single.proximaEjecucion,
|
||||
DateTime(2026, 5, 26, 7, 30),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'inicializar sincroniza ejecucion nativa y evita reprogramar al instante',
|
||||
@@ -628,4 +610,218 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
group('EstadoAlarmas — consultas de vacaciones (ADR-6, WU9)', () {
|
||||
test('rangoVacacionesActivo devuelve el rango cuyo intervalo incluye '
|
||||
'"ahora" (dias restantes derivables de finDia), o null si ninguno '
|
||||
'calza (fixed ahora)', () async {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 7, 10)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarVacaciones([
|
||||
RangoVacaciones(
|
||||
id: 'v1',
|
||||
nombre: 'Julio',
|
||||
inicio: DateTime(2026, 7, 5),
|
||||
fin: DateTime(2026, 7, 15),
|
||||
),
|
||||
]);
|
||||
|
||||
final activo = estado.rangoVacacionesActivo(ahora: DateTime(2026, 7, 10));
|
||||
expect(activo?.id, 'v1');
|
||||
expect(activo!.finDia.difference(DateTime(2026, 7, 10)).inDays, 5);
|
||||
|
||||
expect(estado.rangoVacacionesActivo(ahora: DateTime(2026, 8, 1)), isNull);
|
||||
});
|
||||
|
||||
test('vacacionesProximas devuelve los rangos futuros ordenados por inicio '
|
||||
'ascendente; vacacionesPasadas los finalizados por fin descendente; '
|
||||
'ambas son disjuntas del rango activo', () async {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 7, 10)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarVacaciones([
|
||||
RangoVacaciones(
|
||||
id: 'activo',
|
||||
nombre: 'Activo',
|
||||
inicio: DateTime(2026, 7, 5),
|
||||
fin: DateTime(2026, 7, 15),
|
||||
),
|
||||
RangoVacaciones(
|
||||
id: 'lejos',
|
||||
nombre: 'Lejos',
|
||||
inicio: DateTime(2026, 12, 1),
|
||||
fin: DateTime(2026, 12, 10),
|
||||
),
|
||||
RangoVacaciones(
|
||||
id: 'cerca',
|
||||
nombre: 'Cerca',
|
||||
inicio: DateTime(2026, 8, 1),
|
||||
fin: DateTime(2026, 8, 5),
|
||||
),
|
||||
RangoVacaciones(
|
||||
id: 'viejo',
|
||||
nombre: 'Viejo',
|
||||
inicio: DateTime(2026, 1, 1),
|
||||
fin: DateTime(2026, 1, 10),
|
||||
),
|
||||
RangoVacaciones(
|
||||
id: 'reciente',
|
||||
nombre: 'Reciente',
|
||||
inicio: DateTime(2026, 6, 1),
|
||||
fin: DateTime(2026, 6, 5),
|
||||
),
|
||||
]);
|
||||
final ahora = DateTime(2026, 7, 10);
|
||||
|
||||
final proximas = estado.vacacionesProximas(ahora: ahora);
|
||||
expect(proximas.map((r) => r.id).toList(), ['cerca', 'lejos']);
|
||||
|
||||
final pasadas = estado.vacacionesPasadas(ahora: ahora);
|
||||
expect(pasadas.map((r) => r.id).toList(), ['reciente', 'viejo']);
|
||||
|
||||
expect(proximas.any((r) => r.id == 'activo'), isFalse);
|
||||
expect(pasadas.any((r) => r.id == 'activo'), isFalse);
|
||||
});
|
||||
|
||||
test('impactoDeRango separa alarmas activas pausadas de las no afectadas '
|
||||
'segun sonarEnVacaciones, igual que el predicado del planificador '
|
||||
'(servicio_programacion_alarmas.dart)', () async {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 7, 10)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'p1',
|
||||
nombre: 'Pausada 1',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
sonarEnVacaciones: false,
|
||||
),
|
||||
);
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'p2',
|
||||
nombre: 'Pausada 2',
|
||||
hora: 13,
|
||||
minuto: 45,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
sonarEnVacaciones: false,
|
||||
),
|
||||
);
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'n1',
|
||||
nombre: 'Sigue',
|
||||
hora: 8,
|
||||
minuto: 15,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
final rango = RangoVacaciones(
|
||||
id: 'v1',
|
||||
nombre: 'Julio',
|
||||
inicio: DateTime(2026, 7, 5),
|
||||
fin: DateTime(2026, 7, 15),
|
||||
);
|
||||
await estado.guardarVacaciones([rango]);
|
||||
|
||||
final impacto = estado.impactoDeRango(rango);
|
||||
|
||||
expect(impacto.pausadas.map((a) => a.id).toSet(), {'p1', 'p2'});
|
||||
expect(impacto.noAfectadas.map((a) => a.id).toSet(), {'n1'});
|
||||
});
|
||||
|
||||
test('impactoDeRango no cuenta alarmas inactivas ni como pausadas ni como '
|
||||
'no afectadas', () async {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 7, 10)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'inactiva',
|
||||
nombre: 'Apagada',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
sonarEnVacaciones: false,
|
||||
activa: false,
|
||||
),
|
||||
);
|
||||
final rango = RangoVacaciones(
|
||||
id: 'v1',
|
||||
nombre: 'Julio',
|
||||
inicio: DateTime(2026, 7, 5),
|
||||
fin: DateTime(2026, 7, 15),
|
||||
);
|
||||
await estado.guardarVacaciones([rango]);
|
||||
|
||||
final impacto = estado.impactoDeRango(rango);
|
||||
|
||||
expect(impacto.pausadas, isEmpty);
|
||||
expect(impacto.noAfectadas, isEmpty);
|
||||
});
|
||||
|
||||
test('ninguno de los 4 metodos de consulta reprograma Android (guardia de '
|
||||
'pureza — son solo lectura sobre _alarmas/_vacaciones)', () async {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 7, 10)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
await estado.guardarAlarma(
|
||||
const AlarmaMusical(
|
||||
id: 'q1',
|
||||
nombre: 'Consulta',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
final rango = RangoVacaciones(
|
||||
id: 'v1',
|
||||
nombre: 'Julio',
|
||||
inicio: DateTime(2026, 7, 5),
|
||||
fin: DateTime(2026, 7, 15),
|
||||
);
|
||||
await estado.guardarVacaciones([rango]);
|
||||
final programadasAntes = android.programadas.length;
|
||||
|
||||
estado.rangoVacacionesActivo();
|
||||
estado.vacacionesProximas();
|
||||
estado.vacacionesPasadas();
|
||||
estado.impactoDeRango(rango);
|
||||
|
||||
expect(android.programadas.length, programadasAntes);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_vacaciones.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// WU9, `alarm-vacation-ranges` spec — the real Vacaciones manager screen
|
||||
/// that WU8's summary row pushes to (replacing WU8's own temporary
|
||||
/// placeholder). Covers: active-range hero (name + days-remaining countdown
|
||||
/// + per-alarm impact line), the "PROGRAMADOS" upcoming list, the "Añadir
|
||||
/// rango" CTA, and the "Rangos pasados" history section.
|
||||
///
|
||||
/// Clock note: `PantallaVacaciones` calls `EstadoAlarmas.rangoVacacionesActivo()`
|
||||
/// etc. with NO `ahora` argument (production code always uses real
|
||||
/// `DateTime.now()` — design ADR-6 explicitly rejects a testable Clock
|
||||
/// abstraction as out of scope for this redesign). So fixtures here are
|
||||
/// built RELATIVE to `DateTime.now()`, never as hardcoded calendar dates —
|
||||
/// the `ServicioAlarmas`'s own `reloj` (used only for alarm SCHEDULING
|
||||
/// maths, irrelevant to the vacation-query assertions below) is left at its
|
||||
/// real-clock default too, for consistency.
|
||||
DateTime get _hoy => DateTime.now();
|
||||
DateTime get _hoyDia => DateTime(_hoy.year, _hoy.month, _hoy.day);
|
||||
|
||||
Future<EstadoAlarmas> _crearEstado({
|
||||
List<AlarmaMusical> alarmas = const [],
|
||||
List<RangoVacaciones> vacaciones = const [],
|
||||
}) async {
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
for (final alarma in alarmas) {
|
||||
await estado.guardarAlarma(alarma);
|
||||
}
|
||||
if (vacaciones.isNotEmpty) {
|
||||
await estado.guardarVacaciones(vacaciones);
|
||||
}
|
||||
return estado;
|
||||
}
|
||||
|
||||
Widget _buildScreen(EstadoAlarmas estado) {
|
||||
return ChangeNotifierProvider<EstadoAlarmas>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const PantallaVacaciones(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pumpEstable(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'renders inside a PluriPushScaffold; con un rango activo muestra su '
|
||||
'nombre, los dias restantes, y la linea de impacto por alarma',
|
||||
(tester) async {
|
||||
final estado = await _crearEstado(
|
||||
alarmas: [
|
||||
const AlarmaMusical(
|
||||
id: 'p1',
|
||||
nombre: 'Pausada 1',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
sonarEnVacaciones: false,
|
||||
),
|
||||
const AlarmaMusical(
|
||||
id: 'p2',
|
||||
nombre: 'Pausada 2',
|
||||
hora: 13,
|
||||
minuto: 45,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
sonarEnVacaciones: false,
|
||||
),
|
||||
const AlarmaMusical(
|
||||
id: 'n1',
|
||||
nombre: 'Sigue',
|
||||
hora: 8,
|
||||
minuto: 15,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
],
|
||||
vacaciones: [
|
||||
RangoVacaciones(
|
||||
id: 'v1',
|
||||
nombre: 'Julio activo',
|
||||
inicio: _hoyDia.subtract(const Duration(days: 3)),
|
||||
fin: _hoyDia.add(const Duration(days: 5)),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaVacaciones)),
|
||||
);
|
||||
expect(find.text('Julio activo'), findsOneWidget);
|
||||
expect(find.text(l10n.vacationSummaryActiveCountdown(5)), findsOneWidget);
|
||||
expect(
|
||||
find.text(l10n.vacationImpactPausedLabel('07:30, 13:45')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.text(l10n.vacationImpactContinuesLabel('08:15')),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('sin rango activo, muestra la pista en su lugar', (tester) async {
|
||||
final estado = await _crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaVacaciones)),
|
||||
);
|
||||
expect(find.text(l10n.vacationNoActiveRangeHint), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('los rangos futuros aparecen bajo el encabezado PROGRAMADOS', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = await _crearEstado(
|
||||
vacaciones: [
|
||||
RangoVacaciones(
|
||||
id: 'f1',
|
||||
nombre: 'Rango lejano',
|
||||
inicio: _hoyDia.add(const Duration(days: 120)),
|
||||
fin: _hoyDia.add(const Duration(days: 125)),
|
||||
),
|
||||
RangoVacaciones(
|
||||
id: 'f2',
|
||||
nombre: 'Rango cercano',
|
||||
inicio: _hoyDia.add(const Duration(days: 20)),
|
||||
fin: _hoyDia.add(const Duration(days: 25)),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaVacaciones)),
|
||||
);
|
||||
expect(find.text(l10n.vacationUpcomingSectionTitle), findsOneWidget);
|
||||
expect(find.text('Rango lejano'), findsOneWidget);
|
||||
expect(find.text('Rango cercano'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'los rangos pasados aparecen bajo el encabezado "Rangos pasados"',
|
||||
(tester) async {
|
||||
final estado = await _crearEstado(
|
||||
vacaciones: [
|
||||
RangoVacaciones(
|
||||
id: 'pa1',
|
||||
nombre: 'Rango viejo',
|
||||
inicio: _hoyDia.subtract(const Duration(days: 30)),
|
||||
fin: _hoyDia.subtract(const Duration(days: 20)),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaVacaciones)),
|
||||
);
|
||||
expect(find.text(l10n.vacationPastSectionTitle), findsOneWidget);
|
||||
expect(find.text('Rango viejo'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('el CTA "Añadir rango" abre el formulario de alta existente', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = await _crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaVacaciones)),
|
||||
);
|
||||
await tester.tap(find.text(l10n.addVacationRangeCta));
|
||||
await _pumpEstable(tester);
|
||||
|
||||
expect(find.text(l10n.newVacationRangeTitle), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user