EstadoAlarmas, EstadoGrabacion and EstadoRadio defaulted `esPremium` to `() => true`, so any construction site that forgot to wire entitlement compiled fine and silently ran ungated — failing OPEN to premium and disabling the paywall with no test able to catch it. The parameter is now required with no default. Production wiring in app.dart was already correct and is unchanged; the 184 pre-existing test call sites now pass `() => true` explicitly, which is exactly the old implicit default, so every assertion is untouched. EstadoRadio has no gate of its own but constructs EstadoGrabacion, so it inherits the same contract. The one test that existed to pin the old default is renamed to describe what it still covers (the premium path through iniciar() with no duracion); its assertions are unchanged.
363 lines
14 KiB
Dart
363 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:pluriwave/estado/estado_ecualizador.dart';
|
|
import 'package:pluriwave/estado/estado_entitlement.dart';
|
|
import 'package:pluriwave/estado/estado_grabacion.dart';
|
|
import 'package:pluriwave/estado/estado_idioma.dart';
|
|
import 'package:pluriwave/estado/estado_radio.dart';
|
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
|
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
|
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
|
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../helpers/fakes.dart';
|
|
|
|
// Pre-existing project constraint: PluriGlassSurface (a glassmorphism
|
|
// DecoratedBox) is used as the card-style container throughout PantallaAjustes.
|
|
// Flutter asserts that ListTile's ink is visible, but the assertion fires
|
|
// as a warning (not a correctness bug) — ink animations are simply not visible
|
|
// behind the backdrop-filter blur in production either. We suppress it here so
|
|
// functional tests can run against the existing UI structure.
|
|
void _suppressListTileInkAssertion() {
|
|
final original = FlutterError.onError;
|
|
FlutterError.onError = (details) {
|
|
if (details.exceptionAsString().contains(
|
|
'ListTile background color or ink splashes may be invisible',
|
|
)) {
|
|
return;
|
|
}
|
|
original?.call(details);
|
|
};
|
|
addTearDown(() => FlutterError.onError = original);
|
|
}
|
|
|
|
void main() {
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues({});
|
|
});
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
Widget buildAjustes(EstadoRadio estado, {EstadoIdioma? idioma}) {
|
|
final estadoIdioma = idioma ?? EstadoIdioma();
|
|
return MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
|
|
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
|
|
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
|
ChangeNotifierProvider<EstadoIdioma>.value(value: estadoIdioma),
|
|
ChangeNotifierProvider<EstadoEntitlement>(
|
|
create: (_) => EstadoEntitlement(prefs: null),
|
|
),
|
|
],
|
|
child: MaterialApp(
|
|
locale: const Locale('en'),
|
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
home: const Scaffold(body: PantallaAjustes()),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<EstadoRadio> crearEstado() async {
|
|
final estado = EstadoRadio(
|
|
esPremium: () => true,
|
|
audio: FakeServicioAudio(),
|
|
favoritos: FakeServicioFavoritos(),
|
|
radio: FakeServicioRadio(),
|
|
servicioEcualizador: FakeServicioEcualizador(),
|
|
servicioGrabacion: _FakeGrabacion(),
|
|
resolverArchivoCustom: _archivoCustomVacio,
|
|
iniciarAutomaticamente: false,
|
|
);
|
|
await estado.ecualizador.cargarPersistido();
|
|
return estado;
|
|
}
|
|
|
|
void setLargeSurface(WidgetTester tester) {
|
|
tester.view.physicalSize = const Size(1440, 3200);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(tester.view.resetPhysicalSize);
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
}
|
|
|
|
Future<void> pumpStable(WidgetTester tester) async {
|
|
await tester.pump();
|
|
await tester.pumpAndSettle(const Duration(milliseconds: 100));
|
|
}
|
|
|
|
// ── WU3a: AUDIO + EMISORAS become grouped nav rows ─────────────────────────
|
|
//
|
|
// Design ADR-3: the root carries zero inline controls for the 7 sections
|
|
// WU3a moved (Ecualizador, Salida de audio, Temporizador de sueño, Grupos
|
|
// de favoritos, Emisora preferida, Emisoras personalizadas, Orden de
|
|
// listas) — each is reached through a FilaAjuste row instead. WU3b (below)
|
|
// completes the remaining 5 sections, so the root is now exactly 4
|
|
// GrupoAjustes cards, under 400 lines.
|
|
group('WU3a — AUDIO and EMISORAS groups', () {
|
|
testWidgets('AUDIO group renders exactly 3 nav rows, no inline controls', (
|
|
tester,
|
|
) async {
|
|
setLargeSurface(tester);
|
|
_suppressListTileInkAssertion();
|
|
final estado = await crearEstado();
|
|
addTearDown(estado.dispose);
|
|
|
|
await tester.pumpWidget(buildAjustes(estado));
|
|
await pumpStable(tester);
|
|
|
|
expect(find.text('AUDIO'), findsOneWidget);
|
|
expect(find.text('Equalizer'), findsOneWidget);
|
|
expect(find.text('Advanced Equalization Options'), findsOneWidget);
|
|
expect(find.text('Sleep timer'), findsOneWidget);
|
|
|
|
// Zero inline controls: the old always-visible enable switch and
|
|
// device-management toggle are gone from the root.
|
|
expect(find.text('Enable equalizer'), findsNothing);
|
|
expect(find.text('Enable per-device EQ'), findsNothing);
|
|
});
|
|
|
|
testWidgets(
|
|
'STATIONS group renders exactly 4 nav rows, no inline controls',
|
|
(tester) async {
|
|
setLargeSurface(tester);
|
|
_suppressListTileInkAssertion();
|
|
final estado = await crearEstado();
|
|
addTearDown(estado.dispose);
|
|
|
|
await tester.pumpWidget(buildAjustes(estado));
|
|
await pumpStable(tester);
|
|
|
|
expect(find.text('STATIONS'), findsOneWidget);
|
|
expect(find.text('Favorite lists'), findsOneWidget);
|
|
expect(find.text('Preferred station'), findsOneWidget);
|
|
expect(find.text('Custom stations'), findsOneWidget);
|
|
expect(find.text('Station order'), findsOneWidget);
|
|
|
|
// Zero inline controls: the descriptive body copy that used to sit
|
|
// directly under each header is gone from the root now. (A
|
|
// DropdownButtonFormField still exists on the page — Idioma's
|
|
// language picker, a section WU3b converts, not WU3a.)
|
|
expect(
|
|
find.text(
|
|
'Preselected for new alarms and available for quick playback.',
|
|
),
|
|
findsNothing,
|
|
);
|
|
},
|
|
);
|
|
|
|
testWidgets('tapping the Ecualizador row pushes its detail screen', (
|
|
tester,
|
|
) async {
|
|
setLargeSurface(tester);
|
|
_suppressListTileInkAssertion();
|
|
final estado = await crearEstado();
|
|
addTearDown(estado.dispose);
|
|
|
|
await tester.pumpWidget(buildAjustes(estado));
|
|
await pumpStable(tester);
|
|
|
|
await tester.tap(find.text('Equalizer'));
|
|
await pumpStable(tester);
|
|
|
|
// Pushed, not index-switched: exactly one PluriPushScaffold now exists,
|
|
// and its moved control (the enable switch, audit 11.1 -- now a
|
|
// header action, t4 line 566) is reachable.
|
|
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
|
expect(find.byKey(const ValueKey('eq-master-switch')), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('tapping the Orden de listas row pushes its detail screen', (
|
|
tester,
|
|
) async {
|
|
setLargeSurface(tester);
|
|
_suppressListTileInkAssertion();
|
|
final estado = await crearEstado();
|
|
addTearDown(estado.dispose);
|
|
|
|
await tester.pumpWidget(buildAjustes(estado));
|
|
await pumpStable(tester);
|
|
|
|
await tester.tap(find.text('Station order'));
|
|
await pumpStable(tester);
|
|
|
|
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
|
expect(find.text('By name'), findsOneWidget);
|
|
expect(find.text('By quality'), findsOneWidget);
|
|
});
|
|
});
|
|
|
|
// ── WU3b: GRABACIONES Y MÚSICA + APLICACIÓN become grouped nav rows ───────
|
|
//
|
|
// Design ADR-3: the root now carries zero inline controls for the final 5
|
|
// sections (Grabaciones, Música local, Idioma, Backup, Info) — each is
|
|
// reached through a FilaAjuste row, matching WU3a's AUDIO/EMISORAS
|
|
// treatment. All 12 sections are decomposed now; the root is exactly 4
|
|
// GrupoAjustes cards and crosses under 400 lines (tasks.md 3b.5). The two
|
|
// "Phase 7" friendly-folder-name scenarios that used to live in this file
|
|
// are relocated verbatim to `ajustes/pantalla_ajustes_musica_local_test.dart`,
|
|
// now targeting the isolated pushed screen directly.
|
|
group('WU3b — GRABACIONES Y MÚSICA and APLICACIÓN groups', () {
|
|
testWidgets(
|
|
'RECORDINGS & MUSIC group renders exactly 2 nav rows, no inline '
|
|
'controls',
|
|
(tester) async {
|
|
setLargeSurface(tester);
|
|
_suppressListTileInkAssertion();
|
|
final estado = await crearEstado();
|
|
addTearDown(estado.dispose);
|
|
|
|
await tester.pumpWidget(buildAjustes(estado));
|
|
await pumpStable(tester);
|
|
|
|
expect(find.text('RECORDINGS & MUSIC'), findsOneWidget);
|
|
expect(find.text('Recordings'), findsOneWidget);
|
|
expect(find.text('Local music (Android Auto)'), findsOneWidget);
|
|
|
|
// Zero inline controls: the folder-path row, path action buttons and
|
|
// max-size row are gone from the root now.
|
|
expect(find.text('Change path'), findsNothing);
|
|
expect(find.text('Maximum recording size'), findsNothing);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'APPLICATION group renders exactly 3 nav rows, no inline controls',
|
|
(tester) async {
|
|
setLargeSurface(tester);
|
|
_suppressListTileInkAssertion();
|
|
final estado = await crearEstado();
|
|
addTearDown(estado.dispose);
|
|
|
|
await tester.pumpWidget(buildAjustes(estado));
|
|
await pumpStable(tester);
|
|
|
|
expect(find.text('APPLICATION'), findsOneWidget);
|
|
// "Language" now renders exactly once at the root (the FilaAjuste
|
|
// row only) — the old inline dropdown's duplicate label is gone.
|
|
expect(find.text('Language'), findsOneWidget);
|
|
expect(find.text('Backup'), findsOneWidget);
|
|
expect(find.text('Info'), findsOneWidget);
|
|
|
|
// Zero inline controls: the language dropdown and export/import rows
|
|
// are gone from the root now.
|
|
expect(find.byType(DropdownButtonFormField<String>), findsNothing);
|
|
expect(find.text('Export configuration'), findsNothing);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'tapping the Grabaciones row pushes the recordings LIBRARY, not the '
|
|
'folder/size settings form (WU15b — the approved mockup screen '
|
|
'"Ajustes > Grabaciones" depicts the library)',
|
|
(tester) async {
|
|
setLargeSurface(tester);
|
|
_suppressListTileInkAssertion();
|
|
final estado = await crearEstado();
|
|
addTearDown(estado.dispose);
|
|
|
|
await tester.pumpWidget(buildAjustes(estado));
|
|
await pumpStable(tester);
|
|
|
|
await tester.tap(find.text('Recordings'));
|
|
await pumpStable(tester);
|
|
|
|
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
|
expect(find.text('My recordings'), findsOneWidget);
|
|
// The folder/size settings form is reachable FROM the library now,
|
|
// not directly from the Settings root row — see
|
|
// pantalla_grabaciones_test.dart for that entry point.
|
|
expect(find.text('Change path'), findsNothing);
|
|
},
|
|
);
|
|
|
|
testWidgets('tapping the Info row pushes its detail screen', (
|
|
tester,
|
|
) async {
|
|
setLargeSurface(tester);
|
|
_suppressListTileInkAssertion();
|
|
final estado = await crearEstado();
|
|
addTearDown(estado.dispose);
|
|
|
|
await tester.pumpWidget(buildAjustes(estado));
|
|
await pumpStable(tester);
|
|
|
|
await tester.tap(find.text('Info'));
|
|
await pumpStable(tester);
|
|
|
|
expect(find.byType(PluriPushScaffold), findsOneWidget);
|
|
expect(find.text('Help and tutorial'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('root now contains exactly 4 GrupoAjustes cards', (
|
|
tester,
|
|
) async {
|
|
setLargeSurface(tester);
|
|
_suppressListTileInkAssertion();
|
|
final estado = await crearEstado();
|
|
addTearDown(estado.dispose);
|
|
|
|
await tester.pumpWidget(buildAjustes(estado));
|
|
await pumpStable(tester);
|
|
|
|
expect(find.text('AUDIO'), findsOneWidget);
|
|
expect(find.text('STATIONS'), findsOneWidget);
|
|
expect(find.text('RECORDINGS & MUSIC'), findsOneWidget);
|
|
expect(find.text('APPLICATION'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets(
|
|
'Issue 3 (feedback-pruebas): the gap between stacked settings groups '
|
|
'is 16, matching t4:523/534/541 -- not 12',
|
|
(tester) async {
|
|
setLargeSurface(tester);
|
|
_suppressListTileInkAssertion();
|
|
final estado = await crearEstado();
|
|
addTearDown(estado.dispose);
|
|
|
|
await tester.pumpWidget(buildAjustes(estado));
|
|
await pumpStable(tester);
|
|
|
|
for (final key in [
|
|
'ajustes-group-gap-1',
|
|
'ajustes-group-gap-2',
|
|
'ajustes-group-gap-3',
|
|
]) {
|
|
expect(
|
|
tester.getSize(find.byKey(ValueKey(key))).height,
|
|
16,
|
|
reason: 't4:523/534/541 all draw a 16px gap between stacked groups',
|
|
);
|
|
}
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
// ── Infrastructure ──────────────────────────────────────────────────────────
|
|
|
|
class _FakeGrabacion extends ServicioGrabacionRadio {
|
|
final _controller = StreamController<EstadoGrabacionRadio>.broadcast();
|
|
|
|
@override
|
|
EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva();
|
|
|
|
@override
|
|
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
|
|
|
|
@override
|
|
Future<void> inicializar() async {}
|
|
|
|
@override
|
|
Future<void> dispose() => _controller.close();
|
|
}
|
|
|
|
Future<File> _archivoCustomVacio() async =>
|
|
File('${Directory.current.path}/test/fixtures/emisoras_custom_vacio.json');
|