Files
pluriwave/test/pantallas/pantalla_ajustes_test.dart
T
FreeTLab b17c582572
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m47s
fix(devices): cache platform device names, dedupe placeholder ids, purge collided EQ entries
Dart half of bt-device-identity. EstadoEcualizador now caches each
device's platform-reported name in memory so the settings screen
shows the device's own Bluetooth name instead of its raw id when no
custom rename exists, and skips auto-creating preset entries for the
composite-placeholder sentinel. Enabling multi-device EQ triggers the
Bluetooth permission request through the new channel contract. A
flag-guarded one-time migration purges only entries keyed by the
exact literal placeholder id from the three per-device preference
maps, since those collided entries cannot be attributed to a device.

Work unit 2/2 of bt-device-identity (Dart state + migration).
2026-07-11 00:25:00 +02:00

536 lines
19 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));
});
});
}
// ── 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');