Files
pluriwave/test/widgets/hoja_premium_test.dart
T
FreeTLab a2bed18937 fix(paywall): add dismiss controls and honest premium copy
The premium sheet had no close affordance or way to defer, and its
copy only said "Función Premium" without stating what it unlocks.
Adds a header close (X) button and a "not now" secondary action so
dismissal is never harder than purchasing, and replaces the bare
title with a concrete, honest breakdown of the 5 things premium
unlocks (no ads, Android Auto, station recording, alarm vacation
ranges, unlimited alarms) plus the one-time-purchase framing. The
phone equalizer is never listed, since it stays free for everyone.
New l10n keys added to all 13 locales.
2026-08-28 22:47:53 +02:00

302 lines
10 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_entitlement.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/servicios/servicio_compras.dart';
import 'package:pluriwave/widgets/hoja_premium.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// fix/import-alarmas-y-paywall: a purchase sheet the user cannot escape is
/// a dark pattern and a Play policy risk. These cover the dismiss
/// affordance, the honest/concrete feature list, and the equalizer guard
/// (the phone equalizer is free for everyone and must never be presented as
/// a premium feature — this has regressed conceptually before).
/// Fake [PuertoCompras] (mirrors `app_test.dart`'s own fake): lets a test
/// drive [EstadoEntitlement]'s purchase-stream events without touching
/// `in_app_purchase`.
class _PuertoComprasFalso implements PuertoCompras {
final _eventos = StreamController<EventoCompra>.broadcast();
@override
Stream<EventoCompra> get eventos => _eventos.stream;
@override
Future<void> comprar() async {}
@override
Future<void> restaurar() async {}
void emitir(EventoCompra evento) => _eventos.add(evento);
Future<void> dispose() => _eventos.close();
}
/// FIX 3 / FIX 9 (code review): the paywall must show localized feedback for
/// a failed purchase/restore, a distinct non-error confirmation when a
/// restore finds nothing, and its own dedicated "premium active" string
/// instead of reusing the equalizer's `equalizerActive` translation.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
late AppLocalizations l10n;
Future<EstadoEntitlement> bombear(
WidgetTester tester, {
required _PuertoComprasFalso compras,
}) async {
late EstadoEntitlement estado;
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoEntitlement>(
create: (_) {
estado = EstadoEntitlement(prefs: null, compras: compras);
return estado;
},
),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: HojaPremium()),
),
),
);
await tester.pump();
l10n = AppLocalizations.of(tester.element(find.byType(HojaPremium)));
return estado;
}
/// Presents `HojaPremium` through the REAL `mostrarHojaPremium` modal
/// route (unlike [bombear], which embeds it directly with no route to
/// pop) so the dismiss controls can be exercised end-to-end exactly as a
/// user would encounter them.
Future<void> bombearComoHoja(
WidgetTester tester, {
required _PuertoComprasFalso compras,
}) async {
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null, compras: compras),
),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder:
(context) => Scaffold(
body: TextButton(
onPressed: () => mostrarHojaPremium(context),
child: const Text('abrir'),
),
),
),
),
),
);
await tester.pump();
await tester.tap(find.text('abrir'));
await tester.pumpAndSettle();
l10n = AppLocalizations.of(tester.element(find.byType(HojaPremium)));
}
group('fix/import-alarmas-y-paywall — dismissibility', () {
testWidgets('the close (X) control closes the sheet without purchasing or '
'restoring', (tester) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombearComoHoja(tester, compras: compras);
expect(find.byType(HojaPremium), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('hoja-premium-cerrar')));
await tester.pumpAndSettle();
expect(find.byType(HojaPremium), findsNothing);
});
testWidgets(
'the "not now" secondary action closes the sheet without purchasing '
'or restoring',
(tester) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombearComoHoja(tester, compras: compras);
expect(find.byType(HojaPremium), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('hoja-premium-ahora-no')));
await tester.pumpAndSettle();
expect(find.byType(HojaPremium), findsNothing);
},
);
testWidgets('the system back gesture also closes the sheet (default '
'isDismissible/enableDrag, no PopScope blocking it)', (tester) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombearComoHoja(tester, compras: compras);
expect(find.byType(HojaPremium), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byType(HojaPremium), findsNothing);
});
testWidgets(
'an already-premium user still gets the close control (no decline '
'needed) and no "not now" button',
(tester) async {
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombearComoHoja(tester, compras: compras);
expect(
find.byKey(const ValueKey('hoja-premium-cerrar')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('hoja-premium-ahora-no')),
findsNothing,
);
},
);
});
group('fix/import-alarmas-y-paywall — honest, concrete copy', () {
testWidgets('lists the 5 features premium actually unlocks', (
tester,
) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombear(tester, compras: compras);
expect(find.text(l10n.premiumBeneficioSinAnuncios), findsOneWidget);
expect(find.text(l10n.premiumBeneficioAndroidAuto), findsOneWidget);
expect(find.text(l10n.premiumBeneficioGrabacion), findsOneWidget);
expect(find.text(l10n.premiumBeneficioVacaciones), findsOneWidget);
expect(find.text(l10n.premiumBeneficioAlarmasIlimitadas), findsOneWidget);
});
testWidgets('states this is a one-time purchase, not a subscription', (
tester,
) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombear(tester, compras: compras);
expect(find.text(l10n.premiumPagoUnico), findsOneWidget);
});
testWidgets(
'GUARD: the phone equalizer is never presented as a premium feature',
(tester) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombear(tester, compras: compras);
expect(find.text(l10n.equalizerTitle), findsNothing);
expect(find.text(l10n.equalizerActive), findsNothing);
expect(find.textContaining('cualizador'), findsNothing);
expect(find.textContaining('qualizer'), findsNothing);
},
);
});
group(
'FIX 9 — la etiqueta de premium activo es propia, no la del ecualizador',
() {
testWidgets(
'usuario premium: muestra l10n.premiumActivo, nunca l10n.equalizerActive',
(tester) async {
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombear(tester, compras: compras);
await tester.pump();
expect(find.text(l10n.premiumActivo), findsOneWidget);
expect(find.text(l10n.equalizerActive), findsNothing);
},
);
},
);
group('FIX 3 — feedback de error/restauración en el paywall', () {
testWidgets('un error de compra muestra el mensaje localizado genérico '
'(l10n.compraError), nunca el texto interno de EventoCompra.mensaje', (
tester,
) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombear(tester, compras: compras);
compras.emitir(
const EventoCompra(
TipoEventoCompra.error,
mensaje: 'Producto no encontrado en Play Console',
),
);
await tester.pump();
await tester.pump();
expect(find.text(l10n.compraError), findsOneWidget);
expect(find.text('Producto no encontrado en Play Console'), findsNothing);
});
testWidgets('una restauración sin compras muestra su propia confirmación '
'(l10n.restauracionSinCompras), distinta del mensaje de error', (
tester,
) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombear(tester, compras: compras);
compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada));
await tester.pump();
await tester.pump();
expect(find.text(l10n.restauracionSinCompras), findsOneWidget);
expect(find.text(l10n.compraError), findsNothing);
});
testWidgets(
'descartar el mensaje de error llama a consumirResultadoUsuario() y '
'lo oculta de la UI',
(tester) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
final estado = await bombear(tester, compras: compras);
compras.emitir(const EventoCompra(TipoEventoCompra.error));
await tester.pump();
await tester.pump();
expect(find.text(l10n.compraError), findsOneWidget);
await tester.tap(
find.byKey(const ValueKey('hoja-premium-resultado-descartar')),
);
await tester.pump();
expect(find.text(l10n.compraError), findsNothing);
expect(estado.resultadoUsuario, isNull);
},
);
});
}