Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions jest/contextual/EqualizerScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import React from 'react'
import { StyleSheet } from 'react-native'
import { fireEvent, render } from '@testing-library/react-native'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { TamaguiProvider, Theme } from 'tamagui'
import {
useEqualizer,
useEqualizerPresets,
type EqualizerBand,
type EqualizerPreset,
} from 'react-native-nitro-player'

import EqualizerScreen from '../../src/screens/Settings/equalizer'
import { useIsCasting } from '../../src/stores/player/engine'
import config from '../../src/configs/tamagui.config'

jest.mock('../../src/stores/player/engine', () => ({
useIsCasting: jest.fn().mockReturnValue(false),
}))

const FREQUENCIES = [31, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]

const BUILT_IN_PRESET_NAMES = [
'Flat',
'Rock',
'Pop',
'Classical',
'Dance',
'Techno',
'Club',
'Live',
'Reggae',
'Full Bass',
'Full Treble',
'Full Bass & Treble',
'Large Hall',
'Party',
'Ska',
'Soft',
'Soft Rock',
'Headphones',
'Laptop Speakers',
]

function buildBands(gains: Partial<Record<number, number>> = {}): EqualizerBand[] {
return FREQUENCIES.map((centerFrequency, index) => ({
index,
centerFrequency,
gainDb: gains[index] ?? 0,
frequencyLabel:
centerFrequency >= 1000 ? `${centerFrequency / 1000} kHz` : `${centerFrequency} Hz`,
}))
}

function buildPresets(): EqualizerPreset[] {
return BUILT_IN_PRESET_NAMES.map((name) => ({
name,
gains: new Array(10).fill(0),
type: 'built-in',
}))
}

function mockEqualizer(overrides: Partial<ReturnType<typeof useEqualizer>> = {}) {
const value = {
isEnabled: true,
bands: buildBands(),
currentPreset: 'Flat',
setEnabled: jest.fn().mockResolvedValue(true),
setBandGain: jest.fn().mockResolvedValue(true),
setAllBandGains: jest.fn().mockResolvedValue(true),
reset: jest.fn().mockResolvedValue(undefined),
isLoading: false,
gainRange: { min: -12, max: 12 },
...overrides,
}

jest.mocked(useEqualizer).mockReturnValue(value)

return value
}

function mockPresets(overrides: Partial<ReturnType<typeof useEqualizerPresets>> = {}) {
const value = {
presets: buildPresets(),
builtInPresets: buildPresets(),
customPresets: [],
applyPreset: jest.fn().mockResolvedValue(true),
saveCustomPreset: jest.fn().mockResolvedValue(true),
deleteCustomPreset: jest.fn().mockResolvedValue(true),
currentPreset: 'Flat' as string | null,
isLoading: false,
refreshPresets: jest.fn(),
}

jest.mocked(useEqualizerPresets).mockReturnValue({ ...value, ...overrides })

return value
}

function renderScreen() {
return render(
<GestureHandlerRootView>
<SafeAreaProvider
initialMetrics={{
frame: { x: 0, y: 0, width: 390, height: 844 },
insets: { top: 0, left: 0, right: 0, bottom: 0 },
}}
>
<TamaguiProvider config={config} defaultTheme='purple_dark'>
<Theme name='purple_dark'>
<EqualizerScreen />
</Theme>
</TamaguiProvider>
</SafeAreaProvider>
</GestureHandlerRootView>,
)
}

