Files
pluriwave/test/widgets/pluri_bottom_navigation_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

290 lines
9.7 KiB
Dart

import 'dart:ui' show Tristate;
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/tema/pluriwave_theme.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'package:pluriwave/widgets/pluri_bottom_navigation.dart';
import 'package:pluriwave/widgets/pluri_icon.dart';
import 'package:pluriwave/widgets/pluri_layout.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
/// "Barra globo" bottom navigation — Design turn t4, option 4a. Spec
/// transcribed verbatim from `PluriWave Rediseno.dc.html` lines 91-99: a
/// solid capsule bar with a taller balloon capsule that slides beneath
/// whichever tab is active.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
const items = [
PluriNavItem(glyph: PluriIconGlyph.home, label: 'Escuchar'),
PluriNavItem(glyph: PluriIconGlyph.search, label: 'Buscar'),
PluriNavItem(glyph: PluriIconGlyph.favorites, label: 'Favoritos'),
PluriNavItem(glyph: PluriIconGlyph.alarm, label: 'Alarmas'),
PluriNavItem(glyph: PluriIconGlyph.settings, label: 'Ajustes'),
];
Widget hostFor(int selectedIndex, {ValueChanged<int>? onSelected}) {
return MaterialApp(
theme: PluriWaveTheme.dark(),
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SizedBox(
width: 390,
child: PluriBottomNavigation(
items: items,
selectedIndex: selectedIndex,
onSelected: onSelected ?? (_) {},
),
),
),
);
}
testWidgets('only the active tab renders a visible text label', (
tester,
) async {
await tester.pumpWidget(hostFor(1));
await tester.pump();
expect(find.text('Buscar'), findsOneWidget);
expect(find.text('Escuchar'), findsNothing);
expect(find.text('Favoritos'), findsNothing);
expect(find.text('Alarmas'), findsNothing);
expect(find.text('Ajustes'), findsNothing);
});
testWidgets('active icon and label use the brand colour (#21D4D9)', (
tester,
) async {
await tester.pumpWidget(hostFor(2));
await tester.pump();
final activeIcon = tester.widget<PluriIcon>(
find.descendant(
of: find.byKey(PluriBottomNavigation.itemKey(2)),
matching: find.byType(PluriIcon),
),
);
expect(activeIcon.color, PluriWaveTokens.brand);
final label = tester.widget<Text>(find.text('Favoritos'));
expect(label.style?.color, PluriWaveTokens.brand);
});
testWidgets('inactive icons are dimmed to 46% and carry no colour override', (
tester,
) async {
await tester.pumpWidget(hostFor(0));
await tester.pump();
final inactiveIcon = tester.widget<PluriIcon>(
find.descendant(
of: find.byKey(PluriBottomNavigation.itemKey(1)),
matching: find.byType(PluriIcon),
),
);
expect(inactiveIcon.color, isNull);
// AnimatedOpacity, not a plain Opacity: the dim now transitions with the
// balloon instead of snapping. Users reported the bar's contents
// teleporting while the balloon slid — the lift, dim, icon size and
// label all animate together now.
final dimmed = tester.widget<AnimatedOpacity>(
find.ancestor(
of: find.descendant(
of: find.byKey(PluriBottomNavigation.itemKey(1)),
matching: find.byType(PluriIcon),
),
matching: find.byType(AnimatedOpacity),
),
);
expect(dimmed.opacity, closeTo(0.46, 0.001));
});
testWidgets(
'the balloon exists, sized per spec, and centred over the active tab',
(tester) async {
await tester.pumpWidget(hostFor(0));
await tester.pump();
final balloon = find.byKey(PluriBottomNavigation.balloonKey);
expect(balloon, findsOneWidget);
expect(tester.getSize(balloon), const Size(110, 74));
final balloonCenterX = tester.getCenter(balloon).dx;
final tabCenterX =
tester.getCenter(find.byKey(PluriBottomNavigation.itemKey(0))).dx;
expect(balloonCenterX, closeTo(tabCenterX, 1));
final decoratedBox = tester.widget<DecoratedBox>(
find.descendant(of: balloon, matching: find.byType(DecoratedBox)),
);
final decoration = decoratedBox.decoration as BoxDecoration;
expect(decoration.color, PluriWaveTokens.dark.balloonSurface);
},
);
testWidgets('selection changes slide the balloon to the newly active tab', (
tester,
) async {
var selected = 0;
await tester.pumpWidget(
StatefulBuilder(
builder:
(context, setState) => MaterialApp(
theme: PluriWaveTheme.dark(),
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SizedBox(
width: 390,
child: PluriBottomNavigation(
items: items,
selectedIndex: selected,
onSelected: (i) => setState(() => selected = i),
),
),
),
),
),
);
await tester.pump();
final initialX =
tester.getCenter(find.byKey(PluriBottomNavigation.balloonKey)).dx;
await tester.tap(find.byKey(PluriBottomNavigation.itemKey(3)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 260));
final finalX =
tester.getCenter(find.byKey(PluriBottomNavigation.balloonKey)).dx;
expect(finalX, isNot(closeTo(initialX, 1)));
expect(
finalX,
closeTo(
tester.getCenter(find.byKey(PluriBottomNavigation.itemKey(3))).dx,
1,
),
);
});
testWidgets('inactive tabs keep their semantic label without visible text', (
tester,
) async {
final semantics = tester.ensureSemantics();
await tester.pumpWidget(hostFor(0));
await tester.pump();
final node = tester.getSemantics(
find.byKey(PluriBottomNavigation.itemKey(3)),
);
expect(node.label, 'Alarmas');
expect(node.flagsCollection.isButton, isTrue);
expect(node.flagsCollection.isSelected, Tristate.isFalse);
semantics.dispose();
});
testWidgets('every tab meets the 48x48dp minimum tap target', (tester) async {
await tester.pumpWidget(hostFor(0));
await tester.pump();
for (var i = 0; i < items.length; i++) {
final size = tester.getSize(find.byKey(PluriBottomNavigation.itemKey(i)));
expect(size.width, greaterThanOrEqualTo(48));
expect(size.height, greaterThanOrEqualTo(48));
}
});
testWidgets('PluriBottomNavigation.altura matches its real laid-out height', (
tester,
) async {
await tester.pumpWidget(hostFor(0));
await tester.pump();
final real = tester.getSize(find.byType(PluriBottomNavigation)).height;
expect(PluriBottomNavigation.altura, closeTo(real, 0.5));
});
testWidgets(
'PluriLayout.bottomChromeInset matches the real composed chrome height '
'(MiniReproductor + PluriBottomNavigation + the safe-area gap)',
(tester) async {
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await estado.reproducir(emisoraDemo(uuid: 'a', nombre: 'Station A'));
const chromeKey = Key('chromeSafeArea');
await tester.pumpWidget(
ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: MaterialApp(
theme: PluriWaveTheme.dark(),
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: const SizedBox.shrink(),
bottomNavigationBar: SafeArea(
key: chromeKey,
top: false,
minimum: const EdgeInsets.only(bottom: PluriLayout.compactGap),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Mirrors app.dart's real composition: item 22 / audit
// 3.6 made MiniReproductor full-bleed, so only
// PluriBottomNavigation keeps the 8px side margin.
const MiniReproductor(),
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 0),
child: PluriBottomNavigation(
items: items,
selectedIndex: 0,
onSelected: (_) {},
),
),
],
),
),
),
),
),
);
await tester.pump();
final medido = tester.getSize(find.byKey(chromeKey)).height;
expect(
PluriLayout.bottomChromeInset,
closeTo(medido, 4),
reason:
'bottomChromeInset must be derived from the real composed chrome '
'height (MiniReproductor + PluriBottomNavigation + the '
'safe-area gap), not guessed — if this fails, re-measure and '
'update the formula in pluri_layout.dart',
);
},
);
}