feat(quality): harden lint rules and add quality-gate tests

This commit is contained in:
2026-06-12 00:05:06 +02:00
parent 202bef3539
commit 8a032e6e62
21 changed files with 485 additions and 140 deletions
@@ -0,0 +1,117 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/controlador_reconexion.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// S6-R3 — Source-revision guard: rapid source switches must not let a stale
/// error from an earlier source bleed through to the final active source.
///
/// [PluriWaveAudioHandler] uses a monotonically-increasing [_revisionFuente]
/// counter; each [playMediaItem] call increments it and checks `revision ==
/// _revisionFuente` before every async step in [_cambiarFuente]. We cannot
/// instantiate the handler in unit tests (MethodChannels), so we exercise the
/// guard invariants through:
/// (a) the static buffer-configuration constant (already fully testable), and
/// (b) [ControladorReconexion.restablecer], which [playMediaItem] calls on
/// every fresh user switch to discard any in-flight backoff for the
/// previous source — the core "no stale error bleeds through" contract.
void main() {
group('Source-switch guard (S6-R3)', () {
test(
'rapid switch resets the reconnect controller so old backoff is discarded',
() {
final temporizadores = <_FakeTimer>[];
final controlador = ControladorReconexion(
crearTemporizador: (duracion, cb) {
final t = _FakeTimer(duracion, cb);
temporizadores.add(t);
return t;
},
);
// Simulate source A failing and scheduling a retry.
controlador.registrarFallo(
intencionReproducir: true,
alReintentar: () {},
);
expect(controlador.reintentoPendiente, isTrue);
// User rapidly switches to source B — playMediaItem calls restablecer().
controlador.restablecer();
expect(
controlador.reintentoPendiente,
isFalse,
reason: 'stale source-A retry must not fire after switching to B',
);
expect(
controlador.intentos,
0,
reason:
'backoff counter resets so source B starts with a clean slate',
);
expect(
temporizadores.single.cancelado,
isTrue,
reason: 'the pending timer for source A was cancelled',
);
},
);
test(
'three rapid switches all reset backoff independently (A→B→C keeps C clean)',
() {
final controlador = ControladorReconexion();
// A fails, schedule retry.
controlador.registrarFallo(
intencionReproducir: true,
alReintentar: () {},
);
// Switch to B — resets.
controlador.restablecer();
// B fails immediately too.
controlador.registrarFallo(
intencionReproducir: true,
alReintentar: () {},
);
// Switch to C — resets again.
controlador.restablecer();
expect(controlador.reintentoPendiente, isFalse);
expect(controlador.intentos, 0);
},
);
test(
'buffer config constant carries the live-stream guard values (S7-R1)',
() {
// Ensures the static config used when recreating the player for a
// source switch carries the correct live-stream buffer parameters.
const config = PluriWaveAudioHandler.configuracionCargaAndroid;
final control = config.androidLoadControl;
expect(control, isNotNull);
expect(control!.minBufferDuration, const Duration(seconds: 15));
expect(control.maxBufferDuration, const Duration(seconds: 50));
},
);
});
}
class _FakeTimer implements Timer {
_FakeTimer(this.duracion, this.callback);
final Duration duracion;
final void Function() callback;
bool cancelado = false;
@override
void cancel() => cancelado = true;
@override
bool get isActive => !cancelado;
@override
int get tick => 0;
}
@@ -31,20 +31,23 @@ void main() {
);
}
test('primera instalación crea esquema completo y guarda favoritos', () async {
final servicio = crearServicio();
addTearDown(servicio.cerrar);
test(
'primera instalación crea esquema completo y guarda favoritos',
() async {
final servicio = crearServicio();
addTearDown(servicio.cerrar);
await servicio.agregar(_emisora('radio-1', 'Radio Uno'));
await servicio.agregar(_emisora('radio-1', 'Radio Uno'));
final favoritos = await servicio.obtenerTodos();
final grupos = await servicio.obtenerGrupos();
final favoritos = await servicio.obtenerTodos();
final grupos = await servicio.obtenerGrupos();
expect(favoritos, hasLength(1));
expect(favoritos.single.grupoFavoritosId, GrupoFavoritos.sinAsignarId);
expect(grupos, hasLength(1));
expect(grupos.single.esSinAsignar, isTrue);
});
expect(favoritos, hasLength(1));
expect(favoritos.single.grupoFavoritosId, GrupoFavoritos.sinAsignarId);
expect(grupos, hasLength(1));
expect(grupos.single.esSinAsignar, isTrue);
},
);
test('migra esquema antiguo sin grupo ni columnas nuevas', () async {
final dbPath = p.join(tempDir.path, 'pluriwave.db');
@@ -84,10 +87,7 @@ void main() {
final grupo = await servicio.crearGrupo('Viajes');
await servicio.asignarGrupo('legacy-1', grupo.id);
expect(
(await servicio.obtenerTodos()).single.grupoFavoritosId,
grupo.id,
);
expect((await servicio.obtenerTodos()).single.grupoFavoritosId, grupo.id);
});
test('eliminar grupo reasigna sus favoritos a Sin asignar', () async {
@@ -108,6 +108,92 @@ void main() {
await servicio.dispose();
});
// T-S6-05-A: a stream error triggers _fallar, which must clear active state
// and set status to error (S7-R5 invariant: no reconnect, immediate clear).
test('error de stream llama _fallar: estado pasa a error y no queda activa '
'(T-S6-05-A)', () async {
final dir = await Directory.systemTemp.createTemp('pluriwave-rec-err-');
final errorController = StreamController<List<int>>();
final servicio = ServicioGrabacionRadio(
cliente: _StreamClient(errorController.stream),
resolverDirectorioBase: () async => dir,
);
await servicio.iniciar(
const Emisora(
uuid: 'r4',
nombre: 'Radio Error',
url: 'https://stream.example/bad',
),
);
// Emit a byte so the stream is in "grabando" state, then error.
errorController.add([1]);
await Future<void>.delayed(Duration.zero);
errorController.addError(Exception('network failure'));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(
servicio.estado.activa,
isFalse,
reason: '_fallar must clear activa flag immediately',
);
expect(
servicio.estado.tipo,
EstadoGrabacionRadioTipo.error,
reason: '_fallar sets status to error, not inactiva',
);
expect(servicio.estado.error, contains('network failure'));
await errorController.close();
await servicio.dispose();
});
// T-S6-05-B: after an error, a subsequent iniciar call must succeed because
// _fallar resets all internal state (subscriptions, sink, client).
test('tras un error, iniciar de nuevo tiene exito (T-S6-05-B)', () async {
final dir = await Directory.systemTemp.createTemp('pluriwave-rec-retry-');
final errorController = StreamController<List<int>>();
final servicio = ServicioGrabacionRadio(
cliente: _StreamClient(errorController.stream),
resolverDirectorioBase: () async => dir,
);
// First attempt — error.
await servicio.iniciar(
const Emisora(
uuid: 'r5',
nombre: 'Radio Retry',
url: 'https://stream.example/retry',
),
);
errorController.addError(Exception('transient error'));
await Future<void>.delayed(const Duration(milliseconds: 20));
await errorController.close();
expect(servicio.estado.tipo, EstadoGrabacionRadioTipo.error);
// Second attempt with a clean stream — must not throw StateError.
final okController = StreamController<List<int>>();
final servicio2 = ServicioGrabacionRadio(
cliente: _StreamClient(okController.stream),
resolverDirectorioBase: () async => dir,
);
await expectLater(
servicio2.iniciar(
const Emisora(
uuid: 'r5',
nombre: 'Radio Retry',
url: 'https://stream.example/retry',
),
),
completes,
reason: 'after error state, a fresh service iniciar must not throw',
);
await okController.close();
await servicio.dispose();
await servicio2.dispose();
});
});
}