import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:in_app_purchase/in_app_purchase.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.broadcast(); @override Stream get eventos => _eventos.stream; @override Future comprar() async {} @override Future restaurar() async {} void emitir(EventoCompra evento) => _eventos.add(evento); Future dispose() => _eventos.close(); } /// Fake [InAppPurchase]: permite montar el paywall sobre el /// `ServicioComprasPlayBilling` REAL (no un `PuertoCompras` falso) para cubrir /// el bloqueo del paywall de extremo a extremo. `noSuchMethod` cubre el resto /// de la API del plugin, que este test no ejercita. class _InAppPurchaseFalso implements InAppPurchase { final _compras = StreamController>.broadcast(); @override Stream> get purchaseStream => _compras.stream; @override Future restorePurchases({String? applicationUserName}) async { // Play Billing publica un lote VACÍO cuando no hay nada que restaurar. _compras.add(const []); } Future dispose() => _compras.close(); @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } /// 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 bombear( WidgetTester tester, { required _PuertoComprasFalso compras, }) async { late EstadoEntitlement estado; await tester.pumpWidget( MultiProvider( providers: [ ChangeNotifierProvider( 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 bombearComoHoja( WidgetTester tester, { required _PuertoComprasFalso compras, }) async { await tester.pumpWidget( MultiProvider( providers: [ ChangeNotifierProvider( 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); }, ); }); group('bloqueo del paywall — "Restaurar compras" sin compras previas', () { testWidgets('extremo a extremo (ServicioComprasPlayBilling real): un lote ' 'vacío reactiva AMBOS botones, comprar y restaurar', (tester) async { final iap = _InAppPurchaseFalso(); addTearDown(iap.dispose); final servicio = ServicioComprasPlayBilling(inAppPurchase: iap); addTearDown(servicio.dispose); await tester.pumpWidget( MultiProvider( providers: [ ChangeNotifierProvider( create: (_) => EstadoEntitlement(prefs: null, compras: servicio), ), ], 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))); final restaurar = find.byKey(const ValueKey('hoja-premium-restaurar')); final comprar = find.byKey(const ValueKey('hoja-premium-comprar')); await tester.tap(restaurar); await tester.pump(); await tester.pump(); // El botón sigue vivo: si `noEncontrada` nunca llega, `compraEnCurso` // se queda en `true` y el usuario no puede pagar nunca más. expect( tester.widget(restaurar).onPressed, isNotNull, reason: 'restaurar debe volver a estar habilitado', ); expect( tester.widget(comprar).onPressed, isNotNull, reason: 'comprar debe volver a estar habilitado', ); expect(find.text(l10n.restauracionSinCompras), findsOneWidget); }); }); }