Files
pluriwave/test/servicios/servicio_grabacion_radio_test.dart

330 lines
11 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// WU15 task 15.5: `listarGrabaciones()` is a pure filesystem-listing
/// addition — no logic edit to any existing method above.
void main() {
group('ServicioGrabacionRadio', () {
test(
'guarda el stream original en disco con extensión por codec',
() async {
final dir = await Directory.systemTemp.createTemp('pluriwave-rec-');
final servicio = ServicioGrabacionRadio(
cliente: MockClient((request) async {
return http.Response.bytes(
[1, 2, 3, 4, 5],
200,
headers: {'content-type': 'audio/mpeg'},
);
}),
resolverDirectorioBase: () async => dir,
reloj: () => DateTime(2026, 5, 21, 18, 30),
);
await servicio.iniciar(
const Emisora(
uuid: 'r1',
nombre: 'Radio Prueba',
url: 'https://stream.example/radio',
codec: 'MP3',
),
);
await Future<void>.delayed(Duration.zero);
final carpeta = Directory(
'${dir.path}${Platform.pathSeparator}grabaciones',
);
final archivos = await carpeta.list().where((e) => e is File).toList();
expect(archivos, hasLength(1));
expect(archivos.single.path, endsWith('.mp3'));
expect(await File(archivos.single.path).readAsBytes(), [1, 2, 3, 4, 5]);
expect(servicio.estado.tipo, EstadoGrabacionRadioTipo.inactiva);
expect(servicio.ultimoArchivo?.path, archivos.single.path);
await servicio.dispose();
},
);
test('detiene una grabación activa bajo demanda', () async {
final dir = await Directory.systemTemp.createTemp('pluriwave-rec-stop-');
final controller = StreamController<List<int>>();
final servicio = ServicioGrabacionRadio(
cliente: _StreamClient(controller.stream),
resolverDirectorioBase: () async => dir,
);
await servicio.iniciar(
const Emisora(
uuid: 'r2',
nombre: 'Radio Larga',
url: 'https://stream.example/live',
codec: 'aac',
),
);
controller.add([10, 20, 30]);
await Future<void>.delayed(Duration.zero);
expect(servicio.estado.activa, isTrue);
expect(servicio.estado.bytes, 3);
await servicio.detener();
expect(servicio.estado.tipo, EstadoGrabacionRadioTipo.inactiva);
expect(servicio.ultimoArchivo, isNotNull);
await controller.close();
await servicio.dispose();
});
test('se detiene automáticamente al alcanzar el tamaño máximo', () async {
SharedPreferences.setMockInitialValues({});
final dir = await Directory.systemTemp.createTemp('pluriwave-rec-max-');
final controller = StreamController<List<int>>();
final servicio = ServicioGrabacionRadio(
cliente: _StreamClient(controller.stream),
resolverDirectorioBase: () async => dir,
);
await servicio.inicializar();
await servicio.guardarMaxBytes(3);
await servicio.iniciar(
const Emisora(
uuid: 'r3',
nombre: 'Radio Corta',
url: 'https://stream.example/short',
codec: 'mp3',
),
);
controller.add([1, 2, 3]);
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(servicio.estado.tipo, EstadoGrabacionRadioTipo.inactiva);
await controller.close();
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();
});
group('listarGrabaciones (WU15 task 15.5)', () {
test('lista archivos existentes con metadata, orden más reciente '
'primero', () async {
final dir = await Directory.systemTemp.createTemp(
'pluriwave-rec-list-',
);
final carpeta = Directory(
'${dir.path}${Platform.pathSeparator}grabaciones',
);
await carpeta.create(recursive: true);
final antiguo = File(
'${carpeta.path}${Platform.pathSeparator}2026-01-01-radio-a.mp3',
);
await antiguo.writeAsBytes(List.filled(10, 0));
await antiguo.setLastModified(DateTime(2026, 1, 1));
final reciente = File(
'${carpeta.path}${Platform.pathSeparator}2026-06-01-radio-b.mp3',
);
await reciente.writeAsBytes(List.filled(20, 0));
await reciente.setLastModified(DateTime(2026, 6, 1));
final servicio = ServicioGrabacionRadio(
resolverDirectorioBase: () async => dir,
);
final lista = await servicio.listarGrabaciones();
expect(lista, hasLength(2));
expect(lista.first.nombre, '2026-06-01-radio-b');
expect(lista.first.tamanoBytes, 20);
expect(lista.first.fecha, DateTime(2026, 6, 1));
expect(lista.last.nombre, '2026-01-01-radio-a');
expect(lista.last.tamanoBytes, 10);
await servicio.dispose();
});
test('carpeta inexistente devuelve lista vacía, no lanza', () async {
final dir = await Directory.systemTemp.createTemp(
'pluriwave-rec-list-empty-',
);
final servicio = ServicioGrabacionRadio(
resolverDirectorioBase: () async => dir,
);
final lista = await servicio.listarGrabaciones();
expect(lista, isEmpty);
await servicio.dispose();
});
});
group('eliminarGrabacion y renombrarGrabacion (WU15 task 15.5)', () {
test('eliminarGrabacion borra el archivo del disco', () async {
final dir = await Directory.systemTemp.createTemp(
'pluriwave-rec-delete-',
);
final archivo = File('${dir.path}${Platform.pathSeparator}a.mp3');
await archivo.writeAsBytes([1, 2, 3]);
final servicio = ServicioGrabacionRadio(
resolverDirectorioBase: () async => dir,
);
await servicio.eliminarGrabacion(archivo.path);
expect(await archivo.exists(), isFalse);
await servicio.dispose();
});
test('eliminarGrabacion sobre un archivo ya borrado no lanza', () async {
final dir = await Directory.systemTemp.createTemp(
'pluriwave-rec-delete-missing-',
);
final servicio = ServicioGrabacionRadio(
resolverDirectorioBase: () async => dir,
);
await expectLater(
servicio.eliminarGrabacion(
'${dir.path}${Platform.pathSeparator}no-existe.mp3',
),
completes,
);
await servicio.dispose();
});
test(
'renombrarGrabacion preserva la extensión y devuelve la nueva ruta',
() async {
final dir = await Directory.systemTemp.createTemp(
'pluriwave-rec-rename-',
);
final archivo = File(
'${dir.path}${Platform.pathSeparator}original.mp3',
);
await archivo.writeAsBytes([1, 2, 3]);
final servicio = ServicioGrabacionRadio(
resolverDirectorioBase: () async => dir,
);
final nuevaRuta = await servicio.renombrarGrabacion(
archivo.path,
'mi grabación',
);
expect(nuevaRuta, endsWith('mi grabación.mp3'));
expect(await File(nuevaRuta).exists(), isTrue);
expect(await archivo.exists(), isFalse);
await servicio.dispose();
},
);
});
});
}
class _StreamClient extends http.BaseClient {
_StreamClient(this.stream);
final Stream<List<int>> stream;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
return http.StreamedResponse(
stream,
200,
headers: {'content-type': 'audio/aac'},
);
}
}