feat(quality): harden lint rules and add quality-gate tests
This commit is contained in:
@@ -408,7 +408,7 @@ class _AudioControlado extends ServicioAudio {
|
||||
|
||||
Future<File> _archivoCustomVacio() async => _crearArchivoCustom(const []);
|
||||
|
||||
Future<File> _crearArchivoCustom(List<dynamic> emisoras) async {
|
||||
Future<File> _crearArchivoCustom(List<Emisora> emisoras) async {
|
||||
final dir = await Directory.systemTemp.createTemp('pluriwave-test-');
|
||||
final archivo = File('${dir.path}/emisoras_custom.json');
|
||||
await archivo.writeAsString(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Placeholder — tests de integración requieren dispositivo', (tester) async {
|
||||
testWidgets('Placeholder — tests de integración requieren dispositivo', (
|
||||
tester,
|
||||
) async {
|
||||
// Los tests reales de reproducción de audio requieren un dispositivo físico
|
||||
// o emulador con soporte de audio. Este placeholder evita que el CI falle
|
||||
// por el test de smoke incorrecto del boilerplate original.
|
||||
|
||||
@@ -26,7 +26,9 @@ void main() {
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
testWidgets('ignora eventos de estado después de dispose en indicador', (tester) async {
|
||||
testWidgets('ignora eventos de estado después de dispose en indicador', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = StreamController<EstadoReproduccion>.broadcast();
|
||||
|
||||
await tester.pumpWidget(
|
||||
|
||||
Reference in New Issue
Block a user