Files
pluriwave/test/pantallas/pantalla_favoritos_test.dart
FreeTLab d81fabbe27 refactor(iap): make esPremium a required constructor parameter
EstadoAlarmas, EstadoGrabacion and EstadoRadio defaulted `esPremium` to
`() => true`, so any construction site that forgot to wire entitlement
compiled fine and silently ran ungated — failing OPEN to premium and
disabling the paywall with no test able to catch it.

The parameter is now required with no default. Production wiring in
app.dart was already correct and is unchanged; the 184 pre-existing test
call sites now pass `() => true` explicitly, which is exactly the old
implicit default, so every assertion is untouched.

EstadoRadio has no gate of its own but constructs EstadoGrabacion, so it
inherits the same contract.

The one test that existed to pin the old default is renamed to describe
what it still covers (the premium path through iniciar() with no
duracion); its assertions are unchanged.
2026-08-10 22:06:36 +02:00

666 lines
24 KiB
Dart

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<File> archivoCustomVacio() async => File(
'${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json',
);
Future<EstadoRadio> 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<EstadoRadio> 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<EstadoRadio>.value(value: estado),
Provider<ServicioAnuncios>(
create: (_) => ServicioAnuncios(esPremium: () => true),
),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: PantallaFavoritos()),
),
);
}
Future<void> 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<Padding>(
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<Icon>(find.byIcon(Icons.add_rounded));
expect(icon.size, 20);
final l10n = lookupAppLocalizations(const Locale('en'));
final texto = tester.widget<Text>(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<ReorderableListView>(
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<ReorderableListView>(
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<String>), 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<String>).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<PopupMenuButton<String>>(
find.byType(PopupMenuButton<String>).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<ChoiceChip>(
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<ChoiceChip>(
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);
}