import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; /// `mapearGananciaNativa` — the hand-off from the app's ±12 dB slider to the /// device's native `Equalizer`, whose capability is reported as /// `AndroidEqualizerParameters.min/maxDecibels` /// (`Equalizer.getBandLevelRange()` in millibels, divided by 1000). /// /// WHY THIS CONTRACT CHANGED — the previous one stretched each side of the /// slider against its own end of the native range, so `+6` on a device /// reporting `[-12, +20]` was delivered as `+10`. Both sides of the mapping /// are already the SAME unit, so that multiplication was a unit error: /// /// * `just_audio` documents `setGain` as "Sets the gain for this band in /// decibels", and its Android bridge does `setBandLevel(band, /// round(gain * 1000.0))` — plain dB to millibels, no normalisation. /// `min/maxDecibels` are the device's absolute CAPABILITY in dB, i.e. a /// bound on the control, not a scale to normalise into. /// * The app makes the user a decibel promise in three places at once: the /// slider is hard-coded `min: -12.0, max: 12.0`, the label under each /// band prints `'${banda.toStringAsFixed(1)}dB'`, and TalkBack reads out /// `equalizerBandValue` = "{value} decibels". Stretching made that label /// a lie on every device whose range is not exactly ±12. /// * Presets are persisted and EXPORTED as those same raw slider dB /// (`PresetEcualizador.toJson`), so under the old mapping a backup /// restored on a wider-range phone showed identical numbers and played /// louder — and on the common asymmetric shape `[-12, +19]` boosts were /// multiplied by 1.58 while cuts were not, deforming the preset's SHAPE /// rather than merely its depth. /// /// So: the number the user reads is the number the device is asked for. The /// native range only CLAMPS it. /// /// What this deliberately KEEPS from the previous contract — every invariant /// the «suena muy alto» fix actually earned. 0 dB is always exactly 0 (a /// naive `db.clamp(minDecibels, maxDecibels)` would regress that on a wholly /// positive reported range, turning a FLAT preset into a boost again), the /// sign of the user's intent is never inverted, the result never escapes the /// native range, a device with no headroom above unity can never boost, and a /// zero-width range collapses to 0. void main() { group('mapearGananciaNativa — 0 dB is always exactly 0', () { test('symmetric range (the common case) is unchanged', () { expect( mapearGananciaNativa(0, minDecibels: -15, maxDecibels: 15), 0, ); }); test('asymmetric range no longer boosts a FLAT preset', () { // The reported symptom: on a device reporting [-12, +19] the old // midpoint mapping turned every 0 dB band into +3.5 dB of real boost. expect( mapearGananciaNativa(0, minDecibels: -12, maxDecibels: 19), 0, reason: 'a FLAT preset must be inaudible, on every device', ); }); test('a wholly positive range still cannot boost a FLAT preset', () { // This is precisely why the mapping cannot be a plain // `db.clamp(minDecibels, maxDecibels)`: that would answer +3 here and // bring the «suena muy alto» bug straight back. The clamp window has // to be widened so that it always contains 0. expect(mapearGananciaNativa(0, minDecibels: 3, maxDecibels: 19), 0); }); test('a wholly negative range still cannot cut a FLAT preset', () { expect(mapearGananciaNativa(0, minDecibels: -19, maxDecibels: -3), 0); }); }); group('mapearGananciaNativa — the slider dB reach the device literally', () { test('+12 dB is delivered as +12 dB, not stretched to the native max', () { // CONTRACT CHANGE: this used to assert 19, i.e. the whole of the // device's headroom. The slider says "12.0dB" and the accessibility // label says "12.0 decibels", so 12 dB is what the device must be // asked for. The 7 dB of extra hardware headroom is unreachable by // design until the slider itself is widened and says so. expect(mapearGananciaNativa(12, minDecibels: -12, maxDecibels: 19), 12); }); test('-12 dB is delivered as -12 dB', () { expect(mapearGananciaNativa(-12, minDecibels: -12, maxDecibels: 19), -12); }); test('values beyond the slider scale clamp to the slider limit', () { // CONTRACT CHANGE: these used to answer the NATIVE extremes (±15). // The slider scale is the first bound; the device range is the second. expect(mapearGananciaNativa(40, minDecibels: -15, maxDecibels: 15), 12); expect(mapearGananciaNativa(-40, minDecibels: -15, maxDecibels: 15), -12); }); }); group('mapearGananciaNativa — the label is the value the device gets', () { test('+6 dB on a wide-range device is +6 dB, never 10', () { // CONTRACT CHANGE: this used to assert closeTo(10) — "half boost is // half of the positive headroom". A slider reading "6.0dB" that // produced +10 dB of real boost is exactly what made a restored backup // sound different on a different phone. expect( mapearGananciaNativa(6, minDecibels: -12, maxDecibels: 20), closeTo(6, 1e-9), ); }); test('-6 dB on that same device is -6 dB', () { expect( mapearGananciaNativa(-6, minDecibels: -12, maxDecibels: 20), closeTo(-6, 1e-9), ); }); test('the six factory presets keep their shape on an asymmetric device', () { // Jazz, authored in true dB before any scaling existed. Under the old // mapping [-12, +19] delivered it as [4.75, -1, -1.5, 3.17, 6.33]: a // different tonal curve, not merely a louder one. const jazz = [3.0, -1.0, -1.5, 2.0, 4.0]; final entregado = jazz .map( (db) => mapearGananciaNativa(db, minDecibels: -12, maxDecibels: 19), ) .toList(); expect(entregado, jazz); }); test('the sign of the user intent is never inverted', () { for (final db in [-12.0, -6.0, -1.0, 1.0, 6.0, 12.0]) { final nativo = mapearGananciaNativa( db, minDecibels: -12, maxDecibels: 19, ); expect( nativo.sign, db.sign, reason: 'a cut must never become a boost ($db dB -> $nativo)', ); } }); }); group('mapearGananciaNativa — a device narrower than the slider', () { test('a request that fits is still delivered literally', () { // CONTRACT CHANGE: the old mapping shrank this to (3/12)*6 = 1.5 dB, // so a modest device silently under-delivered every request too. expect(mapearGananciaNativa(3, minDecibels: -6, maxDecibels: 6), 3); expect(mapearGananciaNativa(-3, minDecibels: -6, maxDecibels: 6), -3); }); test('a request beyond the device range clamps to the device limit', () { expect(mapearGananciaNativa(12, minDecibels: -6, maxDecibels: 6), 6); expect(mapearGananciaNativa(-12, minDecibels: -6, maxDecibels: 6), -6); }); test('a very narrow device still gets a sane, in-range value', () { for (final db in [-12.0, -5.0, 0.0, 5.0, 12.0]) { final nativo = mapearGananciaNativa( db, minDecibels: -1.5, maxDecibels: 1.5, ); expect(nativo, greaterThanOrEqualTo(-1.5)); expect(nativo, lessThanOrEqualTo(1.5)); expect(nativo.sign, db.sign); } }); }); group('mapearGananciaNativa — degenerate ranges reported by the device', () { test('a device with no headroom above unity can never boost', () { expect(mapearGananciaNativa(12, minDecibels: -15, maxDecibels: 0), 0); expect(mapearGananciaNativa(6, minDecibels: -15, maxDecibels: 0), 0); }); test('a cut the device could honour exactly is not over-delivered', () { // CONTRACT CHANGE: this used to answer -15, spending the device's whole // range on a request for -12 dB. The user asked for -12; -12 is // representable here, so -12 is what is sent. expect(mapearGananciaNativa(-12, minDecibels: -15, maxDecibels: 0), -12); }); test('a zero-width range collapses everything to 0', () { expect(mapearGananciaNativa(12, minDecibels: 0, maxDecibels: 0), 0); expect(mapearGananciaNativa(-12, minDecibels: 0, maxDecibels: 0), 0); }); test('the result never escapes the native range', () { for (final db in [-12.0, -3.0, 0.0, 3.0, 12.0]) { final nativo = mapearGananciaNativa( db, minDecibels: -3, maxDecibels: 19, ); expect(nativo, greaterThanOrEqualTo(-3)); expect(nativo, lessThanOrEqualTo(19)); } }); }); }