describe('EqualizerScreen', () => {
beforeEach(() => {
jest.mocked(useIsCasting).mockReturnValue(false)
mockEqualizer()
mockPresets()
})

it('renders the enable toggle and ten band rows', () => {
const { getByTestId, getAllByTestId, getByText } = renderScreen()

expect(getByTestId('equalizer-enabled-switch')).toBeTruthy()
expect(getAllByTestId(/equalizer-band-row-/)).toHaveLength(10)
expect(getByText('31 Hz')).toBeTruthy()
expect(getByText('16 kHz')).toBeTruthy()
})

it('renders formatted gain readouts', () => {
mockEqualizer({ bands: buildBands({ 5: 3, 0: -2.5 }) })

const { getByText, getAllByText } = renderScreen()

expect(getByText('+3.0 dB')).toBeTruthy()
expect(getByText('-2.5 dB')).toBeTruthy()
expect(getAllByText('0 dB')).toHaveLength(8)
})

it('toggling the switch calls setEnabled', () => {
const equalizer = mockEqualizer({ isEnabled: false })

const { getByTestId } = renderScreen()

fireEvent(getByTestId('equalizer-enabled-switch'), 'checkedChange', true)

expect(equalizer.setEnabled).toHaveBeenCalledWith(true)
})

it('renders a chip per built-in preset and applies on press', () => {
const presets = mockPresets()

const { getAllByTestId, getByText } = renderScreen()

expect(getAllByTestId(/equalizer-preset-/)).toHaveLength(BUILT_IN_PRESET_NAMES.length)

fireEvent.press(getByText('Rock'))

expect(presets.applyPreset).toHaveBeenCalledWith('Rock')
})

it('shows Custom when no preset is active', () => {
mockEqualizer({ currentPreset: null })
mockPresets({ currentPreset: null })

const { getByText } = renderScreen()

expect(getByText('Current: Custom')).toBeTruthy()
})

it('reset button calls reset', () => {
const equalizer = mockEqualizer()

const { getByTestId } = renderScreen()

fireEvent.press(getByTestId('equalizer-reset-button'))

expect(equalizer.reset).toHaveBeenCalled()
})

it('shows the cast hint only while casting', () => {
const { queryByTestId, unmount } = renderScreen()

expect(queryByTestId('equalizer-cast-hint')).toBeNull()

unmount()

jest.mocked(useIsCasting).mockReturnValue(true)

const { getByTestId } = renderScreen()

expect(getByTestId('equalizer-cast-hint')).toBeTruthy()
})

it('dims and blocks the sections when disabled', () => {
mockEqualizer({ isEnabled: false })

const { getByTestId } = renderScreen()

const sections = getByTestId('equalizer-sections')

expect(sections).toHaveStyle({ opacity: 0.5 })

const flattened = StyleSheet.flatten(sections.props.style)
expect(sections.props.pointerEvents ?? flattened?.pointerEvents).toBe('none')
})
})
60 changes: 60 additions & 0 deletions jest/functional/Equalizer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
EQ_GAIN_MAX,
EQ_GAIN_MIN,
EQ_SLIDER_MAX,
dbToSliderValue,
formatGainLabel,
sliderValueToDb,
} from '../../src/utils/audio/equalizer'

