Files
pluriwave/test/estado/estado_entitlement_test.dart
T
FreeTLab 3449e2cb79
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m23s
fix: corregir ecualizador desincronizado, musica local en Android Auto y bloqueo del paywall
Tres fallos reportados en uso real, con sus causas raiz verificadas en codigo.

1. Ecualizador: el estado no tenia dueño unico

El handler arrancaba con `_ecualizadorActivo = true` a fuego. El valor
persistido solo llegaba por EstadoEcualizador.cargarPersistido(), alcanzable
unicamente desde el arbol de widgets, que un arranque headless de Android Auto
nunca construye. Resultado: el coche reproducia con el EQ forzado a ON mientras
disco e interfaz decian OFF.

Ahora registrarHandler siembra el flag desde disco en todos los motores y
setEcualizadorActivo persiste por su cuenta, asi que un toggle desde el coche o
la notificacion sobrevive sin EstadoEcualizador. _resincronizarConHandler pasa
a ser adopcion pura de interfaz.

Ademas mapearGananciaNativa enviaba 0 dB al punto MEDIO del rango nativo. Con
un getBandLevelRange() asimetrico, un preset plano metia varios dB de boost
real: la causa del "suena muy alto con el boton apagado". Reescrito para
escalar cada lado contra su propio extremo, de modo que 0 dB es siempre 0.

El dispatch de customAction no tenia ningun test. Se extrae decidirToggleEq y
se cubre contra el handler real. El efecto nativo se re-asierta al reactivarse
el reproductor, porque AudioEffect.setEnabled de just_audio es un no-op
mientras la plataforma esta desacoplada.

2. Musica Local no aparecia en el arbol de Android Auto

hayCarpetaConfigurada() consultaba MethodChannel('pluriwave/file_actions'),
registrado solo en MainActivity.configureFlutterEngine. Sin Activity no hay
handler, invokeMethod lanza MissingPluginException y el catch la confundia con
"permiso revocado", omitiendo el nodo. No dependia del entitlement.

La logica SAF sale a packages/pluriwave_file_actions, un paquete plugin local.
El motor headless que crea audio_service ejecuta GeneratedPluginRegistrant en
su constructor, asi que el canal queda registrado en ambos motores. Repuntar el
manifest a una subclase de AudioService no era viable: AudioServicePlugin
enlaza por ComponentName explicito y la app perderia el audio.

EstadoCarpetaLocal de tres valores separa "sin carpeta" de "canal no
disponible"; la raiz decide por la URI persistida y el subarbol muestra un item
explicativo en vez de una carpeta vacia. La invalidacion del arbol cacheado se
dispara al reanudar con el coche ya suscrito; el guardia anterior miraba
View.maybeOf, que bajo runApp siempre existe, por lo que se gastaba en el
arranque headless y no volvia a dispararse.

3. El paywall bloqueaba las compras

restorePurchases() de in_app_purchase_android emite siempre, y con lista vacia
si no hay nada que restaurar. El `if (compras.isEmpty) return;` se la tragaba,
noEncontrada nunca se emitia y la rama que limpia _compraEnCurso estaba muerta
en produccion. Como comprar y restaurar comparten ese flag, un usuario sin
compras que pulsaba restaurar se quedaba sin poder comprar.

Suite completa: 1366 pasan, 2 omitidos. 17 tests nuevos, todos nacidos rojos y
verificados por mutacion. flutter analyze mantiene los 5 avisos preexistentes.
2026-08-31 14:34:49 +02:00

354 lines
12 KiB
Dart

