`AudioService.asyncError` had ZERO subscribers app-wide. The plugin funnels
every asynchronous failure of its own observers into that stream and nowhere
else — `_observePlaybackState`, `_observeMediaItem` and `_observeQueue` each
wrap their whole body in `catch (e) { _asyncError.add(e); }`, and the artwork
path uses `.catchError(_asyncError.add)` — and a `PublishSubject` with no
listeners simply drops what it is given. The platform-side exception behind
"the media playback notification disappeared" was therefore being discarded
without a single log line, which is why that report arrives with no evidence
attached.
`observarErroresAudio` is a pure, injectable seam in `arranque_audio.dart`
(stream in, logger callback out), matching the seam convention this codebase
already uses for `esperarArranqueAudio`, `decidirAvanceCola` and
`debeReaplicarEcualizador`: the unit tests exercise the wiring with a plain
`StreamController`, never the real plugin. The default logger emits one
`[PluriWave]`-prefixed `developer.log` line at `level: 900`, the same level
and prefix `servicio_audio.dart` already uses, so one logcat filter catches
both.
Wired from `lib/main.dart`, not from `arranque_audio.dart`: main.dart is the
module that genuinely owns handler lifecycle — it is the only caller of
`AudioService.init`, `registrarHandler` and `ServicioAudioSession`, and both
the on-time and the degraded/timeout startup branches converge on its
`conectarHandler` closure. `arranque_audio.dart` owns only the timeout race
and the degraded loading shell; it never creates or registers a handler
(`alListo` is injected into it from main.dart), so it has no lifecycle to
hang a subscription on. Subscribing happens before `AudioService.init` — the
getter only touches a static subject — so nothing reported during the
MediaBrowser handshake is missed, and one subscription covers both paths.
The subscription is cancellable and its `cancel` is registered into the
handler via `registrarLimpiezaArranque`, mirroring the existing
`registrarHandler` / `registrarFuenteNavegacion` / `registrarFuenteMusicaLocal`
registration convention. `onTaskRemoved` — the only handler teardown in this
app — runs it, so the subscription cannot outlive what it instruments. The
dependency points bootstrap -> service, so `servicio_audio.dart` never has to
import the bootstrap module or the plugin's static stream.
Zero behaviour change: nothing but log output is added.
156 lines
5.0 KiB
Dart
156 lines
5.0 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:pluriwave/servicios/arranque_audio.dart';
|
|
|
|
/// S8-android-auto-cold-start: covers [esperarArranqueAudio]'s timeout race
|
|
/// in isolation, without touching the real `audio_service` plugin (Design
|
|
/// "Testability" — the init future is injected).
|
|
void main() {
|
|
group('esperarArranqueAudio', () {
|
|
test(
|
|
'handler resuelto antes del timeout devuelve ArranqueAudioListo',
|
|
() async {
|
|
final resultado = await esperarArranqueAudio<String>(
|
|
Future.value('handler-ok'),
|
|
timeout: const Duration(milliseconds: 200),
|
|
);
|
|
|
|
expect(resultado, isA<ArranqueAudioListo<String>>());
|
|
expect((resultado as ArranqueAudioListo<String>).handler, 'handler-ok');
|
|
},
|
|
);
|
|
|
|
test('handler colgado mas alla del timeout devuelve ArranqueAudioPendiente '
|
|
'con el MISMO future original', () async {
|
|
final completer = Completer<String>();
|
|
|
|
final resultado = await esperarArranqueAudio<String>(
|
|
completer.future,
|
|
timeout: const Duration(milliseconds: 20),
|
|
);
|
|
|
|
expect(resultado, isA<ArranqueAudioPendiente<String>>());
|
|
expect(
|
|
identical(
|
|
(resultado as ArranqueAudioPendiente<String>).handlerFuturo,
|
|
completer.future,
|
|
),
|
|
isTrue,
|
|
reason:
|
|
'init nunca debe llamarse dos veces — el future pendiente '
|
|
'debe ser exactamente el mismo objeto',
|
|
);
|
|
|
|
// El completer nunca se completó — liberarlo evita un future
|
|
// colgado tras el test.
|
|
completer.complete('handler-tardio');
|
|
});
|
|
|
|
test('finalizacion tardia tras el timeout resuelve el future original con '
|
|
'el handler real', () async {
|
|
final completer = Completer<String>();
|
|
|
|
final resultado = await esperarArranqueAudio<String>(
|
|
completer.future,
|
|
timeout: const Duration(milliseconds: 20),
|
|
);
|
|
final pendiente = resultado as ArranqueAudioPendiente<String>;
|
|
|
|
completer.complete('handler-tardio');
|
|
final handler = await pendiente.handlerFuturo;
|
|
|
|
expect(handler, 'handler-tardio');
|
|
});
|
|
});
|
|
|
|
/// fix/notificacion-media — commit 1: `AudioService.asyncError` had zero
|
|
/// subscribers, so every exception `audio_service` swallows internally was
|
|
/// dropped on the floor. These cover the injectable seam only (Design
|
|
/// "Testability" — the stream and the logger are both injected), never the
|
|
/// real plugin.
|
|
group('observarErroresAudio', () {
|
|
test('reenvia al logger cada error emitido, en orden', () async {
|
|
final controlador = StreamController<Object>.broadcast();
|
|
final registrados = <Object>[];
|
|
|
|
final sub = observarErroresAudio(
|
|
controlador.stream,
|
|
registrar: registrados.add,
|
|
);
|
|
|
|
controlador.add('fallo-1');
|
|
controlador.add(StateError('fallo-2'));
|
|
await controlador.close();
|
|
|
|
expect(registrados, hasLength(2));
|
|
expect(registrados.first, 'fallo-1');
|
|
expect(registrados.last, isA<StateError>());
|
|
|
|
await sub.cancel();
|
|
});
|
|
|
|
test('cancelar la suscripcion corta el logging — no puede filtrarse '
|
|
'tras el teardown del handler', () async {
|
|
final controlador = StreamController<Object>.broadcast();
|
|
final registrados = <Object>[];
|
|
|
|
final sub = observarErroresAudio(
|
|
controlador.stream,
|
|
registrar: registrados.add,
|
|
);
|
|
|
|
controlador.add('antes-del-cancel');
|
|
// Deja que el evento se entregue antes de cancelar (los broadcast
|
|
// controllers entregan en un microtask, no de forma sincrona).
|
|
await Future<void>.delayed(Duration.zero);
|
|
await sub.cancel();
|
|
|
|
controlador.add('despues-del-cancel');
|
|
await controlador.close();
|
|
|
|
expect(
|
|
registrados,
|
|
['antes-del-cancel'],
|
|
reason:
|
|
'tras cancelar, la suscripcion no debe seguir viva ni registrar '
|
|
'nada mas',
|
|
);
|
|
});
|
|
|
|
test(
|
|
'un evento de error del propio stream tambien llega al logger',
|
|
() async {
|
|
final controlador = StreamController<Object>.broadcast();
|
|
final registrados = <Object>[];
|
|
|
|
final sub = observarErroresAudio(
|
|
controlador.stream,
|
|
registrar: registrados.add,
|
|
);
|
|
|
|
// Rama defensiva: el plugin solo usa `add`, nunca `addError`, pero un
|
|
// error de stream sin manejar seria una excepcion no capturada.
|
|
controlador.addError(const FormatException('stream roto'));
|
|
await controlador.close();
|
|
|
|
expect(registrados, hasLength(1));
|
|
expect(registrados.single, isA<FormatException>());
|
|
|
|
await sub.cancel();
|
|
},
|
|
);
|
|
|
|
test('el logger por defecto acepta cualquier objeto sin lanzar', () {
|
|
expect(
|
|
() => registrarErrorAudioService(StateError('cualquier cosa')),
|
|
returnsNormally,
|
|
);
|
|
expect(
|
|
() => registrarErrorAudioService('un string suelto'),
|
|
returnsNormally,
|
|
);
|
|
});
|
|
});
|
|
}
|