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/pantallas/pantalla_ajustes.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'; 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() async { final estado = EstadoRadio( audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), servicioGrabacion: _FakeGrabacion(), resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); 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)); } // ── WU3a: AUDIO + EMISORAS become grouped nav rows ───────────────────────── // // Design ADR-3: the root carries zero inline controls for the 7 sections // WU3a moved (Ecualizador, Salida de audio, Temporizador de sueño, Grupos // de favoritos, Emisora preferida, Emisoras personalizadas, Orden de // listas) — each is reached through a FilaAjuste row instead. WU3b (below) // completes the remaining 5 sections, so the root is now exactly 4 // GrupoAjustes cards, under 400 lines. group('WU3a — AUDIO and EMISORAS groups', () { testWidgets('AUDIO group renders exactly 3 nav rows, no inline controls', ( tester, ) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstado(); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); expect(find.text('AUDIO'), findsOneWidget); expect(find.text('Equalizer'), findsOneWidget); expect(find.text('Advanced Equalization Options'), findsOneWidget); expect(find.text('Sleep timer'), findsOneWidget); // Zero inline controls: the old always-visible enable switch and // device-management toggle are gone from the root. expect(find.text('Enable equalizer'), findsNothing); expect(find.text('Enable per-device EQ'), findsNothing); }); testWidgets( 'STATIONS group renders exactly 4 nav rows, no inline controls', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstado(); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); expect(find.text('STATIONS'), findsOneWidget); expect(find.text('Favorite lists'), findsOneWidget); expect(find.text('Preferred station'), findsOneWidget); expect(find.text('Custom stations'), findsOneWidget); expect(find.text('Station order'), findsOneWidget); // Zero inline controls: the descriptive body copy that used to sit // directly under each header is gone from the root now. (A // DropdownButtonFormField still exists on the page — Idioma's // language picker, a section WU3b converts, not WU3a.) expect( find.text( 'Preselected for new alarms and available for quick playback.', ), findsNothing, ); }, ); testWidgets('tapping the Ecualizador row pushes its detail screen', ( tester, ) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstado(); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); await tester.tap(find.text('Equalizer')); await pumpStable(tester); // Pushed, not index-switched: exactly one PluriPushScaffold now exists, // and its moved control (the enable switch, audit 11.1 -- now a // header action, t4 line 566) is reachable. expect(find.byType(PluriPushScaffold), findsOneWidget); expect(find.byKey(const ValueKey('eq-master-switch')), findsOneWidget); }); testWidgets('tapping the Orden de listas row pushes its detail screen', ( tester, ) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstado(); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); await tester.tap(find.text('Station order')); await pumpStable(tester); expect(find.byType(PluriPushScaffold), findsOneWidget); expect(find.text('By name'), findsOneWidget); expect(find.text('By quality'), findsOneWidget); }); }); // ── WU3b: GRABACIONES Y MÚSICA + APLICACIÓN become grouped nav rows ─────── // // Design ADR-3: the root now carries zero inline controls for the final 5 // sections (Grabaciones, Música local, Idioma, Backup, Info) — each is // reached through a FilaAjuste row, matching WU3a's AUDIO/EMISORAS // treatment. All 12 sections are decomposed now; the root is exactly 4 // GrupoAjustes cards and crosses under 400 lines (tasks.md 3b.5). The two // "Phase 7" friendly-folder-name scenarios that used to live in this file // are relocated verbatim to `ajustes/pantalla_ajustes_musica_local_test.dart`, // now targeting the isolated pushed screen directly. group('WU3b — GRABACIONES Y MÚSICA and APLICACIÓN groups', () { testWidgets( 'RECORDINGS & MUSIC group renders exactly 2 nav rows, no inline ' 'controls', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstado(); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); expect(find.text('RECORDINGS & MUSIC'), findsOneWidget); expect(find.text('Recordings'), findsOneWidget); expect(find.text('Local music (Android Auto)'), findsOneWidget); // Zero inline controls: the folder-path row, path action buttons and // max-size row are gone from the root now. expect(find.text('Change path'), findsNothing); expect(find.text('Maximum recording size'), findsNothing); }, ); testWidgets( 'APPLICATION group renders exactly 3 nav rows, no inline controls', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstado(); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); expect(find.text('APPLICATION'), findsOneWidget); // "Language" now renders exactly once at the root (the FilaAjuste // row only) — the old inline dropdown's duplicate label is gone. expect(find.text('Language'), findsOneWidget); expect(find.text('Backup'), findsOneWidget); expect(find.text('Info'), findsOneWidget); // Zero inline controls: the language dropdown and export/import rows // are gone from the root now. expect(find.byType(DropdownButtonFormField), findsNothing); expect(find.text('Export configuration'), findsNothing); }, ); testWidgets( 'tapping the Grabaciones row pushes the recordings LIBRARY, not the ' 'folder/size settings form (WU15b — the approved mockup screen ' '"Ajustes > Grabaciones" depicts the library)', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstado(); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); await tester.tap(find.text('Recordings')); await pumpStable(tester); expect(find.byType(PluriPushScaffold), findsOneWidget); expect(find.text('My recordings'), findsOneWidget); // The folder/size settings form is reachable FROM the library now, // not directly from the Settings root row — see // pantalla_grabaciones_test.dart for that entry point. expect(find.text('Change path'), findsNothing); }, ); testWidgets('tapping the Info row pushes its detail screen', ( tester, ) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstado(); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); await tester.tap(find.text('Info')); await pumpStable(tester); expect(find.byType(PluriPushScaffold), findsOneWidget); expect(find.text('Help and tutorial'), findsOneWidget); }); testWidgets('root now contains exactly 4 GrupoAjustes cards', ( tester, ) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstado(); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); expect(find.text('AUDIO'), findsOneWidget); expect(find.text('STATIONS'), findsOneWidget); expect(find.text('RECORDINGS & MUSIC'), findsOneWidget); expect(find.text('APPLICATION'), findsOneWidget); }); testWidgets( 'Issue 3 (feedback-pruebas): the gap between stacked settings groups ' 'is 16, matching t4:523/534/541 -- not 12', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstado(); addTearDown(estado.dispose); await tester.pumpWidget(buildAjustes(estado)); await pumpStable(tester); for (final key in [ 'ajustes-group-gap-1', 'ajustes-group-gap-2', 'ajustes-group-gap-3', ]) { expect( tester.getSize(find.byKey(ValueKey(key))).height, 16, reason: 't4:523/534/541 all draw a 16px gap between stacked groups', ); } }, ); }); } // ── 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');