import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_entitlement.dart';
import 'package:pluriwave/servicios/servicio_compras.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Fake [PuertoCompras] (Design ADR-2): never touches `in_app_purchase`,
/// lets each test drive [emitir] to simulate the purchase stream.
class _PuertoComprasFalso implements PuertoCompras {
final _eventos = StreamController<EventoCompra>.broadcast();
int comprasIntentadas = 0;
int restaurosIntentados = 0;
@override
Stream<EventoCompra> get eventos => _eventos.stream;
@override
Future<void> comprar() async {
comprasIntentadas++;
}
@override
Future<void> restaurar() async {
restaurosIntentados++;
}
void emitir(EventoCompra evento) => _eventos.add(evento);
Future<void> dispose() => _eventos.close();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
group('EstadoEntitlement', () {
test('por defecto es free (sin flag persistida)', () async {
final estado = EstadoEntitlement(
prefs: await SharedPreferences.getInstance(),
);
addTearDown(estado.dispose);
await Future<void>.delayed(Duration.zero);
expect(estado.esPremium, isFalse);
});
test('carga premium desde una flag persistida previamente', () async {
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
final estado = EstadoEntitlement(
prefs: await SharedPreferences.getInstance(),
);
addTearDown(estado.dispose);
await Future<void>.delayed(Duration.zero);
expect(estado.esPremium, isTrue);
});
test('comprar() con éxito desbloquea premium y persiste', () async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
final prefs = await SharedPreferences.getInstance();
final estado = EstadoEntitlement(prefs: prefs, compras: compras);
addTearDown(estado.dispose);
await Future<void>.delayed(Duration.zero);
var notificaciones = 0;
estado.addListener(() => notificaciones++);
unawaited(estado.comprar());
await Future<void>.delayed(Duration.zero);
compras.emitir(const EventoCompra(TipoEventoCompra.comprada));
await Future<void>.delayed(Duration.zero);
expect(estado.esPremium, isTrue);
expect(estado.compraEnCurso, isFalse);
expect(compras.comprasIntentadas, 1);
expect(prefs.getBool('compra_premium_v1'), isTrue);
expect(notificaciones, greaterThan(0));
});
test('comprar() cancelada deja el tier free sin cargo', () async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
final estado = EstadoEntitlement(
prefs: await SharedPreferences.getInstance(),
compras: compras,
);
addTearDown(estado.dispose);
await Future<void>.delayed(Duration.zero);
unawaited(estado.comprar());
await Future<void>.delayed(Duration.zero);
compras.emitir(const EventoCompra(TipoEventoCompra.cancelada));
await Future<void>.delayed(Duration.zero);
expect(estado.esPremium, isFalse);
expect(estado.compraEnCurso, isFalse);
});
test(
'comprar() ya premium es idempotente: no reintenta la compra',
() async {
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
final estado = EstadoEntitlement(
prefs: await SharedPreferences.getInstance(),
compras: compras,
);
addTearDown(estado.dispose);
await Future<void>.delayed(Duration.zero);
await estado.comprar();
expect(compras.comprasIntentadas, 0);
expect(estado.esPremium, isTrue);
},
);
test('restaurar() encuentra una compra y desbloquea premium', () async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
final estado = EstadoEntitlement(
prefs: await SharedPreferences.getInstance(),
compras: compras,
);
addTearDown(estado.dispose);
await Future<void>.delayed(Duration.zero);
unawaited(estado.restaurar());
await Future<void>.delayed(Duration.zero);
compras.emitir(const EventoCompra(TipoEventoCompra.restaurada));
await Future<void>.delayed(Duration.zero);
expect(estado.esPremium, isTrue);
expect(compras.restaurosIntentados, 1);
});
test('restaurar() sin compra previa mantiene free sin error', () async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
final estado = EstadoEntitlement(
prefs: await SharedPreferences.getInstance(),
compras: compras,
);
addTearDown(estado.dispose);
await Future<void>.delayed(Duration.zero);
unawaited(estado.restaurar());
await Future<void>.delayed(Duration.zero);
compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada));
await Future<void>.delayed(Duration.zero);
expect(estado.esPremium, isFalse);
expect(estado.compraEnCurso, isFalse);
});
test('tras restaurar() sin compras el paywall NO queda bloqueado: se '
'puede volver a comprar', () async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
final estado = EstadoEntitlement(
prefs: await SharedPreferences.getInstance(),
compras: compras,
);
addTearDown(estado.dispose);
await Future<void>.delayed(Duration.zero);
unawaited(estado.restaurar());
await Future<void>.delayed(Duration.zero);
expect(estado.compraEnCurso, isTrue);
compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada));
await Future<void>.delayed(Duration.zero);
// `compraEnCurso` deshabilita AMBOS botones de `hoja_premium.dart`
// (comprar y restaurar): si se queda pegado en `true`, el usuario ya no
// puede pagar nunca más.
expect(estado.compraEnCurso, isFalse);
unawaited(estado.comprar());
await Future<void>.delayed(Duration.zero);
expect(compras.comprasIntentadas, 1);
});
test(
'un error en el flujo de compra no bloquea al pagador (fail-open)',
() async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
final estado = EstadoEntitlement(
prefs: await SharedPreferences.getInstance(),
compras: compras,
);
addTearDown(estado.dispose);
await Future<void>.delayed(Duration.zero);
unawaited(estado.comprar());
await Future<void>.delayed(Duration.zero);
compras.emitir(const EventoCompra(TipoEventoCompra.error));
await Future<void>.delayed(Duration.zero);
// Fail-open: un error NUNCA escribe `false` sobre una flag ya premium,
// y tampoco inventa un `true` para un usuario free.
expect(estado.esPremium, isFalse);
},
);
group('resultadoUsuario (FIX 3, code review)', () {
test(
'un error en el flujo de compra expone ResultadoEntitlementUsuario.error',
() async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
final estado = EstadoEntitlement(
prefs: await SharedPreferences.getInstance(),
compras: compras,
);
addTearDown(estado.dispose);
await Future<void>.delayed(Duration.zero);
expect(estado.resultadoUsuario, isNull);
unawaited(estado.comprar());
await Future<void>.delayed(Duration.zero);
compras.emitir(const EventoCompra(TipoEventoCompra.error));
await Future<void>.delayed(Duration.zero);
expect(estado.resultadoUsuario, ResultadoEntitlementUsuario.error);
},
);
test('restaurar() sin compra previa expone su propio resultado '
'(restauracionSinCompras), distinto de un error', () async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
final estado = EstadoEntitlement(
prefs: await SharedPreferences.getInstance(),
compras: compras,
);
addTearDown(estado.dispose);
await Future<void>.delayed(Duration.zero);
unawaited(estado.restaurar());
await Future<void>.delayed(Duration.zero);
compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada));
await Future<void>.delayed(Duration.zero);
expect(
estado.resultadoUsuario,
ResultadoEntitlementUsuario.restauracionSinCompras,
);
expect(
estado.resultadoUsuario,
isNot(ResultadoEntitlementUsuario.error),
);
});
test('consumirResultadoUsuario() limpia la señal y notifica a los '
'listeners', () async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
final estado = EstadoEntitlement(
prefs: await SharedPreferences.getInstance(),
compras: compras,
);
addTearDown(estado.dispose);
await Future<void>.delayed(Duration.zero);
unawaited(estado.comprar());
await Future<void>.delayed(Duration.zero);
compras.emitir(const EventoCompra(TipoEventoCompra.error));
await Future<void>.delayed(Duration.zero);
expect(estado.resultadoUsuario, isNotNull);
var notificaciones = 0;
estado.addListener(() => notificaciones++);
estado.consumirResultadoUsuario();
expect(estado.resultadoUsuario, isNull);
expect(notificaciones, greaterThan(0));
// También se limpia (probado por separado) el resultado de una
// restauración sin compras.
unawaited(estado.restaurar());
await Future<void>.delayed(Duration.zero);
compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada));
await Future<void>.delayed(Duration.zero);
expect(estado.resultadoUsuario, isNotNull);
estado.consumirResultadoUsuario();
expect(estado.resultadoUsuario, isNull);
});
test(
'nunca expone el texto interno/de desarrollador de EventoCompra.mensaje',
() async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
final estado = EstadoEntitlement(
prefs: await SharedPreferences.getInstance(),
compras: compras,
);
addTearDown(estado.dispose);
await Future<void>.delayed(Duration.zero);
unawaited(estado.comprar());
await Future<void>.delayed(Duration.zero);
compras.emitir(
const EventoCompra(
TipoEventoCompra.error,
mensaje: 'Producto no encontrado en Play Console',
),
);
await Future<void>.delayed(Duration.zero);
// resultadoUsuario es un enum tipado -- estructuralmente incapaz
// de filtrar el string interno de EventoCompra.mensaje hacia la
// UI.
expect(estado.resultadoUsuario, ResultadoEntitlementUsuario.error);
},
);
});
});
group('esPremiumPersistido (headless, sin BuildContext)', () {
test('lee la flag persistida directamente desde prefs', () async {
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
final prefs = await SharedPreferences.getInstance();
expect(await esPremiumPersistido(prefs: prefs), isTrue);
});
test('por defecto (sin flag) resuelve a free', () async {
final prefs = await SharedPreferences.getInstance();
expect(await esPremiumPersistido(prefs: prefs), isFalse);
});
test(
'resuelve sin prefs inyectadas (SharedPreferences.getInstance)',
() async {
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
expect(await esPremiumPersistido(), isTrue);
},
);
});
}