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/servicios/servicio_anuncios.dart'; import 'package:pluriwave/widgets/fila_emisora_plana.dart'; import 'package:pluriwave/widgets/pluri_layout.dart'; import 'package:pluriwave/widgets/pluri_push_scaffold.dart'; import 'package:pluriwave/widgets/pluri_root_header.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.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( esPremium: () => true, 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 MultiProvider( providers: [ ChangeNotifierProvider.value(value: estado), Provider( create: (_) => ServicioAnuncios(esPremium: () => true), ), ], 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)); } group('visual fidelity (audit 4.5): dashed custom-station CTA', () { testWidgets( 'padding is 14 all around, the icon is a plain add glyph at 20px, ' 'and the label is 13.5px/w800 (t4 line 235)', (tester) async { final estado = await crearEstadoVacio(); addTearDown(estado.dispose); await tester.pumpWidget(buildScreen(estado)); await pumpStable(tester); final padding = tester.widget( find.byKey(const Key('custom-station-cta-padding')), ); expect(padding.padding, const EdgeInsets.all(14)); expect(find.byIcon(Icons.add_circle_outline_rounded), findsNothing); final icon = tester.widget(find.byIcon(Icons.add_rounded)); expect(icon.size, 20); final l10n = lookupAppLocalizations(const Locale('en')); final texto = tester.widget(find.text(l10n.customStationsAddCta)); expect(texto.style?.fontSize, 13.5); expect(texto.style?.fontWeight, FontWeight.w800); }, ); }); // ── 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 onReorder callback the widget wires up. // `onReorder` is the API present across Flutter versions (the newer // `onReorderItem` does not exist on the CI SDK), so it reports // newIndex in the PRE-removal coordinate space; `_onReorder` // compensates internally. Moving upwards needs no shift, which is why // (2, 0) maps straight through. lista.onReorder!(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('dragging the 1st item downwards lands it in the right slot', ( tester, ) async { // Guards the pre-removal index compensation in `_onReorder`. Downward // drags are the ONLY direction `ReorderableListView.onReorder` reports // in the pre-removal coordinate space, so an off-by-one here would slip // past the upward-drag test above entirely. 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), ); // Move "Station A" (index 0) into the MIDDLE slot. onReorder reports // newIndex == 2 in the pre-removal space; `_onReorder` shifts it to 1. // This specific case is what makes the test meaningful: dropping at the // very end (0, 3) yields the same answer with or without the shift, // because both land in the `newIndex >= restantes.length` branch. Only a // mid-list drop separates the two. lista.onReorder!(0, 2); await pumpStable(tester); expect(estado.listaFavoritosManual.map((e) => e.uuid).toList(), [ 'b', 'a', 'c', ]); }); 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); // Audit 4.1 (t4:216): the manage-groups action moved into the // header as a create_new_folder icon button, replacing the old // "Manage lists" ActionChip in the chip strip. await tester.tap( find.byKey(const ValueKey('favorites-manage-groups-action')), ); 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.byKey(const ValueKey('favorites-manage-groups-action')), 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); }); // Item 23 / audit 4.3 (t4:226-232): flat, background-less rows with a // square thumbnail and a circular play affordance, replacing the full // glass TarjetaEmisora card + two stacked filledTonal buttons. group('Item 23 -- flat rows (audit 4.3)', () { testWidgets( 'each row is a flat FilaEmisoraPlana, not the full glass card', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstadoConFavoritos(); addTearDown(estado.dispose); await tester.pumpWidget(buildScreen(estado)); await pumpStable(tester); expect(find.byType(FilaEmisoraPlana), findsNWidgets(3)); expect( find.byType(TarjetaEmisora), findsNothing, reason: 'audit 4.3 replaces the full glass card with a flat row', ); expect( find.byIcon(Icons.drag_indicator_rounded), findsNWidgets(3), reason: 't4:227 drag_indicator, not drag_handle', ); expect(find.byIcon(Icons.drag_handle_rounded), findsNothing); expect( find.byType(BotonReproducirCircular), findsNWidgets(3), reason: 't4:230 a circular play affordance per row', ); }, ); testWidgets( 'the overflow menu offers exactly "Move to list" and "Remove from ' 'favorites"', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstadoConFavoritos(); addTearDown(estado.dispose); await tester.pumpWidget(buildScreen(estado)); await pumpStable(tester); await tester.tap(find.byIcon(Icons.more_vert_rounded).first); await tester.pumpAndSettle(); expect(find.text('Move to list'), findsOneWidget); expect(find.text('Remove from favorites'), findsOneWidget); expect(find.byType(PopupMenuItem), findsNWidgets(2)); // Regression guard for a real user-reported bug: the button carried // `constraints: BoxConstraints.tightFor(width: 38, height: 42)`, // which sizes the POPUP MENU rather than the button. Every item was // clipped to its first letter — users saw "M" and "E", not the // labels. The three assertions above all PASSED throughout, because // find.text matches a Text widget in the tree whether or not it is // visually clipped. Only measuring the laid-out width catches it. final anchoItem = tester.getSize( find.byType(PopupMenuItem).first, ); expect( anchoItem.width, greaterThan(100), reason: 'a menu item narrower than its label means the popup is being ' 'constrained and the text is clipped', ); }, ); testWidgets( '"Remove from favorites" still calls the same removal path as before', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstadoConFavoritos(); addTearDown(estado.dispose); await tester.pumpWidget(buildScreen(estado)); await pumpStable(tester); // Invoke the callback directly rather than opening the overlay and // tapping its rendered item by screen position: leaving that modal // route open while the tap it triggers removes and unmounts the // very row that anchors it is a real hang hazard, confirmed while // developing this test (multi-minute stall, same class of risk as // the project's other documented `pumpAndSettle` traps). final boton = tester.widget>( find.byType(PopupMenuButton).first, ); boton.onSelected!('remove'); await pumpStable(tester); expect(find.text('Station A'), findsNothing); }, ); }); group('visual fidelity (audit 4.2)', () { testWidgets('the active group chip is solid brand teal with dark text; ' 'inactive chips use listSurface (t4:219-221)', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstadoConFavoritos(); addTearDown(estado.dispose); await tester.pumpWidget(buildScreen(estado)); await pumpStable(tester); final activo = tester.widget( find.widgetWithText(ChoiceChip, 'All · 3'), ); expect(activo.selected, isTrue); expect(activo.selectedColor, const Color(0xFF21D4D9)); expect(activo.labelStyle?.color, const Color(0xFF062126)); final inactivo = tester.widget( find.widgetWithText(ChoiceChip, 'Rock · 2'), ); expect(inactivo.selected, isFalse); expect(inactivo.backgroundColor, const Color(0xFF102532)); }); }); group('Issue 3 (feedback-pruebas): spacing tiers', () { testWidgets('the header title sits at title-tier inset (20px) -- ' 'ReorderableListView.padding used to double up on top of ' "PluriRootHeader's own internal inset", (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstadoConFavoritos(); addTearDown(estado.dispose); final l10n = lookupAppLocalizations(const Locale('en')); await tester.pumpWidget(buildScreen(estado)); await pumpStable(tester); final titulo = find.descendant( of: find.byType(PluriRootHeader), matching: find.text(l10n.favoritesTitle), ); expect( tester.getTopLeft(titulo).dx, PluriLayout.titleHorizontal, reason: 'PluriRootHeader already supplies its own 20px inset; the ' 'previous ReorderableListView.padding of 16 doubled up on top ' 'of it, landing the title at 36px instead of 20px -- the ONE ' "root screen whose header didn't match Alarmas/Ajustes", ); }); testWidgets( 'the header sits at the SAME horizontal position whether the list is ' 'empty or populated -- two mutually-exclusive states of the same ' 'header must not read differently', (tester) async { setLargeSurface(tester); final l10n = lookupAppLocalizations(const Locale('en')); final vacio = await crearEstadoVacio(); addTearDown(vacio.dispose); await tester.pumpWidget(buildScreen(vacio)); await pumpStable(tester); final dxVacio = tester .getTopLeft( find.descendant( of: find.byType(PluriRootHeader), matching: find.text(l10n.favoritesTitle), ), ) .dx; _suppressListTileInkAssertion(); final conFavoritos = await crearEstadoConFavoritos(); addTearDown(conFavoritos.dispose); await tester.pumpWidget(buildScreen(conFavoritos)); await pumpStable(tester); final dxConFavoritos = tester .getTopLeft( find.descendant( of: find.byType(PluriRootHeader), matching: find.text(l10n.favoritesTitle), ), ) .dx; expect( dxConFavoritos, dxVacio, reason: 'the empty and populated branches of this screen must render ' 'the SAME header inset -- they previously did not (0 vs 16 ' 'extra px of list-level padding)', ); }, ); testWidgets( 'each favourite row uses row-tier horizontal inset (12), not the ' 'card-tier constant a background-less row was never meant to carry', (tester) async { setLargeSurface(tester); _suppressListTileInkAssertion(); final estado = await crearEstadoConFavoritos(); addTearDown(estado.dispose); await tester.pumpWidget(buildScreen(estado)); await pumpStable(tester); expect( tester.getTopLeft(find.byType(FilaEmisoraPlana).first).dx, PluriLayout.rowHorizontal, reason: 'audit 4.3: background-less rows are row tier (12), matching ' 'the same widget already fixed on Buscar -- not card tier ' '(16)', ); }, ); }); } void setLargeSurface(WidgetTester tester) { tester.view.physicalSize = const Size(1440, 3200); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); }