Files
pluriwave/test/pantallas/pantalla_grabaciones_test.dart
FreeTLab acf2ebb55f
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m13s
fix: alinear permisos, paywall y grabacion con lo que la app hace de verdad
Revision previa al envio a produccion. Cada punto se verifico en el codigo
antes de tocarlo; lo que ya estaba bien se dejo como estaba.

Ubicacion: se declaraba precision fina sin usarla

El unico consumidor de ubicacion pide `LocationAccuracy.low` y se queda solo
con el codigo ISO del pais, asi que `ACCESS_FINE_LOCATION` no aportaba nada. Y
contradecia la declaracion de Seguridad de los datos ya aprobada en Play, que
dice ubicacion APROXIMADA: declarar una cosa y pedir otra es precisamente lo
que se penaliza en revision.

Verificado que los manifiestos de geolocator_android y geocoding_android no
declaran permisos propios, asi que el merge no lo reinyecta y no hace falta
`tools:node="remove"`. El plugin construye su peticion en tiempo de ejecucion a
partir de lo declarado, de modo que con COARSE pide COARSE. Sin cambio
funcional: la deteccion de pais sigue igual.

El paywall vendia Android Auto como exclusivo, y ya no lo es

La etiqueta era literalmente "Android Auto", a secas. Pero el tier gratuito
recibio una carpeta navegable con emisoras reproducibles cuando hubo que
cumplir las guias del coche, asi que esa frase dejo de ser cierta. Ahora dice
que PRO añade el catalogo completo, favoritos, mis emisoras y musica local, y
aclara que gratis tiene las destacadas. Un paywall que promete lo que el tier
gratuito ya tiene expone a reclamacion y a que se cite en revision.

Microfono: se pide al activar el visualizador, no antes

Con una explicacion previa en los 13 idiomas, en vez de aparecer sin contexto.

Grabacion: uso privado de verdad, no solo en el aviso

La pantalla de grabaciones entregaba el fichero a cualquier aplicacion con
`Share.shareXFiles`. La intencion era abrirlo en un reproductor del propio
telefono, no redistribuirlo, y una cosa es copia privada y la otra no. Ahora
usa el `openFile` que ya existia -- FileProvider + ACTION_VIEW -- y avisa
cuando ningun reproductor del dispositivo puede abrirla, en vez de fallar en
silencio. Se añade ademas el aviso de uso privado en esa pantalla.

`recordingActionShare` la usaban DOS botones con significados distintos: el de
grabaciones, que mandaba el audio, y el del reproductor, que comparte el nombre
y la url de la emisora. Una clave, dos sentidos, y esa ambiguedad basto para
que al leer el codigo pareciera que solo se compartian enlaces. Separadas en
`stationActionShare` y `recordingActionOpenIn`.

La grabacion sigue siendo PRO. Lo que reduce el riesgo es que la copia no salga
del dispositivo, no regalar la funcion: los anuncios tambien son monetizacion.

Suite completa: 1587 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-18 17:06:59 +02:00

