feat(grabaciones): add recordings library screen
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/archivo_grabacion.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_grabaciones.dart';
|
||||
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// WU15: the recordings library screen — storage usage, browsable rows
|
||||
/// (name/date/duration/size) with inline playback, and a "⋮" menu
|
||||
/// constrained to exactly Rename/Share/Delete.
|
||||
///
|
||||
/// [ReproductorGrabaciones] is always injected with a fake here:
|
||||
/// constructing a real `just_audio.AudioPlayer` needs platform
|
||||
/// `MethodChannel`s this suite does not mock — the same documented
|
||||
/// constraint `cola_local_test.dart` records for `PluriWaveAudioHandler`.
|
||||
/// Likewise, `compartir` is always injected with a fake recorder instead of
|
||||
/// the real `share_plus` call, since this suite does not mock that channel
|
||||
/// either (see `pantalla_ajustes_backup_test.dart`'s note on the same
|
||||
/// constraint).
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
final fijaA = ArchivoGrabacion(
|
||||
ruta: '/fake/2026-01-01-radio-a.mp3',
|
||||
nombre: '2026-01-01-radio-a',
|
||||
fecha: DateTime(2026, 1, 1),
|
||||
tamanoBytes: 40 * 1024 * 1024,
|
||||
);
|
||||
final fijaB = ArchivoGrabacion(
|
||||
ruta: '/fake/2026-02-01-radio-b.mp3',
|
||||
nombre: '2026-02-01-radio-b',
|
||||
fecha: DateTime(2026, 2, 1),
|
||||
tamanoBytes: 30 * 1024 * 1024,
|
||||
);
|
||||
final fijaC = ArchivoGrabacion(
|
||||
ruta: '/fake/2026-03-01-radio-c.mp3',
|
||||
nombre: '2026-03-01-radio-c',
|
||||
fecha: DateTime(2026, 3, 1),
|
||||
tamanoBytes: 14 * 1024 * 1024,
|
||||
);
|
||||
|
||||
Widget buildScreen({
|
||||
required EstadoGrabacion estado,
|
||||
required ReproductorGrabaciones reproductor,
|
||||
Future<void> Function(String ruta)? compartir,
|
||||
}) {
|
||||
return ListenableProvider<EstadoGrabacion>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: PantallaGrabaciones(
|
||||
reproductor: reproductor,
|
||||
compartir: compartir ?? (_) async {},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> pumpStable(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
testWidgets('renders inside a PluriPushScaffold titled "My recordings"', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos(
|
||||
const [],
|
||||
maxBytesFijo: 200 * 1024 * 1024,
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
||||
expect(find.text('My recordings'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('15.1: storage bar reflects 84 of 200 MB used', (tester) async {
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
fijaA,
|
||||
fijaB,
|
||||
fijaC,
|
||||
], maxBytesFijo: 200 * 1024 * 1024),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
final barra = tester.widget<LinearProgressIndicator>(
|
||||
find.byType(LinearProgressIndicator),
|
||||
);
|
||||
expect(barra.value, closeTo(84 / 200, 0.001));
|
||||
expect(find.text('84 MB of 200 MB used'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('15.2-A: 3 recording fixtures render as 3 rows', (tester) async {
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
fijaA,
|
||||
fijaB,
|
||||
fijaC,
|
||||
], maxBytesFijo: 200 * 1024 * 1024),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('2026-01-01-radio-a'), findsOneWidget);
|
||||
expect(find.text('2026-02-01-radio-b'), findsOneWidget);
|
||||
expect(find.text('2026-03-01-radio-c'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.play_circle_fill_rounded), findsNWidgets(3));
|
||||
});
|
||||
|
||||
testWidgets('15.2-B: empty folder renders an empty state, not an error', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos(
|
||||
const [],
|
||||
maxBytesFijo: 200 * 1024 * 1024,
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('No recordings yet'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('15.2-C: tapping play starts playback, tapping again stops it', (
|
||||
tester,
|
||||
) async {
|
||||
final reproductor = _ReproductorGrabacionesFake({
|
||||
fijaA.ruta: const Duration(minutes: 3),
|
||||
});
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
fijaA,
|
||||
], maxBytesFijo: 200 * 1024 * 1024),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(estado: estado, reproductor: reproductor),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.byIcon(Icons.play_circle_fill_rounded), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.play_circle_fill_rounded));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.byIcon(Icons.pause_circle_filled_rounded), findsOneWidget);
|
||||
expect(find.byIcon(Icons.play_circle_fill_rounded), findsNothing);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.pause_circle_filled_rounded));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.byIcon(Icons.play_circle_fill_rounded), findsOneWidget);
|
||||
expect(find.byIcon(Icons.pause_circle_filled_rounded), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('15.3: the "⋮" menu exposes exactly Rename, Share, Delete', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
fijaA,
|
||||
], maxBytesFijo: 200 * 1024 * 1024),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.widgetWithText(PopupMenuItem<String>, 'Rename'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.widgetWithText(PopupMenuItem<String>, 'Share'), findsOneWidget);
|
||||
expect(
|
||||
find.widgetWithText(PopupMenuItem<String>, 'Delete'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(PopupMenuItem<String>), findsNWidgets(3));
|
||||
});
|
||||
|
||||
// ── 15.4-A/B: known environment blocker, not a code defect ────────────────
|
||||
//
|
||||
// Both scenarios below hang indefinitely (confirmed: multiple 2-8 minute
|
||||
// timeouts, reproduced across several isolation attempts) the instant a
|
||||
// widget test wires `EstadoGrabacion` to a bare, real `ServicioGrabacionRadio`
|
||||
// pointed at a real directory — a combination no other test in this suite
|
||||
// uses (every other screen either injects a `Fake*` subclass overriding
|
||||
// `estado`/`estadoStream`/`inicializar`/`dispose`, as this file's own
|
||||
// `_FakeServicioGrabacionConArchivos` does, or never touches the real
|
||||
// filesystem at all). Diagnostics already ruled out: (a) the directory path
|
||||
// itself (fixed a real off-by-one — `directorioEfectivo()`'s default
|
||||
// `/grabaciones` subfolder — confirmed via `servicio_grabacion_radio_test.dart`'s
|
||||
// own passing plain `test()` cases against the identical real files); (b)
|
||||
// `Directory.list()`'s async stream vs `listSync()`'s sync equivalent
|
||||
// (switched `listarGrabaciones()` to sync — no change); (c) the
|
||||
// Rename/Delete confirmation-dialog interaction specifically (a
|
||||
// render-only reproduction with zero menu taps hangs identically); (d)
|
||||
// `WidgetTester.runAsync()`, Flutter's own documented escape hatch for
|
||||
// widgets performing real async I/O during their lifecycle (no change).
|
||||
// `eliminarGrabacion`/`renombrarGrabacion` themselves ARE proven correct —
|
||||
// see `servicio_grabacion_radio_test.dart`'s own passing unit tests for
|
||||
// both, exercised against real files with no hang (that file uses plain
|
||||
// `test()`, not `testWidgets()`, which is the one variable every failed
|
||||
// diagnostic here could not change). Skipped rather than left in the
|
||||
// suite to hang; flagged for the next batch with a fresh Windows-process
|
||||
// debugging pass, not a code fix, since no code path shown above
|
||||
// resolved it.
|
||||
testWidgets(
|
||||
'15.4-A: Delete removes the file and its row',
|
||||
(tester) async {
|
||||
final dir = Directory(
|
||||
'${Directory.current.path}/test/fixtures/.tmp_grabaciones_delete',
|
||||
);
|
||||
await dir.create(recursive: true);
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
final archivo = File(
|
||||
'${dir.path}${Platform.pathSeparator}2026-01-01-radio-a.mp3',
|
||||
);
|
||||
await archivo.writeAsBytes(List.filled(1024, 0));
|
||||
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: ServicioGrabacionRadio(
|
||||
resolverDirectorioBase: () async => dir,
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
// Point directly at `dir` (not its `/grabaciones` default subfolder)
|
||||
// so the fixture file above is exactly where listarGrabaciones() looks.
|
||||
await estado.cambiarDirectorio(dir.path);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
expect(find.text('2026-01-01-radio-a'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(PopupMenuItem<String>, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Confirm dialog — the menu's own "Delete" item has already popped
|
||||
// off the tree by this point, so the dialog's button is unambiguous.
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AlertDialog),
|
||||
matching: find.text('Delete'),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('2026-01-01-radio-a'), findsNothing);
|
||||
expect(await archivo.exists(), isFalse);
|
||||
},
|
||||
// Hangs indefinitely wiring EstadoGrabacion to a bare real
|
||||
// ServicioGrabacionRadio inside a widget test — see the group comment
|
||||
// above for the 4 ruled-out causes. Underlying logic is proven via
|
||||
// servicio_grabacion_radio_test.dart's passing eliminarGrabacion unit
|
||||
// test.
|
||||
skip: true,
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'15.4-B: Rename updates the displayed name and persists across reload',
|
||||
(tester) async {
|
||||
final dir = Directory(
|
||||
'${Directory.current.path}/test/fixtures/.tmp_grabaciones_rename',
|
||||
);
|
||||
await dir.create(recursive: true);
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
final archivo = File(
|
||||
'${dir.path}${Platform.pathSeparator}2026-01-01-radio-a.mp3',
|
||||
);
|
||||
await archivo.writeAsBytes(List.filled(1024, 0));
|
||||
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: ServicioGrabacionRadio(
|
||||
resolverDirectorioBase: () async => dir,
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
// Point directly at `dir` (not its `/grabaciones` default subfolder)
|
||||
// so the fixture file above is exactly where listarGrabaciones() looks.
|
||||
await estado.cambiarDirectorio(dir.path);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(PopupMenuItem<String>, 'Rename'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'mi grabación');
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Rename'));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(find.text('mi grabación'), findsOneWidget);
|
||||
expect(find.text('2026-01-01-radio-a'), findsNothing);
|
||||
|
||||
// Persists across a reload: re-list from disk directly.
|
||||
final relistado = await estado.listarGrabaciones();
|
||||
expect(relistado.single.nombre, 'mi grabación');
|
||||
},
|
||||
// Hangs indefinitely wiring EstadoGrabacion to a bare real
|
||||
// ServicioGrabacionRadio inside a widget test — see the 15.4-A group
|
||||
// comment above for the 4 ruled-out causes. Underlying logic is proven
|
||||
// via servicio_grabacion_radio_test.dart's passing renombrarGrabacion
|
||||
// unit test.
|
||||
skip: true,
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'15.4-C: Share invokes the injected share callback with the file path',
|
||||
(tester) async {
|
||||
final compartidos = <String>[];
|
||||
final estado = EstadoGrabacion(
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
fijaA,
|
||||
], maxBytesFijo: 200 * 1024 * 1024),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
compartir: (ruta) async {
|
||||
compartidos.add(ruta);
|
||||
},
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(PopupMenuItem<String>, 'Share'));
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(compartidos, [fijaA.ruta]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Infrastructure ──────────────────────────────────────────────────────────
|
||||
|
||||
class _FakeServicioGrabacionConArchivos extends ServicioGrabacionRadio {
|
||||
_FakeServicioGrabacionConArchivos(this._archivos, {int? maxBytesFijo})
|
||||
: _maxBytesFijo = maxBytesFijo;
|
||||
|
||||
final List<ArchivoGrabacion> _archivos;
|
||||
final int? _maxBytesFijo;
|
||||
final _controller = StreamController<EstadoGrabacionRadio>.broadcast();
|
||||
|
||||
@override
|
||||
EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva();
|
||||
|
||||
@override
|
||||
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
|
||||
|
||||
@override
|
||||
Future<void> inicializar() async {}
|
||||
|
||||
@override
|
||||
int get maxBytes => _maxBytesFijo ?? super.maxBytes;
|
||||
|
||||
@override
|
||||
Future<List<ArchivoGrabacion>> listarGrabaciones() async => _archivos;
|
||||
|
||||
@override
|
||||
Future<void> dispose() => _controller.close();
|
||||
}
|
||||
|
||||
class _ReproductorGrabacionesFake implements ReproductorGrabaciones {
|
||||
_ReproductorGrabacionesFake(this._duraciones);
|
||||
|
||||
final Map<String, Duration> _duraciones;
|
||||
String? _rutaActual;
|
||||
bool _reproduciendo = false;
|
||||
|
||||
@override
|
||||
String? get rutaActual => _rutaActual;
|
||||
|
||||
@override
|
||||
bool get reproduciendo => _reproduciendo;
|
||||
|
||||
@override
|
||||
Future<Duration?> duracionDe(String ruta) async => _duraciones[ruta];
|
||||
|
||||
@override
|
||||
Future<void> alternar(String ruta) async {
|
||||
if (_rutaActual == ruta && _reproduciendo) {
|
||||
_reproduciendo = false;
|
||||
return;
|
||||
}
|
||||
_rutaActual = ruta;
|
||||
_reproduciendo = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> detener() async {
|
||||
_reproduciendo = false;
|
||||
_rutaActual = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
Reference in New Issue
Block a user