fix(stage-ui): keep microphone selection in one store (#2082)

This commit is contained in:
Ryanba
2026-07-20 16:11:21 +08:00
committed by GitHub
parent 38f7460558
commit 2bf8cb1f7f
4 changed files with 157 additions and 3 deletions
@@ -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,