feat(audio): audio session integration and runtime robustness
- Integrate audio_session (new servicio_audio_session.dart): incoming calls pause the radio and resume on end, headphone unplug pauses without auto-resume, permanent focus loss never auto-resumes, duck lowers volume - Add play-intent flag to ServicioAudio so interruption handling and future reconnect logic can distinguish user pause from system-driven stops - Eliminate read-modify-write race in ServicioAlarmas with an in-memory cache and single-writer queue across all mutations; recalcularTodas persists only when state actually changed - Convert ServicioAlarmasAndroid static StreamController/handler to injectable instance fields, restoring test isolation - Inject a single cached SharedPreferences from main.dart across services and state (removes 23 inline getInstance() calls) - Move configurarLocalizaciones out of MiniReproductor.build() (was running on every rebuild during playback) - Bound the alarm fire-dedup set (cap 200 entries, 24h pruning) - 12 new tests (89 total green), flutter analyze clean
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
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';
|
||||
|
||||
/// S3-R6: `_ejecucionesEmitidas` must be bounded — stale entries (>24 h)
|
||||
/// pruned and total size capped.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
EstadoAlarmas crearEstado(FakePuertoAlarmasAndroid android) {
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
return estado;
|
||||
}
|
||||
|
||||
const base = AlarmaMusical(
|
||||
id: 'a1',
|
||||
nombre: 'Diaria',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
);
|
||||
|
||||
test('poda las entradas con mas de 24 horas (S3-R6-A)', () {
|
||||
final estado = crearEstado(FakePuertoAlarmasAndroid());
|
||||
final ahora = DateTime.now();
|
||||
|
||||
for (var i = 0; i < 100; i++) {
|
||||
estado.marcarEjecucionGestionada(
|
||||
base.copyWith(
|
||||
proximaEjecucion: ahora.subtract(Duration(hours: 25, minutes: i)),
|
||||
),
|
||||
);
|
||||
}
|
||||
estado.marcarEjecucionGestionada(
|
||||
base.copyWith(
|
||||
id: 'fresca',
|
||||
proximaEjecucion: ahora.add(const Duration(minutes: 5)),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
estado.ejecucionesEmitidasLength,
|
||||
lessThanOrEqualTo(EstadoAlarmas.maxEjecucionesEmitidas),
|
||||
);
|
||||
expect(
|
||||
estado.ejecucionesEmitidasLength,
|
||||
1,
|
||||
reason: 'solo la entrada fresca sobrevive a la poda por antiguedad',
|
||||
);
|
||||
});
|
||||
|
||||
test('limita el total de entradas al tope configurado', () {
|
||||
final estado = crearEstado(FakePuertoAlarmasAndroid());
|
||||
final ahora = DateTime.now();
|
||||
|
||||
for (var i = 0; i < EstadoAlarmas.maxEjecucionesEmitidas + 50; i++) {
|
||||
estado.marcarEjecucionGestionada(
|
||||
base.copyWith(proximaEjecucion: ahora.add(Duration(minutes: i))),
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
estado.ejecucionesEmitidasLength,
|
||||
lessThanOrEqualTo(EstadoAlarmas.maxEjecucionesEmitidas),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
@@ -20,9 +21,16 @@ class FakeServicioAudio extends ServicioAudio {
|
||||
final List<bool> cambiosEcualizadorActivo = [];
|
||||
final List<double> volumenesAplicados = [];
|
||||
int pausas = 0;
|
||||
int configuracionesL10n = 0;
|
||||
Emisora? _emisoraActual;
|
||||
EstadoReproduccion _estadoActual = EstadoReproduccion.detenido;
|
||||
|
||||
@override
|
||||
void configurarLocalizaciones(AppLocalizations l10n) {
|
||||
// No global handler in tests; just record the call.
|
||||
configuracionesL10n++;
|
||||
}
|
||||
|
||||
@override
|
||||
Emisora? get emisoraActual => _emisoraActual;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
|
||||
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
||||
@@ -22,6 +23,9 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
@override
|
||||
Stream<EventoAlarmaAndroid> get eventosAlarma => _eventos.stream;
|
||||
|
||||
@override
|
||||
void configurarLocalizaciones(AppLocalizations l10n) {}
|
||||
|
||||
@override
|
||||
Future<void> programar(AlarmaMusical alarma) async {
|
||||
programadas.add(alarma);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
|
||||
|
||||
/// S3-R2: the event controller and handler flag must be instance state so
|
||||
/// two bridges created independently never share events.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Future<void> emitirAlarmFired(String canal, Map<String, Object?> payload) {
|
||||
final mensaje = const StandardMethodCodec().encodeMethodCall(
|
||||
MethodCall('alarmFired', payload),
|
||||
);
|
||||
return TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.handlePlatformMessage(canal, mensaje, (_) {});
|
||||
}
|
||||
|
||||
test('dos instancias no comparten el stream de eventos (S3-R2-A)', () async {
|
||||
final servicioA = ServicioAlarmasAndroid(
|
||||
channel: const MethodChannel('pluriwave/alarm_scheduler_test_a'),
|
||||
);
|
||||
final servicioB = ServicioAlarmasAndroid(
|
||||
channel: const MethodChannel('pluriwave/alarm_scheduler_test_b'),
|
||||
);
|
||||
|
||||
final eventosA = <EventoAlarmaAndroid>[];
|
||||
final eventosB = <EventoAlarmaAndroid>[];
|
||||
final subA = servicioA.eventosAlarma.listen(eventosA.add);
|
||||
final subB = servicioB.eventosAlarma.listen(eventosB.add);
|
||||
addTearDown(subA.cancel);
|
||||
addTearDown(subB.cancel);
|
||||
|
||||
await emitirAlarmFired('pluriwave/alarm_scheduler_test_a', {
|
||||
'alarmId': 'solo-a',
|
||||
'alarmTitle': 'Alarma A',
|
||||
'alarmAction': 'es.freetimelab.pluriwave.ALARM_FIRE',
|
||||
});
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(eventosA.map((e) => e.alarmaId), ['solo-a']);
|
||||
expect(eventosB, isEmpty, reason: 'B no debe ver los eventos de A');
|
||||
|
||||
await emitirAlarmFired('pluriwave/alarm_scheduler_test_b', {
|
||||
'alarmId': 'solo-b',
|
||||
'alarmTitle': 'Alarma B',
|
||||
'alarmAction': 'es.freetimelab.pluriwave.ALARM_FIRE',
|
||||
});
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(eventosA.map((e) => e.alarmaId), ['solo-a']);
|
||||
expect(eventosB.map((e) => e.alarmaId), ['solo-b']);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// SharedPreferences spy: only the members ServicioAlarmas touches are
|
||||
/// implemented; everything else throws via noSuchMethod.
|
||||
class _PrefsEspia implements SharedPreferences {
|
||||
final Map<String, Object> _datos = {};
|
||||
int escriturasString = 0;
|
||||
int lecturasString = 0;
|
||||
|
||||
@override
|
||||
String? getString(String key) {
|
||||
lecturasString++;
|
||||
return _datos[key] as String?;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> setString(String key, String value) async {
|
||||
escriturasString++;
|
||||
_datos[key] = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
AlarmaMusical alarmaDiaria(
|
||||
ServicioAlarmas servicio,
|
||||
String nombre,
|
||||
int hora,
|
||||
) {
|
||||
return servicio.crearAlarma(
|
||||
nombre: nombre,
|
||||
hora: hora,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
'recalcularTodas NO escribe cuando la agenda no cambio (S3-R5-A)',
|
||||
() async {
|
||||
final prefs = _PrefsEspia();
|
||||
final reloj = DateTime(2026, 6, 11, 6, 0);
|
||||
final servicio = ServicioAlarmas(prefs: prefs, reloj: () => reloj);
|
||||
await servicio.guardarAlarma(alarmaDiaria(servicio, 'Sin cambios', 7));
|
||||
final escriturasBase = prefs.escriturasString;
|
||||
|
||||
await servicio.recalcularTodas();
|
||||
|
||||
expect(
|
||||
prefs.escriturasString,
|
||||
escriturasBase,
|
||||
reason: 'agenda identica => sin setString',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'recalcularTodas escribe exactamente una vez cuando cambia (S3-R5-B)',
|
||||
() async {
|
||||
var ahora = DateTime(2026, 6, 11, 6, 0);
|
||||
final prefs = _PrefsEspia();
|
||||
final servicio = ServicioAlarmas(prefs: prefs, reloj: () => ahora);
|
||||
await servicio.guardarAlarma(alarmaDiaria(servicio, 'Cambia', 7));
|
||||
final escriturasBase = prefs.escriturasString;
|
||||
|
||||
// A day later the next execution moves, so the schedule changed.
|
||||
ahora = DateTime(2026, 6, 12, 8, 0);
|
||||
await servicio.recalcularTodas();
|
||||
|
||||
expect(prefs.escriturasString, escriturasBase + 1);
|
||||
},
|
||||
);
|
||||
|
||||
test('mutaciones concurrentes no pierden escrituras (S3-R7-A)', () async {
|
||||
final prefs = _PrefsEspia();
|
||||
final servicio = ServicioAlarmas(
|
||||
prefs: prefs,
|
||||
reloj: () => DateTime(2026, 6, 11, 6, 0),
|
||||
);
|
||||
final alarmaA = alarmaDiaria(servicio, 'Concurrente A', 7);
|
||||
final alarmaB = alarmaDiaria(servicio, 'Concurrente B', 8);
|
||||
final lecturasBase = prefs.lecturasString;
|
||||
|
||||
// Dispatched WITHOUT awaiting in between: without the cache + writer
|
||||
// queue both read the same base config and the last write wins.
|
||||
await Future.wait([
|
||||
servicio.guardarAlarma(alarmaA),
|
||||
servicio.guardarAlarma(alarmaB),
|
||||
]);
|
||||
|
||||
final config = await servicio.cargar();
|
||||
expect(config.alarmas.map((a) => a.id).toSet(), {alarmaA.id, alarmaB.id});
|
||||
expect(
|
||||
prefs.lecturasString - lecturasBase,
|
||||
lessThanOrEqualTo(2),
|
||||
reason:
|
||||
'las mutaciones hidratan la cache UNA vez; la lectura extra es el cargar() final',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio_session.dart';
|
||||
|
||||
class _ObjetivoFake implements ObjetivoAudioInterrumpible {
|
||||
bool intencion = false;
|
||||
bool reproduciendo = false;
|
||||
int pausas = 0;
|
||||
int reanudaciones = 0;
|
||||
final List<bool> atenuaciones = [];
|
||||
|
||||
@override
|
||||
bool get intencionReproducir => intencion;
|
||||
|
||||
@override
|
||||
bool get estaReproduciendo => reproduciendo;
|
||||
|
||||
@override
|
||||
Future<void> pausar() async {
|
||||
pausas++;
|
||||
reproduciendo = false;
|
||||
intencion = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reanudar() async {
|
||||
reanudaciones++;
|
||||
reproduciendo = true;
|
||||
intencion = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setAtenuado(bool atenuado) async {
|
||||
atenuaciones.add(atenuado);
|
||||
}
|
||||
}
|
||||
|
||||
/// S3-R1: audio-session interruptions (phone call, transient loss, duck) and
|
||||
/// becoming-noisy (headphones unplugged) must pause/duck and auto-resume.
|
||||
void main() {
|
||||
test('interrupcion begin/pause pausa y baja la intencion (S3-R1)', () async {
|
||||
final objetivo =
|
||||
_ObjetivoFake()
|
||||
..reproduciendo = true
|
||||
..intencion = true;
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(true, AudioInterruptionType.pause),
|
||||
);
|
||||
|
||||
expect(objetivo.pausas, 1);
|
||||
expect(
|
||||
objetivo.intencionReproducir,
|
||||
isFalse,
|
||||
reason: 'el reconnect de S7 no debe pelear con la llamada en curso',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'interrupcion end/shouldResume reanuda la reproduccion (S3-R1)',
|
||||
() async {
|
||||
final objetivo =
|
||||
_ObjetivoFake()
|
||||
..reproduciendo = true
|
||||
..intencion = true;
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(true, AudioInterruptionType.pause),
|
||||
);
|
||||
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(false, AudioInterruptionType.pause),
|
||||
);
|
||||
|
||||
expect(objetivo.reanudaciones, 1);
|
||||
expect(objetivo.intencionReproducir, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('end sin pausa previa por interrupcion NO reanuda', () async {
|
||||
final objetivo = _ObjetivoFake();
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(false, AudioInterruptionType.pause),
|
||||
);
|
||||
|
||||
expect(
|
||||
objetivo.reanudaciones,
|
||||
0,
|
||||
reason:
|
||||
'si el usuario ya estaba en pausa, el fin de llamada no arranca audio',
|
||||
);
|
||||
});
|
||||
|
||||
test('duck atenua al comenzar y restaura al terminar', () async {
|
||||
final objetivo =
|
||||
_ObjetivoFake()
|
||||
..reproduciendo = true
|
||||
..intencion = true;
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(true, AudioInterruptionType.duck),
|
||||
);
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(false, AudioInterruptionType.duck),
|
||||
);
|
||||
|
||||
expect(objetivo.atenuaciones, [true, false]);
|
||||
expect(objetivo.pausas, 0);
|
||||
});
|
||||
|
||||
test('becoming-noisy (auriculares desconectados) pausa (S3-R1)', () async {
|
||||
final objetivo =
|
||||
_ObjetivoFake()
|
||||
..reproduciendo = true
|
||||
..intencion = true;
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
|
||||
await servicio.manejarDesconexionSalida();
|
||||
|
||||
expect(objetivo.pausas, 1);
|
||||
|
||||
// A later interruption end must NOT resume: unplugging is a hard pause.
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(false, AudioInterruptionType.pause),
|
||||
);
|
||||
expect(objetivo.reanudaciones, 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/widgets/mini_reproductor.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
class _EstadoRadioContador extends EstadoRadio {
|
||||
_EstadoRadioContador()
|
||||
: super(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
int llamadasConfigurar = 0;
|
||||
|
||||
@override
|
||||
void configurarLocalizaciones(AppLocalizations l10n) {
|
||||
llamadasConfigurar++;
|
||||
super.configurarLocalizaciones(l10n);
|
||||
}
|
||||
}
|
||||
|
||||
/// S3-R3: `configurarLocalizaciones` must run once per locale change, not on
|
||||
/// every rebuild triggered by playback notifications.
|
||||
void main() {
|
||||
testWidgets(
|
||||
'configurarLocalizaciones corre una vez por locale, no por rebuild (S3-R3-A)',
|
||||
(tester) async {
|
||||
final estado = _EstadoRadioContador();
|
||||
addTearDown(estado.dispose);
|
||||
var locale = const Locale('es');
|
||||
late StateSetter cambiarLocale;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<EstadoRadio>.value(
|
||||
value: estado,
|
||||
child: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
cambiarLocale = setState;
|
||||
return MaterialApp(
|
||||
locale: locale,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: MiniReproductor()),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Ten rebuilds driven by playback state notifications.
|
||||
for (var i = 0; i < 10; i++) {
|
||||
estado.notifyListeners();
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
expect(
|
||||
estado.llamadasConfigurar,
|
||||
1,
|
||||
reason: 'diez rebuilds con el mismo locale => una sola configuracion',
|
||||
);
|
||||
|
||||
cambiarLocale(() => locale = const Locale('en'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
estado.llamadasConfigurar,
|
||||
2,
|
||||
reason: 'el cambio de locale debe reconfigurar exactamente una vez',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user