fix(stage-ui): keep microphone selection in one store (#2082)
This commit is contained in:
+129
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { createPinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick } from 'vue'
|
||||
|
||||
import HearingConfig from './hearing-config.vue'
|
||||
|
||||
const audioDeviceMocks = vi.hoisted(() => ({
|
||||
componentAskPermission: vi.fn(),
|
||||
storeAskPermission: vi.fn(),
|
||||
}))
|
||||
|
||||
function createAudioInput(deviceId: string, label: string): MediaDeviceInfo {
|
||||
return {
|
||||
deviceId,
|
||||
groupId: '',
|
||||
kind: 'audioinput',
|
||||
label,
|
||||
toJSON: () => ({}),
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('../../../../composables/audio', async () => {
|
||||
const { computed, ref, shallowRef } = await vi.importActual<typeof import('vue')>('vue')
|
||||
const permissionGranted = ref(false)
|
||||
|
||||
audioDeviceMocks.storeAskPermission.mockImplementation(async () => {
|
||||
permissionGranted.value = true
|
||||
})
|
||||
|
||||
return {
|
||||
useAudioDevice: () => ({
|
||||
audioInputs: ref([createAudioInput('store-microphone', 'Store microphone')]),
|
||||
selectedAudioInput: ref('store-microphone'),
|
||||
stream: shallowRef<MediaStream>(),
|
||||
deviceConstraints: computed(() => ({ audio: true })),
|
||||
permissionGranted,
|
||||
askPermission: audioDeviceMocks.storeAskPermission,
|
||||
startStream: vi.fn().mockResolvedValue(undefined),
|
||||
stopStream: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../../../composables', async () => {
|
||||
const { ref } = await vi.importActual<typeof import('vue')>('vue')
|
||||
const permissionGranted = ref(false)
|
||||
|
||||
audioDeviceMocks.componentAskPermission.mockImplementation(async () => {
|
||||
permissionGranted.value = true
|
||||
})
|
||||
|
||||
return {
|
||||
useAudioAnalyzer: () => ({ volumeLevel: ref(0) }),
|
||||
useAudioDevice: () => ({
|
||||
audioInputs: ref([createAudioInput('detached-microphone', 'Detached microphone')]),
|
||||
permissionGranted,
|
||||
askPermission: audioDeviceMocks.componentAskPermission,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../../../stores', async () => {
|
||||
const { useSettingsAudioDevice } = await import('../../../../stores/settings/audio-device')
|
||||
return { useSettingsAudioDevice }
|
||||
})
|
||||
|
||||
vi.mock('@proj-airi/ui', async () => {
|
||||
const { defineComponent, h } = await vi.importActual<typeof import('vue')>('vue')
|
||||
|
||||
return {
|
||||
Callout: defineComponent({ render: () => h('div') }),
|
||||
FieldCheckbox: defineComponent({ render: () => h('div') }),
|
||||
FieldCombobox: defineComponent({
|
||||
props: {
|
||||
options: {
|
||||
type: Array as () => Array<{ label: string, value: string }>,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('div', { 'data-testid': 'device-options' }, props.options.map(option => option.label).join(','))
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
function mountHearingConfig() {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const app = createApp(HearingConfig, { granted: true })
|
||||
app.use(createPinia())
|
||||
app.mount(host)
|
||||
|
||||
return { app, host }
|
||||
}
|
||||
|
||||
describe('hearing config audio device ownership', () => {
|
||||
beforeEach(() => {
|
||||
audioDeviceMocks.componentAskPermission.mockClear()
|
||||
audioDeviceMocks.storeAskPermission.mockClear()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('requests microphone permission through the settings store', async () => {
|
||||
const { app, host } = mountHearingConfig()
|
||||
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Enable microphone input"]')
|
||||
|
||||
button?.click()
|
||||
await nextTick()
|
||||
|
||||
expect(audioDeviceMocks.storeAskPermission).toHaveBeenCalledOnce()
|
||||
expect(audioDeviceMocks.componentAskPermission).not.toHaveBeenCalled()
|
||||
|
||||
app.unmount()
|
||||
host.remove()
|
||||
})
|
||||
|
||||
it('renders the device inventory owned by the settings store', () => {
|
||||
const { app, host } = mountHearingConfig()
|
||||
|
||||
expect(host.querySelector('[data-testid="device-options"]')?.textContent).toBe('Store microphone')
|
||||
expect(host.textContent).not.toContain('Detached microphone')
|
||||
|
||||
app.unmount()
|
||||
host.remove()
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { Callout, FieldCheckbox, FieldCombobox } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useAudioAnalyzer, useAudioDevice } from '../../../../composables'
|
||||
import { useAudioAnalyzer } from '../../../../composables'
|
||||
import { useSettingsAudioDevice } from '../../../../stores'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -13,8 +13,8 @@ const props = withDefaults(defineProps<{
|
||||
})
|
||||
|
||||
const deviceStore = useSettingsAudioDevice()
|
||||
const { enabled, selectedAudioInput } = storeToRefs(deviceStore)
|
||||
const { audioInputs, permissionGranted, askPermission } = useAudioDevice()
|
||||
const { askPermission } = deviceStore
|
||||
const { audioInputs, enabled, permissionGranted, selectedAudioInput } = storeToRefs(deviceStore)
|
||||
const { volumeLevel } = useAudioAnalyzer()
|
||||
|
||||
const autoSend = defineModel<boolean | undefined>('autoSend')
|
||||
|
||||
@@ -9,6 +9,7 @@ const storageMock = vi.hoisted(() => ({
|
||||
|
||||
const audioDeviceMock = vi.hoisted(() => ({
|
||||
audioInputs: { value: [] as MediaDeviceInfo[] },
|
||||
permissionGranted: undefined as unknown as { value: boolean },
|
||||
selectedAudioInput: { value: '' },
|
||||
startStream: vi.fn(),
|
||||
stopStream: vi.fn(),
|
||||
@@ -38,11 +39,13 @@ vi.mock('@proj-airi/stage-shared/composables', async () => {
|
||||
|
||||
vi.mock('../../composables/audio', async () => {
|
||||
const vue = await vi.importActual<typeof import('vue')>('vue')
|
||||
audioDeviceMock.permissionGranted = vue.ref(false)
|
||||
|
||||
return {
|
||||
useAudioDevice: () => ({
|
||||
audioInputs: audioDeviceMock.audioInputs,
|
||||
deviceConstraints: vue.computed(() => ({ audio: true })),
|
||||
permissionGranted: audioDeviceMock.permissionGranted,
|
||||
selectedAudioInput: audioDeviceMock.selectedAudioInput,
|
||||
startStream: audioDeviceMock.startStream,
|
||||
stopStream: audioDeviceMock.stopStream,
|
||||
@@ -67,6 +70,8 @@ describe('store settings-audio-devices', () => {
|
||||
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }))
|
||||
storageMock.values.clear()
|
||||
audioDeviceMock.audioInputs.value = []
|
||||
if (audioDeviceMock.permissionGranted)
|
||||
audioDeviceMock.permissionGranted.value = false
|
||||
audioDeviceMock.selectedAudioInput.value = ''
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
@@ -100,6 +105,24 @@ describe('store settings-audio-devices', () => {
|
||||
expect(storageMock.values.get('settings/audio/input')).toBe('microphone-1')
|
||||
})
|
||||
|
||||
it('exposes permission state from the audio device that owns selection', async () => {
|
||||
audioDeviceMock.audioInputs.value = [createAudioInput('microphone-1')]
|
||||
audioDeviceMock.selectedAudioInput.value = 'microphone-1'
|
||||
audioDeviceMock.askPermission.mockImplementation(async () => {
|
||||
audioDeviceMock.permissionGranted.value = true
|
||||
})
|
||||
|
||||
const { useSettingsAudioDevice } = await import('./audio-device')
|
||||
const store = useSettingsAudioDevice()
|
||||
|
||||
expect(store.permissionGranted).toBe(false)
|
||||
|
||||
await store.askPermission()
|
||||
|
||||
expect(store.permissionGranted).toBe(true)
|
||||
expect(store.selectedAudioInput).toBe('microphone-1')
|
||||
})
|
||||
|
||||
/** @example A rapid off/on toggle keeps using the pending browser microphone request. */
|
||||
it('reuses a pending microphone start after disable and re-enable', async () => {
|
||||
const { useSettingsAudioDevice } = await import('./audio-device')
|
||||
|
||||
@@ -10,6 +10,7 @@ export const useSettingsAudioDevice = defineStore('settings-audio-devices', () =
|
||||
const {
|
||||
audioInputs,
|
||||
deviceConstraints,
|
||||
permissionGranted,
|
||||
selectedAudioInput: selectedAudioInputNonPersist,
|
||||
startStream: startAudioInputStream,
|
||||
stopStream: stopAudioInputStream,
|
||||
@@ -146,6 +147,7 @@ export const useSettingsAudioDevice = defineStore('settings-audio-devices', () =
|
||||
return {
|
||||
audioInputs,
|
||||
deviceConstraints,
|
||||
permissionGranted,
|
||||
selectedAudioInput: selectedAudioInputPersist,
|
||||
enabled: audioInputEnabled,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user