import 'dart:io'; import 'dart:ui' show Locale; import 'package:audio_service/audio_service.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; /// Item 4 (Android Auto: equalizer custom actions) — the pure, handler- /// independent half of the fix. `PluriWaveAudioHandler` cannot be /// instantiated in unit tests (a real `just_audio.AudioPlayer` requires /// platform MethodChannels), so the preset-cycling decision, the preset-name /// localization and the `MediaControl` list construction are extracted as /// pure top-level functions here. The handler's own `customAction` dispatch /// and `playbackState` wiring are static-review-only, same as the existing /// EQ re-apply/session-id wiring. void main() { final l10n = lookupAppLocalizations(const Locale('es')); group('presetSiguiente (item 4 — cycling presets)', () { test('advances to the next preset in order', () { expect( presetSiguiente(PresetEcualizador.flat), PresetEcualizador.rock, ); expect( presetSiguiente(PresetEcualizador.rock), PresetEcualizador.pop, ); }); test('wraps around after the last preset', () { expect( presetSiguiente(PresetEcualizador.presets.last), PresetEcualizador.presets.first, ); }); test( 'an unknown/custom preset (e.g. a user-tweaked "Personalizado" band ' 'set) starts from the FIRST preset instead of throwing', () { final personalizado = PresetEcualizador( nombre: 'Personalizado', bandas: [1.0, 2.0, 3.0, 4.0, 5.0], ); expect( presetSiguiente(personalizado), PresetEcualizador.presets.first, ); }, ); test('respects an injected presets list instead of the default 6', () { final propios = [PresetEcualizador.jazz, PresetEcualizador.voz]; expect( presetSiguiente(PresetEcualizador.jazz, presets: propios), PresetEcualizador.voz, ); expect( presetSiguiente(PresetEcualizador.voz, presets: propios), PresetEcualizador.jazz, ); }); }); group('nombrePresetVisible (item 4)', () { test('maps every factory preset name to its localized ARB string', () { expect(nombrePresetVisible(l10n, 'Flat'), l10n.equalizerPresetFlat); expect(nombrePresetVisible(l10n, 'Rock'), l10n.equalizerPresetRock); expect(nombrePresetVisible(l10n, 'Pop'), l10n.equalizerPresetPop); expect( nombrePresetVisible(l10n, 'Bass Boost'), l10n.equalizerPresetBassBoost, ); expect(nombrePresetVisible(l10n, 'Jazz'), l10n.equalizerPresetJazz); expect(nombrePresetVisible(l10n, 'Voz'), l10n.equalizerPresetVoice); expect( nombrePresetVisible(l10n, 'Personalizado'), l10n.equalizerPresetCustom, ); }); test('an unrecognized name falls through verbatim', () { expect(nombrePresetVisible(l10n, 'Mi Preset Guardado'), 'Mi Preset Guardado'); }); }); group('controlesEcualizadorPersonalizados (item 4)', () { test('empty when the equalizer is not available on this device', () { final controles = controlesEcualizadorPersonalizados( disponible: false, activo: true, presetActual: PresetEcualizador.flat, l10n: l10n, ); expect(controles, isEmpty); }); test( 'exactly 2 custom actions when available: on/off toggle + preset ' 'cycle -- Android Auto shows a limited number of custom actions, so ' 'this is deliberately NOT one action per preset', () { final controles = controlesEcualizadorPersonalizados( disponible: true, activo: true, presetActual: PresetEcualizador.rock, l10n: l10n, ); expect(controles, hasLength(2)); expect(controles.every((c) => c.action == MediaAction.custom), isTrue); }, ); test('toggle label reflects ON -> shows "disable" action', () { final controles = controlesEcualizadorPersonalizados( disponible: true, activo: true, presetActual: PresetEcualizador.flat, l10n: l10n, ); final toggle = controles.firstWhere( (c) => c.customAction?.name == accionEqToggle, ); expect(toggle.label, l10n.eqCustomActionDisableLabel); }); test('toggle label reflects OFF -> shows "enable" action', () { final controles = controlesEcualizadorPersonalizados( disponible: true, activo: false, presetActual: PresetEcualizador.flat, l10n: l10n, ); final toggle = controles.firstWhere( (c) => c.customAction?.name == accionEqToggle, ); expect(toggle.label, l10n.eqCustomActionEnableLabel); }); test('preset-cycle label shows the CURRENT preset localized name', () { final controles = controlesEcualizadorPersonalizados( disponible: true, activo: true, presetActual: PresetEcualizador.jazz, l10n: l10n, ); final ciclo = controles.firstWhere( (c) => c.customAction?.name == accionEqPresetSiguiente, ); expect( ciclo.label, l10n.eqCustomActionPresetLabel(l10n.equalizerPresetJazz), ); }); test( 'toggle icon reflects EQ state: ON uses ic_auto_eq_on, OFF uses ' 'ic_auto_eq_off -- a car head unit foregrounds the icon over the ' 'label, so the icon itself must change, not just the text', () { final activado = controlesEcualizadorPersonalizados( disponible: true, activo: true, presetActual: PresetEcualizador.flat, l10n: l10n, ).firstWhere((c) => c.customAction?.name == accionEqToggle); final desactivado = controlesEcualizadorPersonalizados( disponible: true, activo: false, presetActual: PresetEcualizador.flat, l10n: l10n, ).firstWhere((c) => c.customAction?.name == accionEqToggle); expect(activado.androidIcon, 'drawable/ic_auto_eq_on'); expect(desactivado.androidIcon, 'drawable/ic_auto_eq_off'); }, ); test( 'the toggle and the preset-cycle action never share an androidIcon, ' 'in either EQ state -- this was the on-device bug: both buttons used ' 'the same drawable and were visually indistinguishable', () { for (final activo in [true, false]) { final controles = controlesEcualizadorPersonalizados( disponible: true, activo: activo, presetActual: PresetEcualizador.flat, l10n: l10n, ); final iconos = controles.map((c) => c.androidIcon).toSet(); expect( iconos.length, controles.length, reason: 'every custom action must have a distinct androidIcon ' '(activo=$activo)', ); } }, ); }); group('action name constants (item 4 — collision-free with car-tree ids)', () { test('names are non-empty, distinct, and do not collide with any ' 'existing browse-tree media-id prefix', () { expect(accionEqToggle, isNotEmpty); expect(accionEqPresetSiguiente, isNotEmpty); expect(accionEqToggle, isNot(equals(accionEqPresetSiguiente))); }); }); group( 'equalizer drawable assets on disk (on-device feedback follow-up: the ' 'two custom actions used to share one drawable and were visually ' 'indistinguishable)', () { test( 'ic_auto_eq_on, ic_auto_eq_off and ic_auto_eq_preset all exist under ' 'android/app/src/main/res/drawable/ -- a missing drawable is not a ' 'build error, it silently renders blank/default on the head unit, ' 'so this is the only safety net that would have caught the original ' 'duplication', () { for (final nombre in [ 'ic_auto_eq_on', 'ic_auto_eq_off', 'ic_auto_eq_preset', ]) { final archivo = File( 'android/app/src/main/res/drawable/$nombre.xml', ); expect( archivo.existsSync(), isTrue, reason: '$nombre.xml must exist under ' 'android/app/src/main/res/drawable/', ); } }, ); }, ); }