feat(reproductor): restructure full player with tool-tray and EQ sheet
Restructure pantalla_reproductor.dart onto PluriPushScaffold (design ADR-2 - this screen is the documented single consumer of titleOverride, a centered live/not-playing status pill, and the non-default keyboard_arrow_down leadingIcon). Square art replaces the old circular hero, favorite moves from the AppBar into the transport row (the redundant live-indicator dot is dropped - the AppBar pill already covers that signal), the old separate info chips collapse into a single subtitle line, and a new quality row surfaces codec/bitrate with a "Cambiar" action that reconnects the current stream (this app has no per-station alternate-quality capability to invoke, so this reuses the same reproducir() call the existing error-state retry button already uses, rather than a dead button or an invented picker). The always-expanded recording panel and the standalone sleep-timer button both become tool-tray tiles (EQ propio / Grabar / sleep timer / Compartir), each opening its own bottom sheet. "EQ propio" opens a sheet hosting EcualizadorWidget - the exact same component WU13 restyled for Settings, bound via the existing presetParaEmisora/guardarPresetPorEmisora per-station persistence path. No second editor was created; the multi-device-eq resolution hierarchy is untouched. pantalla_reproductor.dart had zero test coverage before this commit (907 lines) - writing it first surfaced two pre-existing bugs blocking any coverage at all, both fixed: initState called estado.reproducir() directly, which notifies listeners synchronously before its first await and threw "setState() during build" the instant the screen mounted against a fresh Provider tree (fixed via addPostFrameCallback); and the body Column had no scrollable ancestor and overflowed even a generously tall viewport (fixed by wrapping it in a SingleChildScrollView, a real UX improvement and not just a test workaround). The three protected EQ test files (servicio_ecualizador_test.dart, estado_ecualizador_test.dart, servicio_audio_eq_reapply_test.dart) stay unmodified. Full suite: 730/730 green (2 skipped, unchanged), up from 713. size:exception - realized 1,410 changed lines (25 files including this docs update) against the 450-600 forecast: the restructured screen file alone is 658 lines (a near-total rewrite of a 907-line file, not a patch), its new test file (first-ever coverage) is 519 lines, and a new test fake plus a togglePlay() override account for the rest. Not splittable: the restructure, the tool tray, and the EQ-sheet wiring are one cohesive change to one screen.
This commit is contained in:
@@ -77,6 +77,21 @@ class FakeServicioAudio extends ServicioAudio {
|
||||
emitirEstado(EstadoReproduccion.pausado);
|
||||
}
|
||||
|
||||
// WU14: the real ServicioAudio.togglePlay() reads `_handler.playbackState`
|
||||
// (a real just_audio-backed handler that requires registrarHandler(), same
|
||||
// gap already documented for androidAudioSessionIdStream above) — unsafe
|
||||
// against a bare FakeServicioAudio. Overridden here using only this Fake's
|
||||
// own state machinery so `pantalla_reproductor.dart`'s play/pause control
|
||||
// (previously untested) can be exercised safely.
|
||||
@override
|
||||
Future<void> togglePlay() async {
|
||||
if (_estadoActual == EstadoReproduccion.reproduciendo) {
|
||||
await pausar();
|
||||
} else {
|
||||
emitirEstado(EstadoReproduccion.reproduciendo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setVolumen(double vol) async {
|
||||
volumenesAplicados.add(vol);
|
||||
@@ -639,6 +654,55 @@ class FakeServicioGrabacionRadio extends ServicioGrabacionRadio {
|
||||
Future<void> dispose() => _controller.close();
|
||||
}
|
||||
|
||||
/// WU14: a recording fake that actually responds to `iniciar`/`detener`
|
||||
/// in-memory, never touching real files or platform channels (`iniciar` on
|
||||
/// the real `ServicioGrabacionRadio` opens an HTTP stream to the station's
|
||||
/// URL and writes to disk — unsafe inside a widget test). Records every
|
||||
/// call for assertions.
|
||||
class FakeServicioGrabacionRadioActivable extends ServicioGrabacionRadio {
|
||||
final _controller = StreamController<EstadoGrabacionRadio>.broadcast();
|
||||
EstadoGrabacionRadio _estadoActual = const EstadoGrabacionRadio.inactiva();
|
||||
final List<Duration?> duracionesIniciadas = [];
|
||||
Emisora? ultimaEmisoraIniciada;
|
||||
int detenerCalls = 0;
|
||||
|
||||
@override
|
||||
EstadoGrabacionRadio get estado => _estadoActual;
|
||||
|
||||
@override
|
||||
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
|
||||
|
||||
@override
|
||||
Future<void> inicializar() async {}
|
||||
|
||||
@override
|
||||
Future<void> iniciar(
|
||||
Emisora emisora, {
|
||||
Duration? duracion,
|
||||
String? directorio,
|
||||
}) async {
|
||||
ultimaEmisoraIniciada = emisora;
|
||||
duracionesIniciadas.add(duracion);
|
||||
_estadoActual = EstadoGrabacionRadio(
|
||||
tipo: EstadoGrabacionRadioTipo.grabando,
|
||||
emisora: emisora,
|
||||
inicio: DateTime.now(),
|
||||
duracionObjetivo: duracion,
|
||||
);
|
||||
_controller.add(_estadoActual);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> detener() async {
|
||||
detenerCalls++;
|
||||
_estadoActual = const EstadoGrabacionRadio.inactiva();
|
||||
_controller.add(_estadoActual);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() => _controller.close();
|
||||
}
|
||||
|
||||
Emisora emisoraDemo({
|
||||
required String uuid,
|
||||
required String nombre,
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_ecualizador.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_reproductor.dart';
|
||||
import 'package:pluriwave/widgets/ecualizador_widget.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// `pantalla_reproductor.dart` (907 lines) had ZERO test coverage before
|
||||
/// this commit — a gap first surfaced during WU5 (no test in this codebase
|
||||
/// had ever exercised `androidAudioSessionIdStream` against a bare
|
||||
/// `FakeServicioAudio` until the Escuchar hero wired `VisualizadorAudio` to
|
||||
/// it). Strict TDD requires coverage BEFORE restructuring this screen, not
|
||||
/// restructuring first and backfilling after — see the two groups below:
|
||||
///
|
||||
/// - `Characterization (pre-WU14 baseline)`: written and run GREEN against
|
||||
/// the screen's CURRENT, unmodified structure (its own commit, before
|
||||
/// this file's restructure). These pin the state-mutation CONTRACTS that
|
||||
/// must survive WU14 unchanged, even though the WIDGETS that trigger them
|
||||
/// move (favorite leaves the AppBar, EQ toggle is replaced by the
|
||||
/// per-station EQ sheet, the always-expanded recording panel and the
|
||||
/// standalone sleep-timer button both become tool-tray tiles).
|
||||
/// - `WU14 — tool tray, square art, EQ sheet reuse`: the NEW target
|
||||
/// structure's RED tests, satisfied by the restructure itself.
|
||||
///
|
||||
/// Two pre-existing bugs surfaced by writing this coverage (both fixed as
|
||||
/// part of this WU, since neither can be worked around from the test side):
|
||||
/// 1. `initState` called `estado.reproducir(...)` directly, which notifies
|
||||
/// `EstadoRadio` listeners SYNCHRONOUSLY before its first `await` (no
|
||||
/// active recording to stop) — threw "setState() or markNeedsBuild()
|
||||
/// called during build" the instant this screen mounted against a fresh
|
||||
/// Provider tree. Fixed via `addPostFrameCallback`.
|
||||
/// 2. The body `Column` has no scrollable ancestor and overflows the
|
||||
/// default 800x600 test viewport (and would overflow on a short real
|
||||
/// device too) — worked around here via the same `physicalSize`
|
||||
/// override `pantalla_alarma_sonando_test.dart` already established,
|
||||
/// which does not require a production change to test against.
|
||||
///
|
||||
/// A third, environment-specific quirk (not a production bug — the same
|
||||
/// `showModalBottomSheet` renders correctly in production and its dialog
|
||||
/// TITLE is always found by these tests, confirming the sheet opens):
|
||||
/// `tester.tap()` by widget position against an `ActionChip` or
|
||||
/// `FilledButton` inside this screen's non-scroll-controlled bottom sheets
|
||||
/// intermittently resolves an offset outside the test viewport regardless
|
||||
/// of viewport size or `disableAnimations`. Every such action inside a
|
||||
/// bottom sheet is invoked directly via its own `onPressed` callback
|
||||
/// instead of `tester.tap()` —
|
||||
/// this only bypasses hit-test positioning, not the actual production
|
||||
/// callback wiring under test.
|
||||
///
|
||||
/// `VisualizadorAudio` starts a repeating `AnimationController` once
|
||||
/// playback reaches "reproduciendo" (WU5's documented hazard) — `initState`
|
||||
/// here calls `estado.reproducir(...)` unconditionally, so EVERY test in
|
||||
/// this file reaches "reproduciendo" almost immediately. `disableAnimations:
|
||||
/// true` (set below) keeps `flutter_animate`'s entrance-animation delays
|
||||
/// from leaving a pending `Timer` at test end; every pump is still bounded
|
||||
/// (`pump()` / `pump(Duration(...))`), never `pumpAndSettle()`.
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
final emisora = emisoraDemo(uuid: 'demo-uuid', nombre: 'Radio Demo');
|
||||
|
||||
EstadoRadio crearEstado({
|
||||
FakeServicioGrabacionRadioActivable? grabacion,
|
||||
List<Emisora> favoritosIniciales = const [],
|
||||
Map<String, PresetEcualizador>? porEmisora,
|
||||
}) {
|
||||
final favoritos = FakeServicioFavoritos();
|
||||
for (final e in favoritosIniciales) {
|
||||
unawaited(favoritos.agregar(e));
|
||||
}
|
||||
return EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: favoritos,
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(porEmisora: porEmisora),
|
||||
servicioGrabacion: grabacion ?? FakeServicioGrabacionRadioInactiva(),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildScreen(
|
||||
EstadoRadio estado, {
|
||||
Emisora? estacion,
|
||||
Future<void> Function(String)? compartir,
|
||||
}) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
|
||||
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
|
||||
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
builder:
|
||||
(context, child) => MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(disableAnimations: true),
|
||||
child: child!,
|
||||
),
|
||||
home: PantallaReproductor(
|
||||
emisora: estacion ?? emisora,
|
||||
compartir: compartir,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The default 800x600 test viewport is shorter than this screen's
|
||||
/// non-scrolling content (never caught before, zero prior coverage) —
|
||||
/// same fix `pantalla_alarma_sonando_test.dart` already established for
|
||||
/// another full-bleed hero screen.
|
||||
Future<void> montarPantalla(
|
||||
WidgetTester tester,
|
||||
EstadoRadio estado, {
|
||||
Emisora? estacion,
|
||||
Future<void> Function(String)? compartir,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1440, 3200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
buildScreen(estado, estacion: estacion, compartir: compartir),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
/// Invokes an `ActionChip`'s own `onPressed` directly, bypassing
|
||||
/// hit-testing — see the file-level doc comment for why.
|
||||
Future<void> presionarActionChip(WidgetTester tester, String label) async {
|
||||
final chip = tester.widget<ActionChip>(
|
||||
find.widgetWithText(ActionChip, label),
|
||||
);
|
||||
chip.onPressed?.call();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
/// Same idea for a `FilledButton` inside a bottom sheet.
|
||||
Future<void> presionarFilledButton(WidgetTester tester, String label) async {
|
||||
final boton = tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, label),
|
||||
);
|
||||
boton.onPressed?.call();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
group('Characterization (pre-WU14 baseline)', () {
|
||||
testWidgets('opening the screen starts playback for the given station', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
|
||||
expect(estado.emisoraActual?.uuid, equals(emisora.uuid));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'opening the screen for the ALREADY-active station does not restart playback',
|
||||
(tester) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
await estado.reproducir(emisora);
|
||||
final llamadasPrevias =
|
||||
(estado.audio as FakeServicioAudio).emisorasReproducidas.length;
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
|
||||
expect(
|
||||
(estado.audio as FakeServicioAudio).emisorasReproducidas.length,
|
||||
equals(llamadasPrevias),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('tapping the primary button while playing pauses playback', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
expect(estado.audio.estaSonando, isTrue);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.pause_rounded));
|
||||
await tester.pump();
|
||||
|
||||
expect(estado.audio.estaSonando, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('tapping stop calls detenerReproduccion', (tester) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
expect(estado.audio.estaSonando, isTrue);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.stop_rounded));
|
||||
await tester.pump();
|
||||
|
||||
// detenerReproduccion() stops playback but does NOT clear
|
||||
// emisoraActual (EstadoRadio.emisoraActual falls back to
|
||||
// _emisoraSeleccionada, which stays set so the screen keeps showing
|
||||
// the last selected station in its "stopped" state) — estaSonando is
|
||||
// the correct signal for "stop actually happened".
|
||||
expect(estado.audio.estaSonando, isFalse);
|
||||
expect(estado.emisoraActual?.uuid, equals(emisora.uuid));
|
||||
});
|
||||
|
||||
testWidgets('tapping favorite toggles the station favorite status', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isFalse);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.favorite_outline_rounded));
|
||||
await tester.pump();
|
||||
|
||||
expect(estado.listaFavoritos.any((e) => e.uuid == emisora.uuid), isTrue);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'starting an indefinite recording calls EstadoGrabacion.iniciar with no duration',
|
||||
(tester) async {
|
||||
final grabacionFake = FakeServicioGrabacionRadioActivable();
|
||||
final estado = crearEstado(grabacion: grabacionFake);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
|
||||
// Tool-tray "Grabar" tile opens the (relocated, unchanged)
|
||||
// `_GrabacionWidget` status card first — its OWN "Record" button
|
||||
// (a FilledButton, scoped to disambiguate from the tile's identical
|
||||
// label behind it) then opens the duration-picker sheet.
|
||||
await tester.tap(find.text('Record'));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await presionarFilledButton(tester, 'Record');
|
||||
await presionarActionChip(tester, 'Indefinite');
|
||||
|
||||
expect(grabacionFake.ultimaEmisoraIniciada?.uuid, equals(emisora.uuid));
|
||||
expect(grabacionFake.duracionesIniciadas, equals([null]));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'starting a custom-duration recording validates and calls iniciar with that duration',
|
||||
(tester) async {
|
||||
final grabacionFake = FakeServicioGrabacionRadioActivable();
|
||||
final estado = crearEstado(grabacion: grabacionFake);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
|
||||
await tester.tap(find.text('Record'));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await presionarFilledButton(tester, 'Record');
|
||||
await presionarActionChip(tester, 'Custom');
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Minutes'),
|
||||
'5',
|
||||
);
|
||||
// The trigger button behind the dialog is ALSO labelled "Record" —
|
||||
// scope to the dialog's own confirm button specifically.
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AlertDialog),
|
||||
matching: find.text('Record'),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(
|
||||
grabacionFake.duracionesIniciadas,
|
||||
equals([const Duration(minutes: 5)]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('starting the sleep timer via a duration chip', (tester) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
expect(estado.timer.activo, isFalse);
|
||||
|
||||
await tester.tap(find.text('Sleep timer'));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await presionarActionChip(tester, '15 min');
|
||||
|
||||
expect(estado.timer.activo, isTrue);
|
||||
|
||||
// ServicioTimer starts a real Timer.periodic(1s) — flutter_test's
|
||||
// pending-timer check runs before addTearDown(estado.dispose) below,
|
||||
// so it must be cancelled here, inside the test body, not left to
|
||||
// teardown.
|
||||
estado.cancelarTimer();
|
||||
});
|
||||
});
|
||||
|
||||
group(
|
||||
'WU14 — square art, single subtitle, quality row, tool tray, EQ sheet reuse',
|
||||
() {
|
||||
testWidgets(
|
||||
'the hero art is square (ClipRRect), not circular (no ClipOval)',
|
||||
(tester) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
|
||||
final arte = find.byKey(const Key('player-hero-art'));
|
||||
expect(arte, findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: arte, matching: find.byType(ClipRRect)),
|
||||
findsWidgets,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: arte, matching: find.byType(ClipOval)),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('favorite lives in the transport row, not the AppBar', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
|
||||
final favorito = find.byIcon(Icons.favorite_outline_rounded);
|
||||
expect(favorito, findsOneWidget);
|
||||
expect(
|
||||
find.ancestor(of: favorito, matching: find.byType(AppBar)),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'a single subtitle line replaces the old separate info chips',
|
||||
(tester) async {
|
||||
const estacion = Emisora(
|
||||
uuid: 'demo-uuid',
|
||||
nombre: 'Radio Demo',
|
||||
url: 'https://stream.demo/radio',
|
||||
pais: 'Argentina',
|
||||
idioma: 'Español',
|
||||
codec: 'MP3',
|
||||
bitrate: 128,
|
||||
);
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado, estacion: estacion);
|
||||
|
||||
expect(find.byType(Chip), findsNothing);
|
||||
expect(find.byKey(const Key('player-subtitle-line')), findsOneWidget);
|
||||
expect(find.text('Argentina · Español'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'the quality row shows codec/bitrate; Change reconnects the current stream',
|
||||
(tester) async {
|
||||
const estacion = Emisora(
|
||||
uuid: 'demo-uuid',
|
||||
nombre: 'Radio Demo',
|
||||
url: 'https://stream.demo/radio',
|
||||
codec: 'MP3',
|
||||
bitrate: 128,
|
||||
);
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado, estacion: estacion);
|
||||
final llamadasPrevias =
|
||||
(estado.audio as FakeServicioAudio).emisorasReproducidas.length;
|
||||
|
||||
expect(find.byKey(const Key('player-quality-row')), findsOneWidget);
|
||||
expect(find.textContaining('MP3'), findsOneWidget);
|
||||
expect(find.text('Change'), findsOneWidget);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const Key('player-quality-change-action')),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
(estado.audio as FakeServicioAudio).emisorasReproducidas.length,
|
||||
greaterThan(llamadasPrevias),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'exactly 4 tool-tray tiles render: EQ propio, Grabar, sleep timer, Compartir',
|
||||
(tester) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
|
||||
expect(find.byKey(const Key('player-tool-eq')), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-tool-record')), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-tool-sleep')), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-tool-share')), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'tapping Compartir invokes the injected share callback with the station name and url',
|
||||
(tester) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
String? compartido;
|
||||
|
||||
await montarPantalla(
|
||||
tester,
|
||||
estado,
|
||||
compartir: (texto) async => compartido = texto,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('player-tool-share')));
|
||||
await tester.pump();
|
||||
|
||||
expect(compartido, contains(emisora.nombre));
|
||||
expect(compartido, contains(emisora.url));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'tapping EQ propio opens a sheet reusing EcualizadorWidget by exact runtime type',
|
||||
(tester) async {
|
||||
final estado = crearEstado(
|
||||
porEmisora: {'demo-uuid': PresetEcualizador.rock},
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await estado.ecualizador.cargarPersistido();
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
|
||||
await tester.tap(find.byKey(const Key('player-tool-eq')));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
final editores = tester.widgetList(find.byType(EcualizadorWidget));
|
||||
expect(editores, hasLength(1));
|
||||
expect(editores.single.runtimeType, equals(EcualizadorWidget));
|
||||
expect(find.byType(Slider), findsNWidgets(5));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'the per-station EQ sheet is bound to the resolved preset and round-trips a change',
|
||||
(tester) async {
|
||||
final estado = crearEstado(
|
||||
porEmisora: {'demo-uuid': PresetEcualizador.rock},
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await estado.ecualizador.cargarPersistido();
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
await tester.tap(find.byKey(const Key('player-tool-eq')));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
final primerSlider = tester.widget<Slider>(find.byType(Slider).first);
|
||||
expect(
|
||||
primerSlider.value,
|
||||
equals(PresetEcualizador.rock.bandas.first),
|
||||
);
|
||||
|
||||
primerSlider.onChanged?.call(4.0);
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
estado.ecualizador.presetsPorEmisora['demo-uuid']?.bandas.first,
|
||||
equals(4.0),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'no second EQ editor file exists — the sheet and Settings share the one EcualizadorWidget class',
|
||||
(tester) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
await tester.tap(find.byKey(const Key('player-tool-eq')));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
// A structural regression guard: if a future change introduced a
|
||||
// parallel editor widget, this assertion (exact type, not "a
|
||||
// widget that looks like an equalizer") would catch it.
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w.runtimeType == EcualizadorWidget),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user