Files
pluriwave/test/pantallas/pantalla_ajustes_test.dart
T
FreeTLab 725169cd31 feat(auto): page local-music folders instead of truncating at 50 [size:exception]
Folders over the 50-item cap now show a "Mas..." item that reveals
the next page on tap, instead of silently dropping the rest. Paging
slices the cheap raw list before building any MediaItem, so items
beyond the requested page are never resolved (art, title) -- proven
by a call-count test. Also swaps the raw SAF content:// URI shown in
settings for a parsed, human-readable folder name with a localized
fallback across all 13 locales.

servicio_audio.dart is untouched; this stays entirely within the
local-music tree/dispatch layer.
2026-07-19 22:14:05 +02:00

593 lines
20 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_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/modelos/dispositivo_audio.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
import 'package:pluriwave/servicios/servicio_grabacion_radio.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),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: PantallaAjustes()),
),
);
}
Future<EstadoRadio> crearEstado({
bool eqMultiDeviceEnabled = false,
Map<String, PresetEcualizador> presetsDispositivo = const {},
FakeServicioDispositivoAudio? dispositivoAudio,
}) async {
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
presetsDispositivo: presetsDispositivo,
),
servicioGrabacion: _FakeGrabacion(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
dispositivoAudio: dispositivoAudio,
);
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));
}
// Also update crearEstado to support nombresDispositivos
Future<EstadoRadio> crearEstadoConNombres({
bool eqMultiDeviceEnabled = true,
Map<String, PresetEcualizador>? presetsDispositivo,
Map<String, String>? nombresDispositivos,
String? activeDeviceId,
String nombrePlataforma = 'BT Speaker',
}) async {
final fakeDispositivo = activeDeviceId != null
? (FakeServicioDispositivoAudio()
..emitirDispositivo(
DispositivoAudio(
id: activeDeviceId,
tipo: TipoDispositivo.bluetoothA2dp,
nombre: nombrePlataforma,
),
))
: null;
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
presetsDispositivo: presetsDispositivo ??
{'bt_a2dp:AA:BB': PresetEcualizador.rock},
nombresDispositivos: nombresDispositivos ?? {},
),
servicioGrabacion: _FakeGrabacion(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
dispositivoAudio: fakeDispositivo,
);
await estado.ecualizador.cargarPersistido();
return estado;
}
// ── Phase 7 tests ──────────────────────────────────────────────────────────
group('_SeccionEcualizadorAvanzado (Phase 7)', () {
testWidgets('7.1-A: toggle OFF — advanced EQ section is visible but device '
'list is not shown', (tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion(); // Must be before pumpWidget.
final estado = await crearEstado(eqMultiDeviceEnabled: false);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
// Scroll to find the advanced EQ section.
await tester.scrollUntilVisible(
find.text('Advanced Equalization Options'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
// The section header must be present.
expect(find.text('Advanced Equalization Options'), findsOneWidget);
// Toggle switch title must be present.
expect(find.text('Enable per-device EQ'), findsOneWidget);
// When toggle is OFF, device list must NOT be rendered.
expect(find.text('Known audio devices'), findsNothing);
});
testWidgets(
'7.1-B: toggle ON with known devices — device list is visible',
(tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstado(
eqMultiDeviceEnabled: true,
presetsDispositivo: {
'bt_a2dp:AA:BB:CC:DD:EE:FF': PresetEcualizador.rock,
},
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Advanced Equalization Options'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
// Section header present.
expect(find.text('Advanced Equalization Options'), findsOneWidget);
// Toggle switch title present.
expect(find.text('Enable per-device EQ'), findsOneWidget);
// Known devices header should appear when toggle is on and there are
// known devices.
expect(find.text('Known audio devices'), findsOneWidget);
// The device ID should appear in the list.
expect(
find.textContaining('bt_a2dp:AA:BB:CC:DD:EE:FF'),
findsOneWidget,
);
},
);
testWidgets(
'7.1-C: toggle can be flipped — tapping it enables multi-device EQ',
(tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstado(eqMultiDeviceEnabled: false);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Advanced Equalization Options'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
// Initially OFF.
expect(estado.ecualizador.eqMultiDeviceEnabled, isFalse);
// Tap the Switch widget to toggle on.
await tester.tap(find.byType(Switch).last);
await pumpStable(tester);
// After tap, toggle should be ON.
expect(estado.ecualizador.eqMultiDeviceEnabled, isTrue);
},
);
});
// ── Phase 3 (eq-device-autoswitch-ux): connection indicator + modal ──────────
group('SeccionEcualizadorAvanzado connection indicator Phase 3', () {
// 3.1 RED — active device row shows green connection dot
testWidgets('3.1 active device row shows green connection indicator', (tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
const activeId = 'bt_a2dp:AA:BB';
final estado = await crearEstadoConNombres(
eqMultiDeviceEnabled: true,
presetsDispositivo: {
activeId: PresetEcualizador.rock,
'wired_headset': PresetEcualizador.jazz,
},
activeDeviceId: activeId,
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Known audio devices'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
// Green connection dot should be present (Icon with green color for active device)
final greenIcons = tester.widgetList<Icon>(find.byType(Icon)).where(
(icon) => icon.color == Colors.green,
);
// At least one green icon present
expect(greenIcons, isNotEmpty);
});
// 3.2 RED — tapping device row opens bottom sheet with TextField and EcualizadorWidget
testWidgets('3.2 tapping device row opens modal with TextField', (tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstadoConNombres(
presetsDispositivo: {'bt_a2dp:AA:BB': PresetEcualizador.rock},
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Known audio devices'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
// Tap the device row (tap the edit icon or the row itself)
final editIcons = find.byIcon(Icons.edit_rounded);
expect(editIcons, findsWidgets);
await tester.tap(editIcons.first);
await pumpStable(tester);
// Bottom sheet should appear with a TextField
expect(find.byType(TextField), findsWidgets);
});
// 3.6 RED — renaming in modal and confirming calls renombrarDispositivo
testWidgets('3.6 confirming rename in modal updates device name', (tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
const deviceId = 'bt_a2dp:AA:BB';
final estado = await crearEstadoConNombres(
presetsDispositivo: {deviceId: PresetEcualizador.rock},
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Known audio devices'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
// Open modal
await tester.tap(find.byIcon(Icons.edit_rounded).first);
await pumpStable(tester);
// Enter a new name
final textField = find.byType(TextField).first;
await tester.enterText(textField, 'My Living Room Speaker');
await pumpStable(tester);
// Tap confirm/save button
final saveButton = find.byIcon(Icons.save_rounded);
expect(saveButton, findsWidgets);
await tester.tap(saveButton.first);
await pumpStable(tester);
// Device should now have the new name
expect(
estado.ecualizador.obtenerNombreDispositivo(deviceId),
equals('My Living Room Speaker'),
);
});
// 3.7 RED — dismissing modal without confirming leaves name unchanged
testWidgets('3.7 dismissing modal without confirming leaves name unchanged', (tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
const deviceId = 'bt_a2dp:AA:BB';
final estado = await crearEstadoConNombres(
presetsDispositivo: {deviceId: PresetEcualizador.rock},
nombresDispositivos: {deviceId: 'Original Name'},
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Known audio devices'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
// Open modal
await tester.tap(find.byIcon(Icons.edit_rounded).first);
await pumpStable(tester);
// Change the text but do NOT confirm
final textField = find.byType(TextField).first;
await tester.enterText(textField, 'New Name Not Saved');
await pumpStable(tester);
// Dismiss by pressing back/escape
await tester.tapAt(const Offset(100, 100)); // tap outside bottom sheet
await pumpStable(tester);
// Name should remain unchanged
expect(
estado.ecualizador.obtenerNombreDispositivo(deviceId),
equals('Original Name'),
);
});
});
// ── bt-device-identity Phase 4: display fix + permission trigger ─────────
group('_SeccionEcualizadorAvanzado — bt-device-identity Phase 4', () {
// 4.1 — new behavior: the cached platform name (not '') now feeds
// nombreVisible, so a device with no custom rename shows its real name.
testWidgets('4.1 platform name displays with no custom rename', (
tester,
) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
final estado = await crearEstadoConNombres(
presetsDispositivo: {deviceId: PresetEcualizador.rock},
activeDeviceId: deviceId,
nombrePlataforma: 'AirPods Pro',
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Known audio devices'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
expect(find.text('AirPods Pro'), findsOneWidget);
expect(find.text(deviceId), findsNothing);
});
// 4.4 — triangulation companion: custom rename still wins even though
// the row now also has a cached platform name available.
testWidgets('4.4 custom rename overrides platform name', (tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
final estado = await crearEstadoConNombres(
presetsDispositivo: {deviceId: PresetEcualizador.rock},
activeDeviceId: deviceId,
nombrePlataforma: 'AirPods Pro',
nombresDispositivos: {deviceId: 'My Headphones'},
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Known audio devices'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
expect(find.text('My Headphones'), findsOneWidget);
expect(find.text('AirPods Pro'), findsNothing);
});
// 4.5 — approval test (regression-lock): a device never seen on the
// stream has no cached platform name, so legacy raw-id fallback holds.
testWidgets('4.5 no platform name yet falls back to raw id', (
tester,
) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
final estado = await crearEstadoConNombres(
presetsDispositivo: {deviceId: PresetEcualizador.rock},
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Known audio devices'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
expect(find.text(deviceId), findsOneWidget);
});
// 4.6 — permission trigger point: turning the toggle ON requests
// BLUETOOTH_CONNECT; turning it back OFF must not re-fire the request.
testWidgets('4.6 permission call fires on device-management open', (
tester,
) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final fakeDispositivo = FakeServicioDispositivoAudio();
final estado = await crearEstado(
eqMultiDeviceEnabled: false,
dispositivoAudio: fakeDispositivo,
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Advanced Equalization Options'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
expect(fakeDispositivo.solicitarPermisoBluetoothCalls, equals(0));
// Toggle ON: permission requested exactly once.
await tester.tap(find.byType(Switch).last);
await pumpStable(tester);
expect(estado.ecualizador.eqMultiDeviceEnabled, isTrue);
expect(fakeDispositivo.solicitarPermisoBluetoothCalls, equals(1));
// Toggle OFF again: no further permission request.
await tester.tap(find.byType(Switch).last);
await pumpStable(tester);
expect(estado.ecualizador.eqMultiDeviceEnabled, isFalse);
expect(fakeDispositivo.solicitarPermisoBluetoothCalls, equals(1));
});
});
// ── android-auto-local-music-paging Phase 7: friendly folder name ────────
group('_SeccionMusicaLocal — friendly folder name (Phase 7)', () {
testWidgets(
'7.1-A: carpeta configurada muestra el nombre amigable derivado de '
'la URI, nunca la URI cruda',
(tester) async {
SharedPreferences.setMockInitialValues({
'musica_local_uri':
'content://com.android.externalstorage.documents/tree/'
'primary%3AMusic%2FMyFolder',
});
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Local music folder'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
expect(find.text('MyFolder'), findsOneWidget);
expect(find.textContaining('content://'), findsNothing);
},
);
testWidgets(
'7.1-B: sin carpeta configurada mantiene el mensaje '
'localMusicFolderNotConfigured',
(tester) async {
SharedPreferences.setMockInitialValues({});
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Local music folder'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
expect(find.text('No folder selected'), findsOneWidget);
},
);
});
}
// ── 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');