From 2bf8cb1f7f53a8e38b74151d6e98282e8cf2598a Mon Sep 17 00:00:00 2001 From: Ryanba <92616678+Gujiassh@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:11:21 +0800 Subject: [PATCH] fix(stage-ui): keep microphone selection in one store (#2082) --- .../hearing-config.browser.test.ts | 129 ++++++++++++++++++ .../dialogs/audio-input/hearing-config.vue | 6 +- .../src/stores/settings/audio-device.test.ts | 23 ++++ .../src/stores/settings/audio-device.ts | 2 + 4 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 packages/stage-ui/src/components/scenarios/dialogs/audio-input/hearing-config.browser.test.ts diff --git a/packages/stage-ui/src/components/scenarios/dialogs/audio-input/hearing-config.browser.test.ts b/packages/stage-ui/src/components/scenarios/dialogs/audio-input/hearing-config.browser.test.ts new file mode 100644 index 000000000..185bb28ea --- /dev/null +++ b/packages/stage-ui/src/components/scenarios/dialogs/audio-input/hearing-config.browser.test.ts @@ -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('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(), + 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('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('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('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() + }) +}) diff --git a/packages/stage-ui/src/components/scenarios/dialogs/audio-input/hearing-config.vue b/packages/stage-ui/src/components/scenarios/dialogs/audio-input/hearing-config.vue index 4ea09fbf4..b9dcd2cef 100644 --- a/packages/stage-ui/src/components/scenarios/dialogs/audio-input/hearing-config.vue +++ b/packages/stage-ui/src/components/scenarios/dialogs/audio-input/hearing-config.vue @@ -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('autoSend') diff --git a/packages/stage-ui/src/stores/settings/audio-device.test.ts b/packages/stage-ui/src/stores/settings/audio-device.test.ts index a3e254adb..510d0dc7b 100644 --- a/packages/stage-ui/src/stores/settings/audio-device.test.ts +++ b/packages/stage-ui/src/stores/settings/audio-device.test.ts @@ -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('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') diff --git a/packages/stage-ui/src/stores/settings/audio-device.ts b/packages/stage-ui/src/stores/settings/audio-device.ts index 8e945d2e4..d718bc8ad 100644 --- a/packages/stage-ui/src/stores/settings/audio-device.ts +++ b/packages/stage-ui/src/stores/settings/audio-device.ts @@ -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,