3 Commits
Author SHA1 Message Date
FreeTLab 1e7c0daa90 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 30s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
2026-08-05 23:07:20 +02:00
FreeTLab bca0a9bbb7 merge: dart-side snooze anchor guard, Auto progress bar, drop EQ folder 2026-08-05 23:07:19 +02:00
FreeTLab 80538900db fix(alarmas,auto): guard the last unguarded snooze path, surface car progress
Continuation of 7054a4c: the native anchor guard alone did not fix the
reported ~1444-minute snooze, because Dart runs AFTERWARDS on the
pre-notice path and had no guard at all.

1. Snooze from the pre-notice notification, root cause.

app.dart dispatches AFTER the receiver's postponeNext already ran and
after startActivity, and EstadoAlarmas.posponerProximaDesdePreaviso took
whatever occurrence it was handed on faith, then persisted and
rescheduled from it -- the last snooze path in the codebase with no
occurrence guard. The occurrence itself is not trustworthy either:
app.dart falls back to alarma.proximaEjecucion when the native event
carries none, and that field can already point at tomorrow.

_ocurrenciaSonando is generalized into _ocurrenciaValida with a caller-
supplied forward allowance and an externally-proposed occurrence that
still has to survive the same check. The pre-notice path gets a
ventanaPreaviso (30 min, matching AlarmScheduler.PRE_NOTICE_MILLIS) --
unlike the ringing-screen guard, this occurrence legitimately has not
happened yet, which is exactly why the existing helper could not just be
reused here.

Also heals state already poisoned by the missing guard: a snoozeHasta
parked past a 3-hour ceiling (posponerEjecucion clamps to 120 minutes,
so anything beyond that is corruption, not a long real snooze) is
dropped on recalculation. Without it, an alarm poisoned on a build
before this fix keeps reporting tomorrow after updating, and the user
reasonably concludes nothing changed.

2. Android Auto: no progress bar or time labels on a local track.

updatePosition was never set anywhere in the handler, so it sat at its
Duration.zero default while copyWith refreshed updateTime to now on every
push -- the car was told "position 0, as of right now" on every event, a
bar pinned at the start regardless of what was actually playing. Now set
from _player.position on both the player-state and buffered-position
listeners (the latter ticks ~2/s, which is what keeps the car's bar
smooth between player-state events). Also stream the MediaItem's
duration once the source reports it -- Auto draws no bar at all without
one, and radio streams correctly keep reporting none (live audio has no
length).

3. Android Auto: drop the Ecualizador browsable folder.

Owner decision after driving with it: a browsable six-preset list is
more interaction than a driver wants, and on/off from all three player
views (already fixed in 7054a4c to win the custom-action slot) is the
only equalizer control that belongs in the car. Preset selection stays on
the phone. This lands back on the redesign mockup's original rule ("sin
carpeta de ecualizador"), now for a road-tested reason. getChildren keeps
answering the folder's id transitionally, since a head unit can have the
old tree cached for a session or two.

The two "raiz always includes/ends with Ecualizador" tests are replaced,
not regressed -- same move the codebase already made once in the other
direction for the same folder.

