Files
pluriwave/test/pantallas/pantalla_favoritos_test.dart
T
FreeTLab aa0b242374 feat(iap): add freemium unlock via one-time in-app purchase
Adds a permanent, non-consumable premium unlock (EstadoEntitlement +
PuertoCompras/ServicioComprasPlayBilling) that removes ads and unlocks
alarm vacations, alarms past a 5-alarm free cap, recording start, and
full Android Auto browsing. The phone equalizer stays free for everyone.

- Entitlement is prefs-backed (compra_premium_v1), fail-open, and
  resolvable headlessly via esPremiumPersistido() for the Android Auto
  audio handler, which registers before runApp.
- Android Auto reduced mode keeps the real root folder labels for free
  users; browsing into any of them (and playFromMediaId/playFromSearch/
  skipToNext/skipToPrevious) is blocked at the getChildren/servicio_audio
  choke points, with a locked "Función Premium" item as the backstop.
  Current-station play/pause/stop stays untouched. A free -> premium
  transition actively invalidates the head unit's cached browse tree.
- Ads (top banner + capped interstitial before adding a station or an
  alarm) are gated behind entitlement via ServicioAnuncios, using
  official Google test ad unit IDs pending AdMob provisioning.
- Alarm cap UX shows an explanatory message with a secondary unlock
  action rather than a bare paywall jump; existing data is grandfathered.
- 4 new localization keys translated across all 13 supported locales.

Co-located tests use strict TDD (RED test before implementation) for
every new pure-logic unit; full existing suite passes unchanged.
2026-08-10 20:37:07 +02:00

665 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(
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);
}