773 lines
25 KiB
Dart

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_glass_surface.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/Open-in-another-app/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, `abrirEnOtraApp` is always injected with a fake recorder
/// instead of the real `pluriwave/file_actions` round trip, since this suite
/// does not mock that channel either (see `pantalla_ajustes_backup_test.dart`
/// for the same constraint).
///
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
/// PluriGlassSurface paints a background over ListTile's ink layer, which
/// Flutter flags as a warning-level assertion, not a correctness bug. Only
/// needed by the WU15b settings-affordance test below, which pushes
/// PantallaAjustesGrabaciones (a ListTile-with-onTap screen) — this file's
/// other scenarios never mount that screen.
void _suppressListTileInkAssertion() {
final original = FlutterError.onError;
FlutterError.onError = (details) {
if (details.exceptionAsString().contains(
'ListTile background color or ink splashes may be invisible',
)) {
return;
}
original?.call(details);
};
addTearDown(() => FlutterError.onError = original);
}
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<bool> Function(String ruta)? abrirEnOtraApp,
}) {
return ListenableProvider<EstadoGrabacion>.value(
value: estado,
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: PantallaGrabaciones(
reproductor: reproductor,
abrirEnOtraApp: abrirEnOtraApp ?? (_) async => true,
),
),
);
}
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(
esPremium: () => true,
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);
});
group('aviso de uso privado', () {
/// Recording a broadcast is defensible as a private copy, and stops
/// being defensible the moment the product reads as a redistribution
/// tool. The library screen had no such statement at all, while the
/// manifest already exposes the recordings folder to the system file
/// manager, so the notice states the intended use in plain words.
testWidgets(
'la biblioteca muestra el aviso de uso personal con la lista vacia',
(tester) async {
final estado = EstadoGrabacion(
esPremium: () => true,
servicio: _FakeServicioGrabacionConArchivos(
const [],
maxBytesFijo: 200 * 1024 * 1024,
),
);
addTearDown(estado.dispose);
await tester.pumpWidget(
buildScreen(
estado: estado,
reproductor: _ReproductorGrabacionesFake(const {}),
),
);
await pumpStable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaGrabaciones)),
);
expect(find.text(l10n.recordingsPrivateUseNotice), findsOneWidget);
},
);
testWidgets('el aviso sigue presente con grabaciones en la lista', (
tester,
) async {
final estado = EstadoGrabacion(
esPremium: () => true,
servicio: _FakeServicioGrabacionConArchivos([
fijaA,
fijaB,
], maxBytesFijo: 200 * 1024 * 1024),
);
addTearDown(estado.dispose);
await tester.pumpWidget(
buildScreen(
estado: estado,
reproductor: _ReproductorGrabacionesFake(const {}),
),
);
await pumpStable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaGrabaciones)),
);
expect(find.text(l10n.recordingsPrivateUseNotice), findsOneWidget);
});
});
testWidgets(
'WU15b: tapping the settings icon pushes the folder/size settings '
'screen (PantallaAjustesGrabaciones stays reachable, now from within '
'the library instead of directly from the Settings root row)',
(tester) async {
_suppressListTileInkAssertion();
final estado = EstadoGrabacion(
esPremium: () => true,
servicio: _FakeServicioGrabacionConArchivos(
const [],
maxBytesFijo: 200 * 1024 * 1024,
),
);
addTearDown(estado.dispose);
await tester.pumpWidget(
buildScreen(
estado: estado,
reproductor: _ReproductorGrabacionesFake(const {}),
),
);
await pumpStable(tester);
// Audit 12.1 (t4:610): the header action icon is `folder_open`, not
// a generic gear.
await tester.tap(find.byIcon(Icons.folder_open_rounded));
await pumpStable(tester);
expect(find.byType(PluriPushScaffold), findsNWidgets(2));
expect(find.text('Change path'), findsOneWidget);
},
);
testWidgets('15.1: storage bar reflects 84 of 200 MB used', (tester) async {
final estado = EstadoGrabacion(
esPremium: () => true,
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(
'visual fidelity (audit 12.2/12.3): the storage card shows the bold '
'"used of total" headline ABOVE the bar, then a folder-path + purge '
'caption below it (t4 line 613)',
(tester) async {
final estado = EstadoGrabacion(
esPremium: () => true,
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 headline = tester.widget<Text>(find.text('84 MB of 200 MB used'));
expect(headline.style?.fontWeight, FontWeight.w800);
expect(
find.text('Music/PluriWave · purges oldest at limit'),
findsOneWidget,
);
final headlineY = tester.getTopLeft(find.text('84 MB of 200 MB used')).dy;
final barY = tester.getTopLeft(find.byType(LinearProgressIndicator)).dy;
final captionY =
tester
.getTopLeft(find.text('Music/PluriWave · purges oldest at limit'))
.dy;
expect(
headlineY < barY && barY < captionY,
isTrue,
reason: 'headline, then bar, then caption -- top to bottom',
);
},
);
testWidgets(
'Issue 3 (feedback-pruebas): the gap between the storage card and the '
'rows below is 16, matching t4:617 -- not 12',
(tester) async {
final estado = EstadoGrabacion(
esPremium: () => true,
servicio: _FakeServicioGrabacionConArchivos([
fijaA,
], maxBytesFijo: 200 * 1024 * 1024),
);
addTearDown(estado.dispose);
await tester.pumpWidget(
buildScreen(
estado: estado,
reproductor: _ReproductorGrabacionesFake(const {}),
),
);
await pumpStable(tester);
expect(
tester
.getSize(find.byKey(const ValueKey('grabaciones-storage-gap')))
.height,
16,
reason: 't4:617 draws a 16px gap here',
);
},
);
testWidgets('15.2-A: 3 recording fixtures render as 3 rows', (tester) async {
final estado = EstadoGrabacion(
esPremium: () => true,
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));
});
// Item 23 / audit 12.4 (t4:616-619): flat, background-less rows with a
// 44x44/radius-12 thumbnail placeholder -- replacing the PluriGlassSurface
// + ListTile card, which had no artwork slot at all.
testWidgets(
'rows are flat -- no ListTile, no per-row PluriGlassSurface -- with a '
'44x44/radius-12 thumbnail placeholder',
(tester) async {
final estado = EstadoGrabacion(
esPremium: () => true,
servicio: _FakeServicioGrabacionConArchivos([
fijaA,
fijaB,
], maxBytesFijo: 200 * 1024 * 1024),
);
addTearDown(estado.dispose);
await tester.pumpWidget(
buildScreen(
estado: estado,
reproductor: _ReproductorGrabacionesFake(const {}),
),
);
await pumpStable(tester);
expect(
find.byType(ListTile),
findsNothing,
reason: 'audit 12.4 replaces the ListTile row with a flat Row',
);
expect(
find.byType(PluriGlassSurface),
findsOneWidget,
reason: 'only the storage card keeps a surface -- rows do not',
);
final miniaturas = find.byKey(const ValueKey('fila-grabacion-arte'));
expect(miniaturas, findsNWidgets(2));
expect(tester.getSize(miniaturas.first), const Size(44, 44));
final clip = tester.widget<ClipRRect>(miniaturas.first);
expect(
(clip.borderRadius as BorderRadius).topLeft,
const Radius.circular(12),
);
},
);
testWidgets('15.2-B: empty folder renders an empty state, not an error', (
tester,
) async {
final estado = EstadoGrabacion(
esPremium: () => true,
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(
esPremium: () => true,
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);
});
// The middle entry used to be Share, which handed the audio file to the
// system share sheet. It is now a LOCAL open: play your own recording in
// another app on the same device. The exact-count assertion is the guard
// that no off-device action creeps back in beside it.
testWidgets('15.3: the "⋮" menu exposes exactly Rename, Open in another '
'app, Delete', (tester) async {
final estado = EstadoGrabacion(
esPremium: () => true,
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>, 'Open in another app'),
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(
esPremium: () => true,
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(
esPremium: () => true,
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,
);
/// The row menu used to hand the audio file to the system share sheet,
/// which is redistribution of someone else's broadcast. What the owner
/// actually wanted is to play your own recording in another app on the
/// same device, so the action is a local ACTION_VIEW instead.
group('15.4-C: abrir la grabacion en otra app del dispositivo', () {
testWidgets('invoca el seam de apertura local con la ruta del archivo', (
tester,
) async {
final abiertos = <String>[];
final estado = EstadoGrabacion(
esPremium: () => true,
servicio: _FakeServicioGrabacionConArchivos([
fijaA,
], maxBytesFijo: 200 * 1024 * 1024),
);
addTearDown(estado.dispose);
await tester.pumpWidget(
buildScreen(
estado: estado,
reproductor: _ReproductorGrabacionesFake(const {}),
abrirEnOtraApp: (ruta) async {
abiertos.add(ruta);
return true;
},
),
);
await pumpStable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaGrabaciones)),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
await tester.tap(
find.widgetWithText(PopupMenuItem<String>, l10n.recordingActionOpenIn),
);
await pumpStable(tester);
expect(abiertos, [fijaA.ruta]);
});
testWidgets(
'si ningun reproductor del dispositivo puede abrirla, lo dice en vez '
'de fallar en silencio',
(tester) async {
final estado = EstadoGrabacion(
esPremium: () => true,
servicio: _FakeServicioGrabacionConArchivos([
fijaA,
], maxBytesFijo: 200 * 1024 * 1024),
);
addTearDown(estado.dispose);
await tester.pumpWidget(
buildScreen(
estado: estado,
reproductor: _ReproductorGrabacionesFake(const {}),
abrirEnOtraApp: (_) async => false,
),
);
await pumpStable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaGrabaciones)),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
await tester.tap(
find.widgetWithText(
PopupMenuItem<String>,
l10n.recordingActionOpenIn,
),
);
await pumpStable(tester);
expect(
find.widgetWithText(SnackBar, l10n.recordingOpenNoAppError),
findsOneWidget,
);
},
);
});
}
// ── 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 {}
}