Tests: 1127 -> 1132.
2026-08-05 23:07:10 +02:00
6 changed files with 326 additions and 59 deletions
+60 -6
View File
@@ -341,14 +341,41 @@ class EstadoAlarmas extends ChangeNotifier {
/// `posponerAlarma` alone (`9c7cf4e`) while `finalizarEjecucion` sat ten /// `posponerAlarma` alone (`9c7cf4e`) while `finalizarEjecucion` sat ten
/// lines below with the identical hazard and no guard, and it stayed that /// lines below with the identical hazard and no guard, and it stayed that
/// way until a user lost a whole week of alarms. Do not re-inline it. /// way until a user lost a whole week of alarms. Do not re-inline it.
DateTime _ocurrenciaSonando(AlarmaMusical? alarma) { DateTime _ocurrenciaSonando(AlarmaMusical? alarma) =>
_ocurrenciaValida(alarma);
/// How far ahead the PRE-NOTICE notification's occurrence may legitimately
/// sit: it is armed exactly this far before the alarm, so between the
/// reminder appearing and the user tapping it, the occurrence has not
/// happened yet and rejecting it would be wrong.
///
/// Mirrors `AlarmScheduler.PRE_NOTICE_MILLIS` (30 min). Both sides must
/// agree or one of them starts discarding perfectly good anchors.
static const ventanaPreaviso = Duration(minutes: 30);
/// [_ocurrenciaSonando] generalized with a forward allowance, and with an
/// externally-supplied [propuesta] taking priority when it survives the
/// same check.
///
/// [propuesta] is what the NATIVE side reported as the occurrence its
/// notification was about. It is trusted first — it is better evidence than
/// anything reconstructed here — but only after being validated, because it
/// can arrive as a fallback the caller invented (`app.dart` substitutes
/// `alarma.proximaEjecucion` when the native event carries no occurrence,
/// and that field may already point at tomorrow).
DateTime _ocurrenciaValida(
AlarmaMusical? alarma, {
DateTime? propuesta,
Duration margen = Duration.zero,
}) {
final ahora = servicio.ahora(); final ahora = servicio.ahora();
final limite = ahora.add( final limite = ahora.add(
ServicioProgramacionAlarmas.toleranciaDisparoInminente, margen + ServicioProgramacionAlarmas.toleranciaDisparoInminente,
); );
DateTime? sonando(DateTime? candidata) => DateTime? sonando(DateTime? candidata) =>
candidata != null && !candidata.isAfter(limite) ? candidata : null; candidata != null && !candidata.isAfter(limite) ? candidata : null;
return sonando(alarma?.snoozeOrigen) ?? return sonando(propuesta) ??
sonando(alarma?.snoozeOrigen) ??
sonando(alarma?.proximaEjecucion) ?? sonando(alarma?.proximaEjecucion) ??
sonando(alarma?.ultimaEjecucionGestionada) ?? sonando(alarma?.ultimaEjecucionGestionada) ??
ahora; ahora;
@@ -382,6 +409,24 @@ class EstadoAlarmas extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// "Posponer" on the PRE-NOTICE notification.
///
/// Reported on-device: this left the alarm snoozed for 1400+ minutes — a
/// whole day — instead of the configured few. The native lane got its guard
/// in 7054a4c, but Dart runs AFTERWARDS on this path (the receiver's
/// `postponeNext` fires, then `startActivity`, then this) and persists +
/// reschedules, so whatever it computes is the value that survives. It was
/// the last snooze path in the codebase with NO occurrence guard at all:
/// it took [ejecucion] on faith and turned it straight into the next alarm.
///
/// And [ejecucion] is not trustworthy: `app.dart` falls back to
/// `alarma.proximaEjecucion` whenever the native event carries no
/// occurrence, and that field can already point at tomorrow.
///
/// Validated through [_ocurrenciaValida] with a [ventanaPreaviso]
/// allowance — unlike the ringing-screen paths this occurrence legitimately
/// has NOT arrived yet, which is exactly why `_ocurrenciaSonando` could not
/// simply be reused here.
Future<void> posponerProximaDesdePreaviso( Future<void> posponerProximaDesdePreaviso(
AlarmaMusical alarma, AlarmaMusical alarma,
int minutos, int minutos,
@@ -389,14 +434,23 @@ class EstadoAlarmas extends ChangeNotifier {
) async { ) async {
_error = null; _error = null;
final seguros = _snoozeSeguro(minutos); final seguros = _snoozeSeguro(minutos);
final snoozeHasta = ejecucion.add(Duration(minutes: seguros)); final ocurrencia = _ocurrenciaValida(
alarma,
propuesta: ejecucion,
margen: ventanaPreaviso,
);
final snoozeHasta = ocurrencia.add(Duration(minutes: seguros));
debugPrint( debugPrint(
'[PluriWave][alarmas] posponer desde preaviso id=${alarma.id} minutos=$seguros ejecucion=${ejecucion.toIso8601String()} hasta=${snoozeHasta.toIso8601String()}', '[PluriWave][alarmas] posponer desde preaviso id=${alarma.id} minutos=$seguros propuesta=${ejecucion.toIso8601String()} ocurrencia=${ocurrencia.toIso8601String()} hasta=${snoozeHasta.toIso8601String()}',
); );
await android.ocultarNotificacionAlarma(alarma.id); await android.ocultarNotificacionAlarma(alarma.id);
final config = await servicio.posponerEjecucionHasta( final config = await servicio.posponerEjecucionHasta(
alarma.id, alarma.id,
ejecucion, // The VALIDATED occurrence, not the raw parameter: this becomes both
// `snoozeOrigen` and `ultimaEjecucionGestionada`, so passing the
// unchecked value here would poison the very state a9da855/0430059
// exist to keep clean.
ocurrencia,
snoozeHasta, snoozeHasta,
); );
_aplicar(config); _aplicar(config);
+13 -15
View File
@@ -315,20 +315,19 @@ class ConstructorArbolAuto {
/// optionally Música Local, Ecualizador), all non-playable. /// optionally Música Local, Ecualizador), all non-playable.
/// ///
/// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer /// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer
/// folder" rule that used to live in this doc comment (commit `2403da3`, /// There is NO `Ecualizador` folder. The car's only equalizer control is
/// mirroring the redesign mockup's "sin carpeta de ecualizador", turn t4 /// the on/off custom action on the playback screen
/// line 40). That rule was sound when written, but predated on-device /// (`controlesEcualizadorPersonalizados` in `servicio_audio.dart`), which
/// feedback showing that Android Auto custom actions don't surface /// the driver reaches from all three player views without leaving them.
/// enough state for choosing among six presets: a monochrome icon cannot ///
/// legibly encode "which preset", and many head units render a custom /// The folder existed briefly (`8423ccd`) because custom actions were
/// action icon-first, hiding its label. `Ecualizador` is a real /// thought unable to convey enough state for a six-preset choice. Owner
/// browsable folder again: "Desactivar" first, then the six factory /// decision after driving with it: a browsable preset list is more
/// presets, the active one marked (children built by /// interaction than a driver wants, and on/off is the only equalizer
/// `itemsEcualizadorAuto` in `servicio_audio.dart` -- this class stays /// control that belongs in a car. Preset selection stays on the phone.
/// free of any `AppLocalizations` dependency, unlike that builder). /// This lands back on the redesign mockup's original rule ("sin carpeta de
/// Always present, and LAST in the list (after Música Local, when /// ecualizador", turn t4 line 40), now for a road-tested reason rather than
/// included) -- unlike [idMusicaLocal] it is never conditionally hidden. /// an assumed one.
/// Do not "restore" the no-folder rule without re-reading that decision.
/// ///
/// `Música Local` is OMITTED entirely (not just empty) unless /// `Música Local` is OMITTED entirely (not just empty) unless
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder /// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
@@ -339,7 +338,6 @@ class ConstructorArbolAuto {
_carpeta(idTodas, 'Todas las emisoras'), _carpeta(idTodas, 'Todas las emisoras'),
_carpeta(idMisEmisoras, 'Mis emisoras'), _carpeta(idMisEmisoras, 'Mis emisoras'),
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'), if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
_carpeta(idEcualizador, 'Ecualizador'),
]; ];
MediaItem _carpeta(String id, String titulo) => MediaItem( MediaItem _carpeta(String id, String titulo) => MediaItem(
+14
View File
@@ -564,8 +564,22 @@ class ServicioAlarmas {
final ahora = _reloj(); final ahora = _reloj();
// S2-R5: a disabled alarm must not keep a pending snooze; clearing it // S2-R5: a disabled alarm must not keep a pending snooze; clearing it
// here guarantees the snoozed occurrence dies with the alarm. // here guarantees the snoozed occurrence dies with the alarm.
// Self-heal for a snooze target parked absurdly far out — the reported
// "posponer left it 1400+ minutes away". A legitimate snooze can never
// reach here: posponerEjecucion clamps to `minutos.clamp(1, 120)` and the
// anchor is now guarded on both the native and Dart sides, so anything
// past that ceiling is a leftover from a build that had neither guard.
// Without this, an alarm poisoned before the fix keeps showing tomorrow
// on every tick — the user reinstalls, sees no change, and reasonably
// concludes nothing was fixed. Generous margin over the 120-minute cap so
// a real long snooze is never mistaken for corruption.
const techoSnooze = Duration(hours: 3);
final snoozeCorrupto =
alarma.snoozeHasta != null &&
alarma.snoozeHasta!.isAfter(ahora.add(techoSnooze));
final snoozeActivo = final snoozeActivo =
alarma.activa && alarma.activa &&
!snoozeCorrupto &&
alarma.snoozeHasta != null && alarma.snoozeHasta != null &&
alarma.snoozeHasta!.isAfter(ahora); alarma.snoozeHasta!.isAfter(ahora);
// Self-heal for state poisoned before the Detener anchor fix: a stop // Self-heal for state poisoned before the Detener anchor fix: a stop
+41 -1
View File
@@ -578,6 +578,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
late AudioPlayer _player = _crearPlayer(); late AudioPlayer _player = _crearPlayer();
StreamSubscription<PlayerState>? _estadoPlayerSub; StreamSubscription<PlayerState>? _estadoPlayerSub;
StreamSubscription<Duration>? _bufferedSub; StreamSubscription<Duration>? _bufferedSub;
StreamSubscription<Duration?>? _duracionSub;
StreamSubscription<PlaybackEvent>? _eventosSub; StreamSubscription<PlaybackEvent>? _eventosSub;
StreamSubscription<int?>? _androidAudioSessionIdSub; StreamSubscription<int?>? _androidAudioSessionIdSub;
final _androidAudioSessionIdController = StreamController<int?>.broadcast(); final _androidAudioSessionIdController = StreamController<int?>.broadcast();
@@ -720,6 +721,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler
cambiandoFuente: _cambiandoFuente, cambiandoFuente: _cambiandoFuente,
), ),
playing: playing, playing: playing,
// Reported: in Android Auto the progress bar and the time labels of
// a local track never move. `updatePosition` was NEVER set anywhere
// in this file, so it stayed at its `Duration.zero` default while
// `copyWith` refreshed `updateTime` to now on every push
// (audio_service.dart:411-413, :256). A client extrapolates
// `updatePosition + (now - updateTime) * speed`, so it was told
// "position 0, as of right now" over and over — a bar pinned at the
// start. The phone UI never noticed because it reads
// `_player.positionStream` directly.
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition, bufferedPosition: _player.bufferedPosition,
speed: _player.speed, speed: _player.speed,
), ),
@@ -728,7 +739,27 @@ class PluriWaveAudioHandler extends BaseAudioHandler
}); });
_bufferedSub = _player.bufferedPositionStream.listen((pos) { _bufferedSub = _player.bufferedPositionStream.listen((pos) {
playbackState.add(playbackState.value.copyWith(bufferedPosition: pos)); playbackState.add(
playbackState.value.copyWith(
bufferedPosition: pos,
// Must ride along: `copyWith` stamps a fresh `updateTime` but keeps
// the old `updatePosition`, so a push without it actively tells the
// client the PREVIOUS position is current NOW — freezing the bar
// between player-state events. This stream ticks ~2/s, which is
// what keeps the car's bar smooth.
updatePosition: _player.position,
),
);
});
// Duration arrives asynchronously once the source is parsed, and Android
// Auto draws no progress bar for a MediaItem without one. Radio streams
// report null (correct: live audio has no length) and are left alone.
_duracionSub = _player.durationStream.listen((duracion) {
final actual = mediaItem.value;
if (duracion == null || actual == null) return;
if (actual.duration == duracion) return;
mediaItem.add(actual.copyWith(duration: duracion));
}); });
_eventosSub = _player.playbackEventStream.listen( _eventosSub = _player.playbackEventStream.listen(
@@ -1180,6 +1211,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
Future<void> _recrearPlayer() async { Future<void> _recrearPlayer() async {
await _estadoPlayerSub?.cancel(); await _estadoPlayerSub?.cancel();
await _bufferedSub?.cancel(); await _bufferedSub?.cancel();
await _duracionSub?.cancel();
await _eventosSub?.cancel(); await _eventosSub?.cancel();
await _androidAudioSessionIdSub?.cancel(); await _androidAudioSessionIdSub?.cancel();
@@ -1486,6 +1518,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
await stop(); await stop();
await _estadoPlayerSub?.cancel(); await _estadoPlayerSub?.cancel();
await _bufferedSub?.cancel(); await _bufferedSub?.cancel();
await _duracionSub?.cancel();
await _eventosSub?.cancel(); await _eventosSub?.cancel();
await _androidAudioSessionIdSub?.cancel(); await _androidAudioSessionIdSub?.cancel();
await _player.dispose(); await _player.dispose();
@@ -1535,6 +1568,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler
// external data source, unlike every branch below it -- checked // external data source, unlike every branch below it -- checked
// before the `_fuenteNavegacionGlobal` gate, mirroring how the // before the `_fuenteNavegacionGlobal` gate, mirroring how the
// local-music branch above is also resolved before that gate. // local-music branch above is also resolved before that gate.
// The Ecualizador folder is no longer offered by `raiz()` (owner
// decision: the car keeps only the on/off toggle on the playback
// screen). This branch stays as a TRANSITIONAL courtesy: Android Auto
// caches browse trees on the head unit, so a stale "Ecualizador" entry
// can survive the update for a session or two. Answering it keeps that
// leftover working instead of opening an empty dead folder. Delete
// once no head unit can still be holding the old tree.
if (parentMediaId == ConstructorArbolAuto.idEcualizador) { if (parentMediaId == ConstructorArbolAuto.idEcualizador) {
return itemsEcualizadorAuto( return itemsEcualizadorAuto(
activo: _ecualizadorActivo, activo: _ecualizadorActivo,
@@ -0,0 +1,163 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes_alarmas.dart';
/// Reported twice on-device: "Posponer" on the pre-notice notification left
/// the alarm snoozed 1400+ minutes — a whole day — instead of the configured
/// few minutes.
///
/// The first fix (7054a4c) guarded the NATIVE anchor, and it was not enough,
/// because Dart runs AFTERWARDS on this path: the receiver's `postponeNext`
/// fires, then `startActivity`, then `app.dart` dispatches here, and this
/// method persists and reschedules. Whatever Dart computes is the value that
/// survives. It was the last snooze path with no occurrence guard at all.
///
/// It also cannot simply reuse `_ocurrenciaSonando`: the pre-notice's
/// occurrence legitimately has NOT arrived yet (the reminder is armed 30 min
/// ahead), so the ringing-screen guard would reject a perfectly good anchor.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
AlarmaMusical diaria(String id) => AlarmaMusical(
id: id,
nombre: 'Mañana',
hora: 16,
minuto: 20,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
snoozeMinutos: 5,
);
({EstadoAlarmas estado, FakePuertoAlarmasAndroid android}) montar(
DateTime Function() reloj,
) {
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: reloj),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
return (estado: estado, android: android);
}
test('una ocurrencia del DÍA SIGUIENTE se rechaza: pospone minutos, '
'no 24 horas', () async {
// The exact reported shape. The pre-notice for today's 16:20 is on
// screen at 16:16; the anchor handed in points at TOMORROW (either the
// native spec was already advanced, or app.dart fell back to a
// proximaEjecucion that had moved on).
var ahora = DateTime(2026, 8, 3, 16, 0);
final m = montar(() => ahora);
await m.estado.guardarAlarma(diaria('p1'));
ahora = DateTime(2026, 8, 3, 16, 16);
await m.estado.posponerProximaDesdePreaviso(
m.estado.alarmas.single,
5,
DateTime(2026, 8, 4, 16, 20), // <- tomorrow
);
final snooze = m.estado.alarmas.single.snoozeHasta!;
final minutos = snooze.difference(ahora).inMinutes;
expect(
minutos,
lessThan(60),
reason:
'la alarma quedó a $minutos min ($snooze). El reporte fue "más de '
'1400 minutos"; cualquier cosa por encima de una hora es el mismo bug',
);
expect(
m.estado.alarmas.single.snoozeOrigen,
isNot(DateTime(2026, 8, 4, 16, 20)),
reason:
'el ancla sin validar también se guarda como snoozeOrigen y como '
'ultimaEjecucionGestionada — envenenaría el estado que a9da855 y '
'0430059 existen para mantener limpio',
);
});
test('la ocurrencia REAL del preaviso se respeta aunque esté en el futuro: '
'ancla en la ocurrencia + N, no en ahora + N', () async {
// The whole reason this path needs its own guard instead of reusing
// _ocurrenciaSonando: 30 minutes ahead is legitimate here.
var ahora = DateTime(2026, 8, 3, 16, 0);
final m = montar(() => ahora);
await m.estado.guardarAlarma(diaria('p2'));
// Pre-notice fires at 15:50; the user taps at 15:52, 28 min before.
ahora = DateTime(2026, 8, 3, 15, 52);
await m.estado.posponerProximaDesdePreaviso(
m.estado.alarmas.single,
5,
DateTime(2026, 8, 3, 16, 20),
);
expect(m.estado.alarmas.single.snoozeHasta, DateTime(2026, 8, 3, 16, 25));
expect(m.estado.alarmas.single.snoozeOrigen, DateTime(2026, 8, 3, 16, 20));
});
test('un snooze ya envenenado en disco se cura al recalcular', () async {
// Devices that ran the buggy build carry snoozeHasta = tomorrow in
// SharedPreferences. Without healing it, the alarm keeps reporting
// tomorrow on every tick and the user sees no change after updating.
final ahora = DateTime(2026, 8, 3, 16, 0);
final servicio = ServicioAlarmas(reloj: () => ahora);
await servicio.guardarAlarma(
diaria('p4').copyWith(
snoozeHasta: DateTime(2026, 8, 4, 16, 25),
snoozeOrigen: DateTime(2026, 8, 4, 16, 20),
),
);
final alarma = (await servicio.recalcularTodas()).alarmas.single;
expect(alarma.snoozeHasta, isNull);
expect(alarma.proximaProgramable, DateTime(2026, 8, 3, 16, 20));
});
test('un snooze legítimo de 2 horas NO se toca', () async {
// posponerEjecucion clamps to 120 minutes, so the ceiling has to sit
// above that or the heal would eat real snoozes.
final ahora = DateTime(2026, 8, 3, 16, 0);
final servicio = ServicioAlarmas(reloj: () => ahora);
final hasta = DateTime(2026, 8, 3, 18, 0);
await servicio.guardarAlarma(
diaria('p5').copyWith(snoozeHasta: hasta, snoozeOrigen: ahora),
);
expect(
(await servicio.recalcularTodas()).alarmas.single.snoozeHasta,
hasta,
);
});
test('un ancla absurdamente lejana cae a la ocurrencia propia de la alarma, '
'no a un valor inventado', () async {
var ahora = DateTime(2026, 8, 3, 16, 0);
final m = montar(() => ahora);
await m.estado.guardarAlarma(diaria('p3'));
ahora = DateTime(2026, 8, 3, 16, 10);
await m.estado.posponerProximaDesdePreaviso(
m.estado.alarmas.single,
5,
DateTime(2027, 1, 1, 16, 20), // absurd
);
// proximaEjecucion (today 16:20) is inside the pre-notice window, so it
// is the right fallback and the snooze lands on 16:25.
expect(m.estado.alarmas.single.snoozeHasta, DateTime(2026, 8, 3, 16, 25));
});
}
+35 -37
View File
@@ -238,37 +238,10 @@ void main() {
}); });
group('ConstructorArbolAuto.raiz', () { group('ConstructorArbolAuto.raiz', () {
test('con incluirMusicaLocal: true devuelve exactamente 5 carpetas no ' test('con incluirMusicaLocal: true devuelve exactamente 4 carpetas no '
'reproducibles con los ids esperados, terminando en Ecualizador', () { 'reproducibles con los ids esperados', () {
final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: true); final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: true);
expect(raiz, hasLength(5));
final ids = raiz.map((item) => item.id).toSet();
expect(
ids,
equals({
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
ConstructorArbolAuto.idEcualizador,
}),
);
for (final item in raiz) {
expect(item.playable, isFalse);
expect(item.title, isNotEmpty);
}
// Decision `auto/ecualizador-diseno`: Ecualizador is always LAST,
// after Música Local when it is present.
expect(raiz[raiz.length - 2].id, ConstructorArbolAuto.idMusicaLocal);
expect(raiz.last.id, ConstructorArbolAuto.idEcualizador);
});
test('con incluirMusicaLocal: false devuelve exactamente 4 carpetas — '
'Música Local queda OCULTA, no vacía, y Ecualizador sigue presente y '
'al final', () {
final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: false);
expect(raiz, hasLength(4)); expect(raiz, hasLength(4));
final ids = raiz.map((item) => item.id).toSet(); final ids = raiz.map((item) => item.id).toSet();
expect( expect(
@@ -277,25 +250,50 @@ void main() {
ConstructorArbolAuto.idFavoritos, ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas, ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras, ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idEcualizador, ConstructorArbolAuto.idMusicaLocal,
}),
);
for (final item in raiz) {
expect(item.playable, isFalse);
expect(item.title, isNotEmpty);
}
expect(raiz.last.id, ConstructorArbolAuto.idMusicaLocal);
});
test('con incluirMusicaLocal: false devuelve exactamente 3 carpetas — '
'Música Local queda OCULTA, no vacía', () {
final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: false);
expect(raiz, hasLength(3));
final ids = raiz.map((item) => item.id).toSet();
expect(
ids,
equals({
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
}), }),
); );
expect(ids, isNot(contains(ConstructorArbolAuto.idMusicaLocal))); expect(ids, isNot(contains(ConstructorArbolAuto.idMusicaLocal)));
expect(raiz.last.id, ConstructorArbolAuto.idEcualizador);
}); });
test('la raíz ofrece la carpeta de ecualizador -- decisión ' test('la raíz NO ofrece la carpeta de ecualizador -- decisión owner '
'`auto/ecualizador-diseno` SUPERSEDE la regla anterior de "sin ' 'tras conducir con ella: on/off en pantalla es el único control de '
'carpeta de ecualizador" (commit `2403da3`); este test REEMPLAZA ' 'ecualizador que pertenece en el coche, la selección de preset se '
'deliberadamente al test homónimo previo que afirmaba lo contrario, ' 'queda en el móvil. Esto REEMPLAZA deliberadamente al test homónimo '
'no es una regresión', () { 'previo que afirmaba lo contrario (que a su vez había reemplazado la '
'ausencia original en `2403da3`) -- no es una regresión, es la '
'segunda vuelta de la misma decisión con evidencia real de uso', () {
final ids = final ids =
ConstructorArbolAuto() ConstructorArbolAuto()
.raiz(incluirMusicaLocal: true) .raiz(incluirMusicaLocal: true)
.map((item) => item.id) .map((item) => item.id)
.toList(); .toList();
expect(ids, contains(ConstructorArbolAuto.idEcualizador)); expect(ids, isNot(contains(ConstructorArbolAuto.idEcualizador)));
// The id constant itself is NOT deleted: getChildren still answers it
// transitionally for a head unit with a stale cached browse tree. See
// servicio_audio.dart's getChildren for that courtesy branch.
expect(ConstructorArbolAuto.idEcualizador, 'ecualizador'); expect(ConstructorArbolAuto.idEcualizador, 'ecualizador');
}); });
}); });