import 'dart:async'; import 'dart:io'; 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_idioma.dart'; import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/modelos/dispositivo_audio.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/pantallas/pantalla_ajustes.dart'; import 'package:pluriwave/servicios/servicio_grabacion_radio.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/fakes.dart'; // Pre-existing project constraint: PluriGlassSurface (a glassmorphism // DecoratedBox) is used as the card-style container throughout PantallaAjustes. // Flutter asserts that ListTile's ink is visible, but the assertion fires // as a warning (not a correctness bug) — ink animations are simply not visible // behind the backdrop-filter blur in production either. We suppress it here so // functional tests can run against the existing UI structure. 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({}); }); // ── Helpers ──────────────────────────────────────────────────────────────── Widget buildAjustes(EstadoRadio estado, {EstadoIdioma? idioma}) { final estadoIdioma = idioma ?? EstadoIdioma(); return MultiProvider( providers: [ ChangeNotifierProvider.value(value: estado), ListenableProvider.value(value: estado.ecualizador), ListenableProvider.value(value: estado.grabacion), ChangeNotifierProvider.value(value: estadoIdioma), ], child: MaterialApp( locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const Scaffold(body: PantallaAjustes()), ), ); } Future crearEstado({ bool eqMultiDeviceEnabled = false, Map presetsDispositivo = const {}, FakeServicioDispositivoAudio? dispositivoAudio, }) async { final estado = EstadoRadio( audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador( eqMultiDeviceEnabled: eqMultiDeviceEnabled, presetsDispositivo: presetsDispositivo, ), servicioGrabacion: _FakeGrabacion(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, dispositivoAudio: dispositivoAudio, ); await estado.ecualizador.cargarPersistido(); return estado; } void setLargeSurface(WidgetTester tester) { tester.view.physicalSize = const Size(1440, 3200); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); } Future pumpStable(WidgetTester tester) async { await tester.pump(); await tester.pumpAndSettle(const Duration(milliseconds: 100)); } // Also update crearEstado to support nombresDispositivos Future crearEstadoConNombres({ bool eqMultiDeviceEnabled = true, Map? presetsDispositivo, Map? nombresDispositivos, String? activeDeviceId, String nombrePlataforma = 'BT Speaker', }) async { final fakeDispositivo = activeDeviceId != null ? (FakeServicioDispositivoAudio() ..emitirDispositivo( DispositivoAudio( id: activeDeviceId, tipo: TipoDispositivo.bluetoothA2dp, nombre: nombrePlataforma, ), )) : null; final estado = EstadoRadio( audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador( eqMultiDeviceEnabled: eqMultiDeviceEnabled, presetsDispositivo: presetsDispositivo ?? {'bt_a2dp:AA:BB': PresetEcualizador.rock}, nombresDispositivos: nombresDispositivos ?? {}, ), servicioGrabacion: _FakeGrabacion(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, dispositivoAudio: fakeDispositivo, ); await estado.ecualizador.cargarPersistido(); return estado; } // ── Phase 7 tests ────────────────────────────────────────────────────────── group('_SeccionEcualizadorAvanzado (Phase 7)', () { testWidgets('7.1-A: toggle OFF — advanced EQ section is visible but device ' 'list is not shown', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); // Must be before pumpWidget. final estado = await crearEstado(eqMultiDeviceEnabled: false); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); // Scroll to find the advanced EQ section. await tester.scrollUntilVisible( find.text('Advanced Equalization Options'), 300, scrollable: find.byType(Scrollable).first, ); await pumpStable(tester); // The section header must be present. expect(find.text('Advanced Equalization Options'), findsOneWidget); // Toggle switch title must be present. expect(find.text('Enable per-device EQ'), findsOneWidget); // When toggle is OFF, device list must NOT be rendered. expect(find.text('Known audio devices'), findsNothing); }); testWidgets( '7.1-B: toggle ON with known devices — device list is visible', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstado( eqMultiDeviceEnabled: true, presetsDispositivo: { 'bt_a2dp:AA:BB:CC:DD:EE:FF': PresetEcualizador.rock, }, ); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); await tester.scrollUntilVisible( find.text('Advanced Equalization Options'), 300, scrollable: find.byType(Scrollable).first, ); await pumpStable(tester); // Section header present. expect(find.text('Advanced Equalization Options'), findsOneWidget); // Toggle switch title present. expect(find.text('Enable per-device EQ'), findsOneWidget); // Known devices header should appear when toggle is on and there are // known devices. expect(find.text('Known audio devices'), findsOneWidget); // The device ID should appear in the list. expect( find.textContaining('bt_a2dp:AA:BB:CC:DD:EE:FF'), findsOneWidget, ); }, ); testWidgets( '7.1-C: toggle can be flipped — tapping it enables multi-device EQ', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstado(eqMultiDeviceEnabled: false); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); await tester.scrollUntilVisible( find.text('Advanced Equalization Options'), 300, scrollable: find.byType(Scrollable).first, ); await pumpStable(tester); // Initially OFF. expect(estado.ecualizador.eqMultiDeviceEnabled, isFalse); // Tap the Switch widget to toggle on. await tester.tap(find.byType(Switch).last); await pumpStable(tester); // After tap, toggle should be ON. expect(estado.ecualizador.eqMultiDeviceEnabled, isTrue); }, ); }); // ── Phase 3 (eq-device-autoswitch-ux): connection indicator + modal ────────── group('SeccionEcualizadorAvanzado connection indicator Phase 3', () { // 3.1 RED — active device row shows green connection dot testWidgets('3.1 active device row shows green connection indicator', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); const activeId = 'bt_a2dp:AA:BB'; final estado = await crearEstadoConNombres( eqMultiDeviceEnabled: true, presetsDispositivo: { activeId: PresetEcualizador.rock, 'wired_headset': PresetEcualizador.jazz, }, activeDeviceId: activeId, ); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); await tester.scrollUntilVisible( find.text('Known audio devices'), 300, scrollable: find.byType(Scrollable).first, ); await pumpStable(tester); // Green connection dot should be present (Icon with green color for active device) final greenIcons = tester.widgetList(find.byType(Icon)).where( (icon) => icon.color == Colors.green, ); // At least one green icon present expect(greenIcons, isNotEmpty); }); // 3.2 RED — tapping device row opens bottom sheet with TextField and EcualizadorWidget testWidgets('3.2 tapping device row opens modal with TextField', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstadoConNombres( presetsDispositivo: {'bt_a2dp:AA:BB': PresetEcualizador.rock}, ); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); await tester.scrollUntilVisible( find.text('Known audio devices'), 300, scrollable: find.byType(Scrollable).first, ); await pumpStable(tester); // Tap the device row (tap the edit icon or the row itself) final editIcons = find.byIcon(Icons.edit_rounded); expect(editIcons, findsWidgets); await tester.tap(editIcons.first); await pumpStable(tester); // Bottom sheet should appear with a TextField expect(find.byType(TextField), findsWidgets); }); // 3.6 RED — renaming in modal and confirming calls renombrarDispositivo testWidgets('3.6 confirming rename in modal updates device name', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); const deviceId = 'bt_a2dp:AA:BB'; final estado = await crearEstadoConNombres( presetsDispositivo: {deviceId: PresetEcualizador.rock}, ); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); await tester.scrollUntilVisible( find.text('Known audio devices'), 300, scrollable: find.byType(Scrollable).first, ); await pumpStable(tester); // Open modal await tester.tap(find.byIcon(Icons.edit_rounded).first); await pumpStable(tester); // Enter a new name final textField = find.byType(TextField).first; await tester.enterText(textField, 'My Living Room Speaker'); await pumpStable(tester); // Tap confirm/save button final saveButton = find.byIcon(Icons.save_rounded); expect(saveButton, findsWidgets); await tester.tap(saveButton.first); await pumpStable(tester); // Device should now have the new name expect( estado.ecualizador.obtenerNombreDispositivo(deviceId), equals('My Living Room Speaker'), ); }); // 3.7 RED — dismissing modal without confirming leaves name unchanged testWidgets('3.7 dismissing modal without confirming leaves name unchanged', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); const deviceId = 'bt_a2dp:AA:BB'; final estado = await crearEstadoConNombres( presetsDispositivo: {deviceId: PresetEcualizador.rock}, nombresDispositivos: {deviceId: 'Original Name'}, ); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); await tester.scrollUntilVisible( find.text('Known audio devices'), 300, scrollable: find.byType(Scrollable).first, ); await pumpStable(tester); // Open modal await tester.tap(find.byIcon(Icons.edit_rounded).first); await pumpStable(tester); // Change the text but do NOT confirm final textField = find.byType(TextField).first; await tester.enterText(textField, 'New Name Not Saved'); await pumpStable(tester); // Dismiss by pressing back/escape await tester.tapAt(const Offset(100, 100)); // tap outside bottom sheet await pumpStable(tester); // Name should remain unchanged expect( estado.ecualizador.obtenerNombreDispositivo(deviceId), equals('Original Name'), ); }); }); // ── bt-device-identity Phase 4: display fix + permission trigger ───────── group('_SeccionEcualizadorAvanzado — bt-device-identity Phase 4', () { // 4.1 — new behavior: the cached platform name (not '') now feeds // nombreVisible, so a device with no custom rename shows its real name. testWidgets('4.1 platform name displays with no custom rename', ( tester, ) async { setLargeSurface(tester); _suppressListTileInkAssertion(); const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; final estado = await crearEstadoConNombres( presetsDispositivo: {deviceId: PresetEcualizador.rock}, activeDeviceId: deviceId, nombrePlataforma: 'AirPods Pro', ); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); await tester.scrollUntilVisible( find.text('Known audio devices'), 300, scrollable: find.byType(Scrollable).first, ); await pumpStable(tester); expect(find.text('AirPods Pro'), findsOneWidget); expect(find.text(deviceId), findsNothing); }); // 4.4 — triangulation companion: custom rename still wins even though // the row now also has a cached platform name available. testWidgets('4.4 custom rename overrides platform name', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; final estado = await crearEstadoConNombres( presetsDispositivo: {deviceId: PresetEcualizador.rock}, activeDeviceId: deviceId, nombrePlataforma: 'AirPods Pro', nombresDispositivos: {deviceId: 'My Headphones'}, ); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); await tester.scrollUntilVisible( find.text('Known audio devices'), 300, scrollable: find.byType(Scrollable).first, ); await pumpStable(tester); expect(find.text('My Headphones'), findsOneWidget); expect(find.text('AirPods Pro'), findsNothing); }); // 4.5 — approval test (regression-lock): a device never seen on the // stream has no cached platform name, so legacy raw-id fallback holds. testWidgets('4.5 no platform name yet falls back to raw id', ( tester, ) async { setLargeSurface(tester); _suppressListTileInkAssertion(); const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; final estado = await crearEstadoConNombres( presetsDispositivo: {deviceId: PresetEcualizador.rock}, ); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); await tester.scrollUntilVisible( find.text('Known audio devices'), 300, scrollable: find.byType(Scrollable).first, ); await pumpStable(tester); expect(find.text(deviceId), findsOneWidget); }); // 4.6 — permission trigger point: turning the toggle ON requests // BLUETOOTH_CONNECT; turning it back OFF must not re-fire the request. testWidgets('4.6 permission call fires on device-management open', ( tester, ) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final fakeDispositivo = FakeServicioDispositivoAudio(); final estado = await crearEstado( eqMultiDeviceEnabled: false, dispositivoAudio: fakeDispositivo, ); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); await tester.scrollUntilVisible( find.text('Advanced Equalization Options'), 300, scrollable: find.byType(Scrollable).first, ); await pumpStable(tester); expect(fakeDispositivo.solicitarPermisoBluetoothCalls, equals(0)); // Toggle ON: permission requested exactly once. await tester.tap(find.byType(Switch).last); await pumpStable(tester); expect(estado.ecualizador.eqMultiDeviceEnabled, isTrue); expect(fakeDispositivo.solicitarPermisoBluetoothCalls, equals(1)); // Toggle OFF again: no further permission request. await tester.tap(find.byType(Switch).last); await pumpStable(tester); expect(estado.ecualizador.eqMultiDeviceEnabled, isFalse); expect(fakeDispositivo.solicitarPermisoBluetoothCalls, equals(1)); }); }); } // ── Infrastructure ────────────────────────────────────────────────────────── class _FakeGrabacion extends ServicioGrabacionRadio { final _controller = StreamController.broadcast(); @override EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva(); @override Stream get estadoStream => _controller.stream; @override Future inicializar() async {} @override Future dispose() => _controller.close(); } Future _archivoCustomVacio() async => File('${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json');