Compare commits
3
Commits
61c78b5497
...
1e7c0daa90
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e7c0daa90 | ||
|
|
bca0a9bbb7 | ||
|
|
80538900db |
@@ -341,14 +341,41 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
/// `posponerAlarma` alone (`9c7cf4e`) while `finalizarEjecucion` sat ten
|
||||
/// 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.
|
||||
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 limite = ahora.add(
|
||||
ServicioProgramacionAlarmas.toleranciaDisparoInminente,
|
||||
margen + ServicioProgramacionAlarmas.toleranciaDisparoInminente,
|
||||
);
|
||||
DateTime? sonando(DateTime? candidata) =>
|
||||
candidata != null && !candidata.isAfter(limite) ? candidata : null;
|
||||
return sonando(alarma?.snoozeOrigen) ??
|
||||
return sonando(propuesta) ??
|
||||
sonando(alarma?.snoozeOrigen) ??
|
||||
sonando(alarma?.proximaEjecucion) ??
|
||||
sonando(alarma?.ultimaEjecucionGestionada) ??
|
||||
ahora;
|
||||
@@ -382,6 +409,24 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
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(
|
||||
AlarmaMusical alarma,
|
||||
int minutos,
|
||||
@@ -389,14 +434,23 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
) async {
|
||||
_error = null;
|
||||
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(
|
||||
'[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);
|
||||
final config = await servicio.posponerEjecucionHasta(
|
||||
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,
|
||||
);
|
||||
_aplicar(config);
|
||||
|
||||
@@ -315,20 +315,19 @@ class ConstructorArbolAuto {
|
||||
/// optionally Música Local, Ecualizador), all non-playable.
|
||||
///
|
||||
/// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer
|
||||
/// folder" rule that used to live in this doc comment (commit `2403da3`,
|
||||
/// mirroring the redesign mockup's "sin carpeta de ecualizador", turn t4
|
||||
/// line 40). That rule was sound when written, but predated on-device
|
||||
/// feedback showing that Android Auto custom actions don't surface
|
||||
/// enough state for choosing among six presets: a monochrome icon cannot
|
||||
/// legibly encode "which preset", and many head units render a custom
|
||||
/// action icon-first, hiding its label. `Ecualizador` is a real
|
||||
/// browsable folder again: "Desactivar" first, then the six factory
|
||||
/// presets, the active one marked (children built by
|
||||
/// `itemsEcualizadorAuto` in `servicio_audio.dart` -- this class stays
|
||||
/// free of any `AppLocalizations` dependency, unlike that builder).
|
||||
/// Always present, and LAST in the list (after Música Local, when
|
||||
/// included) -- unlike [idMusicaLocal] it is never conditionally hidden.
|
||||
/// Do not "restore" the no-folder rule without re-reading that decision.
|
||||
/// There is NO `Ecualizador` folder. The car's only equalizer control is
|
||||
/// the on/off custom action on the playback screen
|
||||
/// (`controlesEcualizadorPersonalizados` in `servicio_audio.dart`), which
|
||||
/// the driver reaches from all three player views without leaving them.
|
||||
///
|
||||
/// The folder existed briefly (`8423ccd`) because custom actions were
|
||||
/// thought unable to convey enough state for a six-preset choice. Owner
|
||||
/// decision after driving with it: a browsable preset list is more
|
||||
/// interaction than a driver wants, and on/off is the only equalizer
|
||||
/// control that belongs in a car. Preset selection stays on the phone.
|
||||
/// This lands back on the redesign mockup's original rule ("sin carpeta de
|
||||
/// ecualizador", turn t4 line 40), now for a road-tested reason rather than
|
||||
/// an assumed one.
|
||||
///
|
||||
/// `Música Local` is OMITTED entirely (not just empty) unless
|
||||
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
|
||||
@@ -339,7 +338,6 @@ class ConstructorArbolAuto {
|
||||
_carpeta(idTodas, 'Todas las emisoras'),
|
||||
_carpeta(idMisEmisoras, 'Mis emisoras'),
|
||||
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
|
||||
_carpeta(idEcualizador, 'Ecualizador'),
|
||||
];
|
||||
|
||||
MediaItem _carpeta(String id, String titulo) => MediaItem(
|
||||
|
||||
@@ -564,8 +564,22 @@ class ServicioAlarmas {
|
||||
final ahora = _reloj();
|
||||
// S2-R5: a disabled alarm must not keep a pending snooze; clearing it
|
||||
// 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 =
|
||||
alarma.activa &&
|
||||
!snoozeCorrupto &&
|
||||
alarma.snoozeHasta != null &&
|
||||
alarma.snoozeHasta!.isAfter(ahora);
|
||||
// Self-heal for state poisoned before the Detener anchor fix: a stop
|
||||
|
||||
@@ -578,6 +578,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
late AudioPlayer _player = _crearPlayer();
|
||||
StreamSubscription<PlayerState>? _estadoPlayerSub;
|
||||
StreamSubscription<Duration>? _bufferedSub;
|
||||
StreamSubscription<Duration?>? _duracionSub;
|
||||
StreamSubscription<PlaybackEvent>? _eventosSub;
|
||||
StreamSubscription<int?>? _androidAudioSessionIdSub;
|
||||
final _androidAudioSessionIdController = StreamController<int?>.broadcast();
|
||||
@@ -720,6 +721,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
cambiandoFuente: _cambiandoFuente,
|
||||
),
|
||||
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,
|
||||
speed: _player.speed,
|
||||
),
|
||||
@@ -728,7 +739,27 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
});
|
||||
|
||||
_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(
|
||||
@@ -1180,6 +1211,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
Future<void> _recrearPlayer() async {
|
||||
await _estadoPlayerSub?.cancel();
|
||||
await _bufferedSub?.cancel();
|
||||
await _duracionSub?.cancel();
|
||||
await _eventosSub?.cancel();
|
||||
await _androidAudioSessionIdSub?.cancel();
|
||||
|
||||
@@ -1486,6 +1518,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
await stop();
|
||||
await _estadoPlayerSub?.cancel();
|
||||
await _bufferedSub?.cancel();
|
||||
await _duracionSub?.cancel();
|
||||
await _eventosSub?.cancel();
|
||||
await _androidAudioSessionIdSub?.cancel();
|
||||
await _player.dispose();
|
||||
@@ -1535,6 +1568,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
// external data source, unlike every branch below it -- checked
|
||||
// before the `_fuenteNavegacionGlobal` gate, mirroring how the
|
||||
// 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) {
|
||||
return itemsEcualizadorAuto(
|
||||
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));
|
||||
});
|
||||
}
|
||||
@@ -238,37 +238,10 @@ void main() {
|
||||
});
|
||||
|
||||
group('ConstructorArbolAuto.raiz', () {
|
||||
test('con incluirMusicaLocal: true devuelve exactamente 5 carpetas no '
|
||||
'reproducibles con los ids esperados, terminando en Ecualizador', () {
|
||||
test('con incluirMusicaLocal: true devuelve exactamente 4 carpetas no '
|
||||
'reproducibles con los ids esperados', () {
|
||||
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));
|
||||
final ids = raiz.map((item) => item.id).toSet();
|
||||
expect(
|
||||
@@ -277,25 +250,50 @@ void main() {
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
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(raiz.last.id, ConstructorArbolAuto.idEcualizador);
|
||||
});
|
||||
|
||||
test('la raíz SÍ ofrece la carpeta de ecualizador -- decisión '
|
||||
'`auto/ecualizador-diseno` SUPERSEDE la regla anterior de "sin '
|
||||
'carpeta de ecualizador" (commit `2403da3`); este test REEMPLAZA '
|
||||
'deliberadamente al test homónimo previo que afirmaba lo contrario, '
|
||||
'no es una regresión', () {
|
||||
test('la raíz NO ofrece la carpeta de ecualizador -- decisión owner '
|
||||
'tras conducir con ella: on/off en pantalla es el único control de '
|
||||
'ecualizador que pertenece en el coche, la selección de preset se '
|
||||
'queda en el móvil. Esto REEMPLAZA deliberadamente al test homónimo '
|
||||
'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 =
|
||||
ConstructorArbolAuto()
|
||||
.raiz(incluirMusicaLocal: true)
|
||||
.map((item) => item.id)
|
||||
.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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user