describe('Equalizer Mapping Module', () => {
it('maps slider extremes and midpoint to dB', () => {
expect(sliderValueToDb(0)).toBe(EQ_GAIN_MIN)
expect(sliderValueToDb(EQ_SLIDER_MAX)).toBe(EQ_GAIN_MAX)
expect(sliderValueToDb(EQ_SLIDER_MAX / 2)).toBe(0)
})

it('rounds slider values to 0.5 dB steps', () => {
expect(sliderValueToDb(13.3)).toBe(1.5)
expect(sliderValueToDb(13.2)).toBe(1)
expect(sliderValueToDb(11.75)).toBe(0)
})

it('clamps out-of-range slider values', () => {
expect(sliderValueToDb(-1)).toBe(EQ_GAIN_MIN)
expect(sliderValueToDb(EQ_SLIDER_MAX + 1)).toBe(EQ_GAIN_MAX)
})

it('maps dB to slider position without rounding', () => {
expect(dbToSliderValue(EQ_GAIN_MIN)).toBe(0)
expect(dbToSliderValue(0)).toBe(EQ_SLIDER_MAX / 2)

// Built-in presets use fractional gains, e.g. Rock boosts 31 Hz by 4.8 dB
expect(dbToSliderValue(4.8)).toBeCloseTo(16.8, 10)
})

it('clamps out-of-range gains', () => {
expect(dbToSliderValue(EQ_GAIN_MIN - 1)).toBe(0)
expect(dbToSliderValue(EQ_GAIN_MAX + 1)).toBe(EQ_SLIDER_MAX)
})

it('round-trips step-aligned gains', () => {
for (const gainDb of [-12, -2.5, 0, 0.5, 12]) {
expect(sliderValueToDb(dbToSliderValue(gainDb))).toBe(gainDb)
}
})

it('formats gain labels', () => {
expect(formatGainLabel(3)).toBe('+3.0 dB')
expect(formatGainLabel(-2.5)).toBe('-2.5 dB')
expect(formatGainLabel(12)).toBe('+12.0 dB')
expect(formatGainLabel(0)).toBe('0 dB')
})

it('treats negative zero and float dust as flat', () => {
expect(formatGainLabel(-0)).toBe('0 dB')
expect(formatGainLabel(0.04)).toBe('0 dB')
expect(sliderValueToDb(12.1)).toBe(0)
expect(Object.is(sliderValueToDb(11.9), 0)).toBe(true)
})
})
52 changes: 52 additions & 0 deletions jest/setup/nitro-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,26 @@ jest.mock('react-native-nitro-player', () => ({
removeTrackFromPlaylist: jest.fn(),
reorderTrackInPlaylist: jest.fn(),
},
Equalizer: {
setEnabled: jest.fn().mockResolvedValue(undefined),
isEnabled: jest.fn().mockReturnValue(false),
getBands: jest.fn().mockResolvedValue([]),
setBandGain: jest.fn().mockResolvedValue(undefined),
setAllBandGains: jest.fn().mockResolvedValue(undefined),
getBandRange: jest.fn().mockReturnValue({ min: -12, max: 12 }),
getPresets: jest.fn().mockReturnValue([]),
getBuiltInPresets: jest.fn().mockReturnValue([]),
getCustomPresets: jest.fn().mockReturnValue([]),
applyPreset: jest.fn().mockResolvedValue(undefined),
getCurrentPresetName: jest.fn().mockReturnValue(null),
saveCustomPreset: jest.fn().mockResolvedValue(undefined),
deleteCustomPreset: jest.fn().mockResolvedValue(undefined),
getState: jest.fn().mockResolvedValue({ enabled: false, bands: [], currentPreset: null }),
reset: jest.fn().mockResolvedValue(undefined),
onEnabledChange: jest.fn(),
onBandChange: jest.fn(),
onPresetChange: jest.fn(),
},
DownloadManager: {
configure: jest.fn(),
downloadTrack: jest.fn(),
Expand Down Expand Up @@ -57,4 +77,36 @@ jest.mock('react-native-nitro-player', () => ({
useOnPlaybackStateChange: jest.fn().mockReturnValue({ state: 'none' }),
useDownloadProgress: jest.fn().mockReturnValue({ progress: 0 }),
useDownloadedTracks: jest.fn().mockReturnValue({ downloadedTracks: [] }),
useEqualizer: jest.fn().mockReturnValue({
isEnabled: false,
bands: [31, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000].map(
(centerFrequency, index) => ({
index,
centerFrequency,
gainDb: 0,
frequencyLabel:
centerFrequency >= 1000
? `${centerFrequency / 1000} kHz`
: `${centerFrequency} Hz`,
}),
),
currentPreset: null,
setEnabled: jest.fn().mockResolvedValue(true),
setBandGain: jest.fn().mockResolvedValue(true),
setAllBandGains: jest.fn().mockResolvedValue(true),
reset: jest.fn().mockResolvedValue(undefined),
isLoading: false,
gainRange: { min: -12, max: 12 },
}),
useEqualizerPresets: jest.fn().mockReturnValue({
presets: [],
builtInPresets: [],
customPresets: [],
applyPreset: jest.fn().mockResolvedValue(true),
saveCustomPreset: jest.fn().mockResolvedValue(true),
deleteCustomPreset: jest.fn().mockResolvedValue(true),
currentPreset: null,
isLoading: false,
refreshPresets: jest.fn(),
}),
}))
8 changes: 6 additions & 2 deletions src/components/Settings/components/sections/action-chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Icon from '../../../Global/components/icon'
interface ActionChipProps {
active: boolean
label: string
icon: string
icon?: string
onPress: () => void
testID?: string
}
Expand All @@ -29,7 +29,11 @@ export default function ActionChip({
paddingHorizontal='$2.5'
size='$2'
borderRadius='$10'
icon={<Icon name={icon} color={active ? '$background' : '$color'} small />}
icon={
icon ? (
<Icon name={icon} color={active ? '$background' : '$color'} small />
) : undefined
}
>
<SizableText color={active ? '$background' : '$color'} size='$2'>
{label}
Expand Down
7 changes: 7 additions & 0 deletions src/components/Settings/components/vertical-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ export default function VerticalSettings(): React.JSX.Element {
route='Playback'
iconColor='$warning'
/>
<SettingsNavRow
testID='settings-nav-equalizer'
title='Equalizer'
icon='equalizer'
route='Equalizer'
iconColor='$success'
/>
<SettingsNavRow
testID='settings-nav-storage'
title='Storage'
Expand Down
Loading
Loading