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.
665 lines
24 KiB
Dart
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);
|
|
}
|