import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_grupos_favoritos.dart'; import 'package:pluriwave/pantallas/pantalla_favoritos.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'; import '../helpers/fakes_alarmas.dart'; /// WU4, `favorites-organization` spec: chip-filtered flat list replacing the /// stacked per-group panels, drag-to-reorder, the `swap_vert` sort action, /// group management, and the custom-station CTA — all reachable from /// Favoritos. /// /// This screen previously had NO widget-level test coverage — /// `pantalla_favoritos_plural_test.dart` only exercises `stationCount`'s ARB /// plural formatting via `AppLocalizations` directly and never imports /// `PantallaFavoritos`. That file is left untouched (it stays a valid, /// unrelated regression guard); this new file covers the screen itself. /// /// 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. 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({}); }); Future archivoCustomVacio() async => File( '${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json', ); Future crearEstadoVacio() async { return EstadoRadio( audio: FakeServicioAudio(), favoritos: FakeServicioFavoritos(), radio: FakeServicioRadio(), servicioEcualizador: FakeServicioEcualizador(), servicioGrabacion: FakeServicioGrabacionRadioInactiva(), resolverArchivoCustom: archivoCustomVacio, iniciarAutomaticamente: false, ); } /// Seeds 3 favorites (A, B unassigned won't apply — A is unassigned; B and /// C are in a "Rock" group) added in A, B, C order (so the natural/manual /// order is A, B, C). Future crearEstadoConFavoritos() async { final estado = await crearEstadoVacio(); final favoritos = estado.favoritos as FakeServicioFavoritos; final rock = await favoritos.crearGrupo('Rock'); await favoritos.agregar(emisoraDemo(uuid: 'a', nombre: 'Station A')); await favoritos.agregar(emisoraDemo(uuid: 'b', nombre: 'Station B')); await favoritos.agregar(emisoraDemo(uuid: 'c', nombre: 'Station C')); await favoritos.asignarGrupo('b', rock.id); await favoritos.asignarGrupo('c', rock.id); await estado.cargarFavoritos(); await estado.cargarGruposFavoritos(); return estado; } Widget buildScreen(EstadoRadio estado) { // Wrapped in a bare Scaffold, matching pantalla_ajustes_test.dart's // convention: in the real app, _PaginaPrincipal's own Scaffold is what // gives root screens (which construct zero Scaffold themselves, per // ADR-2) a Material ancestor. Without it, Material components like // ChoiceChip/PopupMenuButton/ActionChip fail to find one. return ChangeNotifierProvider.value( value: estado, child: MaterialApp( locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const Scaffold(body: PantallaFavoritos()), ), ); } Future pumpStable(WidgetTester tester) async { await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); } // ── EstadoRadio: new state surface (state-layer, not widget-layer) ─────── test('listaFavoritosManual returns favorites in stored order, NOT re-sorted ' 'by the global ordenListas setting (unlike listaFavoritos)', () async { final estado = await crearEstadoConFavoritos(); addTearDown(estado.dispose); // Natural insertion order is A, B, C; ordenListas defaults to calidad, // which would NOT necessarily preserve that order if applied. expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [ 'a', 'b', 'c', ]); }); test('reordenarFavorito persists the new manual order', () async { final estado = await crearEstadoConFavoritos(); addTearDown(estado.dispose); await estado.reordenarFavorito('c', 0); expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [ 'c', 'a', 'b', ]); // Persists across a simulated restart: reload from the (fake) service. await estado.cargarFavoritos(); expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [ 'c', 'a', 'b', ]); }); test('ordenarFavoritos applies an existing OrdenEmisoras criterion and ' 'persists the result as the new manual order', () async { final estado = await crearEstadoConFavoritos(); addTearDown(estado.dispose); // Put them out of alphabetical order first. await estado.reordenarFavorito('c', 0); expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [ 'c', 'a', 'b', ]); await estado.ordenarFavoritos(OrdenEmisoras.nombre); expect(estado.listaFavoritosManual.map((e) => e.nombre).toList(), [ 'Station A', 'Station B', 'Station C', ]); // Persists: a simulated restart still shows the sorted order, not the // pre-sort manual order. await estado.cargarFavoritos(); expect(estado.listaFavoritosManual.map((e) => e.nombre).toList(), [ 'Station A', 'Station B', 'Station C', ]); }); // ── Widget-level: PantallaFavoritos ─────────────────────────────────────── testWidgets( 'shows the empty state with the custom-station CTA when there are no ' 'favorites', (tester) async { final estado = await crearEstadoVacio(); addTearDown(estado.dispose); await estado.cargarFavoritos(); await estado.cargarGruposFavoritos(); await tester.pumpWidget(buildScreen(estado)); await pumpStable(tester); expect(find.text('No favorites yet'), findsOneWidget); expect(find.text('Add custom station'), findsOneWidget); }, ); testWidgets( 'chip filter narrows the list to the selected group ("All · N" plus ' 'one chip per group)', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstadoConFavoritos(); addTearDown(estado.dispose); await tester.pumpWidget(buildScreen(estado)); await pumpStable(tester); expect(find.text('All · 3'), findsOneWidget); expect(find.text('Unassigned · 1'), findsOneWidget); expect(find.text('Rock · 2'), findsOneWidget); expect(find.text('Station A'), findsOneWidget); expect(find.text('Station B'), findsOneWidget); expect(find.text('Station C'), findsOneWidget); await tester.tap(find.text('Rock · 2')); await pumpStable(tester); expect(find.text('Station A'), findsNothing); expect(find.text('Station B'), findsOneWidget); expect(find.text('Station C'), findsOneWidget); }, ); testWidgets('dragging the 3rd item to the 1st position persists across a ' 'simulated restart', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstadoConFavoritos(); addTearDown(estado.dispose); await tester.pumpWidget(buildScreen(estado)); await pumpStable(tester); final lista = tester.widget( find.byType(ReorderableListView), ); // Drag the 3rd row (index 2, "Station C") to the 1st position (index // 0) — exercised via the real onReorderItem callback the widget wires // up. onReorderItem (not the deprecated onReorder) already adjusts // newIndex for the removed item, so no manual index math here. lista.onReorderItem!(2, 0); await pumpStable(tester); expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [ 'c', 'a', 'b', ]); // Persists across a simulated restart. await estado.cargarFavoritos(); expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [ 'c', 'a', 'b', ]); }); testWidgets( 'swap_vert sort action applies OrdenEmisoras.nombre and re-renders ' 'alphabetically', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstadoConFavoritos(); addTearDown(estado.dispose); await estado.reordenarFavorito('c', 0); // out of alphabetical order await tester.pumpWidget(buildScreen(estado)); await pumpStable(tester); await tester.tap(find.byIcon(Icons.swap_vert_rounded)); await tester.pumpAndSettle(); await tester.tap(find.text('By name')); await pumpStable(tester); expect(estado.listaFavoritosManual.map((e) => e.nombre).toList(), [ 'Station A', 'Station B', 'Station C', ]); // The list re-renders in the new order too, not just the state. expect( tester.getCenter(find.text('Station A')).dy < tester.getCenter(find.text('Station B')).dy, isTrue, ); }, ); testWidgets( 'the create-group action opens group management, and a newly created ' 'group appears as a new filter chip', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstadoConFavoritos(); addTearDown(estado.dispose); await tester.pumpWidget(buildScreen(estado)); await pumpStable(tester); await tester.tap(find.text('Manage lists')); await pumpStable(tester); // "Group Management Reachable from Favoritos": the SAME screen // Settings uses, reused rather than duplicated. expect(find.byType(PluriPushScaffold), findsOneWidget); expect(find.byType(PantallaAjustesGruposFavoritos), findsOneWidget); // Deliberately does NOT interact with _editarGrupo's create-group // bottom sheet here: it has a pre-existing, out-of-scope // TextEditingController dispose-race (Engram // sdd/rediseno-funcional/controller-dispose-bugfix, tracked // separately, not to be fixed in this WU) already independently // exercised by pantalla_ajustes_grupos_favoritos_test.dart. Popping // back to Favoritos and calling the same EstadoRadio method that // form calls proves the part that's actually NEW here — the chip // row's reactivity to a newly created group — without depending on // that unrelated bug's timing. // // PluriPushScaffold uses a plain IconButton for its back affordance, // not a semantic BackButtonIcon/CupertinoNavigationBarBackButton — // tester.pageBack() looks for those and finds neither. await tester.tap(find.byIcon(Icons.arrow_back_rounded)); // pumpAndSettle, not pumpStable: the pop transition's default // animation needs more than a bounded 100ms pump to fully finish. await tester.pumpAndSettle(); expect(find.byType(PantallaAjustesGruposFavoritos), findsNothing); expect(find.text('Manage lists'), findsOneWidget); await estado.crearGrupoFavoritos('Road trip'); await pumpStable(tester); expect(find.text('Road trip · 0'), findsOneWidget); }, ); testWidgets('custom-station CTA opens the add-station flow', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstadoConFavoritos(); addTearDown(estado.dispose); await tester.pumpWidget(buildScreen(estado)); await pumpStable(tester); // The 3200px test surface already fits header + chips + 3 rows + // footer, so no scroll is needed to reach the CTA. await tester.tap(find.text('Add custom station')); await tester.pumpAndSettle(); expect(find.widgetWithText(TextFormField, 'Name *'), findsOneWidget); }); } void setLargeSurface(WidgetTester tester) { tester.view.physicalSize = const Size(1440, 3200); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); }