fix(iap): address code review defects in freemium/IAP change
Build & Deploy PluriWave / Análisis de código (push) Successful in 42s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 4m23s

Fixes 9 of 10 review findings (10th requires a manual Play Console
step, no code change):

1. app.dart/banner_anuncio_superior.dart: move the top SafeArea inside
   BannerAnuncioSuperior so it only reserves status-bar height when an
   ad actually renders, restoring edge-to-edge layout for premium and
   free-unloaded users.
2. servicio_anuncios.dart: bound every interstitial await (load,
   presentation, and the injected implementation itself) with
   injectable timeouts so a callback that never fires can no longer
   hang a caller.
3. estado_entitlement.dart/hoja_premium.dart: expose a typed
   resultadoUsuario signal for purchase/restore failures and
   restore-found-nothing, with dedicated localized messages
   (compraError, restauracionSinCompras) across all 13 locales --
   never the raw developer/exception string.
4. main.dart/servicio_consentimiento.dart: add a GDPR/UMP consent flow
   (ConsentInformation/ConsentForm) that gates Mobile Ads SDK init on
   canRequestAds(); premium users never see a consent form; failures
   degrade to no ads instead of crashing or blocking startup.
6. servicio_anuncios.dart: track real ad presentation
   (onAdShowedFullScreenContent) so a failed-to-show interstitial no
   longer consumes a session cap slot.
7. banner_anuncio_superior.dart: add an explicit load-attempted guard
   so repeated didChangeDependencies (e.g. entitlement notifyListeners
   during a purchase) can only ever trigger one banner load attempt.
8. servicio_anuncios.dart: make esPremium a required constructor
   parameter, matching the hardened contract already applied to
   EstadoAlarmas/EstadoGrabacion/EstadoRadio.
9. hoja_premium.dart: add a dedicated premiumActivo localized string
   instead of reusing the equalizer's equalizerActive translation,
   across all 13 locales.

All fixes implemented RED-first (failing test before production
code). Full suite: 1261 passed, 2 pre-existing skips, 0 failures.
flutter analyze: 5 pre-existing issues only, 0 new.

[version set]
This commit is contained in:
2026-08-12 16:10:46 +02:00
parent 94f354a7c1
commit 9cfa5ac17d
40 changed files with 1194 additions and 68 deletions
@@ -1,3 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_anuncios.dart';
@@ -10,10 +13,12 @@ void main() {
required DateTime Function() ahora,
required bool premium,
Future<bool> Function()? mostrarInterstitialImpl,
Duration? timeoutIntentoInterstitial,
}) => ServicioAnuncios(
ahora: ahora,
esPremium: () => premium,
mostrarInterstitialImpl: mostrarInterstitialImpl ?? (() async => true),
timeoutIntentoInterstitial: timeoutIntentoInterstitial,
);
group('intentarInterstitial — cap de frecuencia', () {
@@ -87,6 +92,75 @@ void main() {
);
});
group('intentarInterstitial — fallo al mostrar no consume el cupo', () {
test('la implementación real de AdMob distingue presentación real de '
'fallo de renderizado (FIX 6, code review): '
'onAdShowedFullScreenContent debe estar instrumentado y el resultado '
'de show() no puede ser un true incondicional', () {
final source =
File('lib/servicios/servicio_anuncios.dart').readAsStringSync();
expect(
source.contains('onAdShowedFullScreenContent'),
isTrue,
reason:
'debe registrar si el anuncio realmente llegó a presentarse '
'(onAdShowedFullScreenContent) para no devolver true tras un '
'onAdFailedToShowFullScreenContent',
);
expect(
source.contains('await cierreCompleter.future;\n return true;'),
isFalse,
reason:
'el resultado de _mostrarInterstitialAdMob ya no puede ser un '
'true incondicional tras el cierre -- debe reflejar si el '
'anuncio realmente se presentó',
);
});
});
group('intentarInterstitial — timeout acotado (FIX 2, code review)', () {
test('una implementación que nunca completa resuelve false dentro del '
'timeout inyectado y no consume el cupo', () async {
final ahora = DateTime(2026, 1, 1, 10, 0);
final nuncaCompleta = Completer<bool>();
addTearDown(() {
if (!nuncaCompleta.isCompleted) nuncaCompleta.complete(false);
});
final servicio = construir(
ahora: () => ahora,
premium: false,
mostrarInterstitialImpl: () => nuncaCompleta.future,
timeoutIntentoInterstitial: const Duration(milliseconds: 20),
);
final resultado = await servicio.intentarInterstitial().timeout(
const Duration(seconds: 2),
);
expect(resultado, isFalse);
// El cupo no se consumió: un intento posterior con una
// implementación que SÍ resuelve sigue mostrando el anuncio.
final servicioReal = construir(ahora: () => ahora, premium: false);
expect(await servicioReal.intentarInterstitial(), isTrue);
});
test('una implementación lenta pero que SÍ completa dentro del timeout '
'sigue resolviendo con su resultado real', () async {
final ahora = DateTime(2026, 1, 1, 10, 0);
final servicio = construir(
ahora: () => ahora,
premium: false,
mostrarInterstitialImpl: () async {
await Future<void>.delayed(const Duration(milliseconds: 5));
return true;
},
timeoutIntentoInterstitial: const Duration(milliseconds: 200),
);
expect(await servicio.intentarInterstitial(), isTrue);
});
});
group('debeMostrarBanner', () {
test('free: true', () {
final servicio = construir(