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/servicios/servicio_anuncios.dart'; import 'package:pluriwave/servicios/servicio_compras.dart'; import 'package:pluriwave/widgets/banner_anuncio_superior.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// Fake [PuertoCompras] (mirrors `app_test.dart`'s own fake): lets a test /// drive [EstadoEntitlement]'s purchase-stream `notifyListeners()` calls /// 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(); } /// Ad-display spec "Persistent Top Banner, Never Overlapping Content": the /// banner is entitlement-aware and reserves layout via a `Column` /// (`SizedBox.shrink()` collapses it to zero footprint) — never a `Stack` /// overlay. AdMob's own `BannerAd.load()` cannot reach a real ad server in /// `flutter test` (no plugin channel registered), so it always resolves to /// "unloaded" here — exactly the same degrade-to-shrink path a genuine /// failed load takes in production (never a crash, never a placeholder). void main() { setUp(() { SharedPreferences.setMockInitialValues({}); }); Widget construir({required bool premium}) { return MultiProvider( providers: [ ChangeNotifierProvider( create: (_) { final estado = EstadoEntitlement(prefs: null); return estado; }, ), Provider( create: (_) => ServicioAnuncios(esPremium: () => premium), ), ], child: MaterialApp( home: Scaffold( body: Column( children: [ const BannerAnuncioSuperior(), const Expanded(child: Center(child: Text('contenido'))), ], ), ), ), ); } testWidgets( 'usuario free sin anuncio cargado: colapsa a SizedBox.shrink (nunca overlay)', (tester) async { await tester.pumpWidget(construir(premium: false)); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); final banner = find.byType(BannerAnuncioSuperior); expect(banner, findsOneWidget); // Collapsed to zero footprint (no ad ever loads in a widget test — no // AdMob plugin channel registered) — a `SizedBox.shrink()`, not an // overlay: the Column layout below it stays fully visible. expect(tester.getSize(banner).height, 0); expect(find.text('contenido'), findsOneWidget); }, ); testWidgets('usuario premium: nunca intenta mostrar el banner', ( tester, ) async { await tester.pumpWidget(construir(premium: true)); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); final banner = find.byType(BannerAnuncioSuperior); expect(tester.getSize(banner).height, 0); expect(find.text('contenido'), findsOneWidget); }); testWidgets('didChangeDependencies repetido (ej. notifyListeners de ' 'EstadoEntitlement durante una compra en curso) dispara como mucho UN ' 'intento de carga real (FIX 7, code review)', (tester) async { final compras = _PuertoComprasFalso(); addTearDown(compras.dispose); var intentosDeCarga = 0; await tester.pumpWidget( MultiProvider( providers: [ ChangeNotifierProvider( create: (_) => EstadoEntitlement(prefs: null, compras: compras), ), Provider( create: (_) => ServicioAnuncios(esPremium: () => false), ), ], child: MaterialApp( home: Scaffold( body: Column( children: [ BannerAnuncioSuperior( alIntentarCargar: () => intentosDeCarga++, ), const Expanded(child: Center(child: Text('contenido'))), ], ), ), ), ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); expect( intentosDeCarga, 1, reason: 'el primer didChangeDependencies debe intentar UNA carga', ); // Cada uno de estos eventos hace que EstadoEntitlement llame a // notifyListeners() -- y como BannerAnuncioSuperior.build() hace // context.watch(), cada notificación vuelve a // ejecutar didChangeDependencies() en este widget. compras.emitir(const EventoCompra(TipoEventoCompra.pendiente)); await tester.pump(); compras.emitir(const EventoCompra(TipoEventoCompra.cancelada)); await tester.pump(); compras.emitir(const EventoCompra(TipoEventoCompra.pendiente)); await tester.pump(); compras.emitir(const EventoCompra(TipoEventoCompra.error)); await tester.pump(); expect( intentosDeCarga, 1, reason: 'cuatro notificaciones adicionales de EstadoEntitlement NO ' 'deben disparar cuatro cargas más -- solo la primera cuenta', ); }); }