diff --git a/spx-gui/src/components/editor/sprite/animation/SoundEditor.test.ts b/spx-gui/src/components/editor/sprite/animation/SoundEditor.test.ts new file mode 100644 index 000000000..d24a011b7 --- /dev/null +++ b/spx-gui/src/components/editor/sprite/animation/SoundEditor.test.ts @@ -0,0 +1,161 @@ +import { mount } from '@vue/test-utils' +import { defineComponent, nextTick } from 'vue' +import { describe, expect, it, vi } from 'vitest' + +import { fromText } from '@/models/common/file' +import { Animation } from '@/models/spx/animation' +import { SpxProject } from '@/models/spx/project' +import { Sound } from '@/models/spx/sound' +import { provideLocalEditorCtx } from '@/components/editor/EditorContextProvider.vue' +import SoundEditor from './SoundEditor.vue' + +vi.mock('@/components/asset', () => ({ + useAddAssetFromLibrary: () => vi.fn(), + useAddSoundByRecording: () => vi.fn(), + useAddSoundFromLocalFile: () => vi.fn() +})) + +vi.mock('@/utils/exception', async (importOriginal) => ({ + ...(await importOriginal()), + useMessageHandle: (fn: unknown) => ({ fn }) +})) + +function mockFile(name = 'mocked') { + return fromText(name, Math.random() + '') +} + +function mountSoundEditor(animationInits?: { sound?: string | null; soundLoop?: boolean }) { + const project = new SpxProject() + const sound1 = new Sound('Sound01', mockFile()) + const sound2 = new Sound('Sound02', mockFile()) + project.addSound(sound1) + project.addSound(sound2) + + const animation = new Animation('walk', { + sound: animationInits?.sound ?? undefined, + soundLoop: animationInits?.soundLoop ?? false + }) + const doAction = vi.fn(async (_action, fn: () => void) => fn()) + const state = { history: { doAction } } + + const Harness = defineComponent({ + name: 'SoundEditorHarness', + components: { SoundEditor }, + setup() { + provideLocalEditorCtx({ project, state: state as any }) + return { animation, closed: 0 } + }, + template: '' + }) + + const wrapper = mount(Harness, { + global: { + directives: { + radar: () => {} + }, + mocks: { + $t: (message?: { en?: string } | string) => (typeof message === 'string' ? message : message?.en ?? '') + }, + stubs: { + SoundItem: { + props: ['sound', 'selectable'], + emits: ['click'], + template: + '' + }, + UIDropdown: { + template: '
' + }, + UITooltip: { + template: '
' + }, + UIMenu: { + template: '
' + }, + UIMenuItem: { + emits: ['click'], + template: '' + } + } + } + }) + + return { wrapper, animation, sounds: [sound1, sound2], doAction } +} + +describe('SoundEditor', () => { + it('hides playback selector until a sound is selected', () => { + const { wrapper } = mountSoundEditor() + + expect(wrapper.text()).not.toContain('Playback') + }) + + it('closes without saving on cancel', async () => { + const { wrapper, animation, doAction } = mountSoundEditor() + const originalSound = animation.sound + + await wrapper.findAll('.sound-item')[0].trigger('click') + const cancelButton = wrapper.findAll('button').find((button) => button.text() === 'Cancel') + + expect(cancelButton).toBeTruthy() + await cancelButton!.trigger('click') + + expect(doAction).not.toHaveBeenCalled() + expect(animation.sound).toBe(originalSound) + expect((wrapper.vm as any).closed).toBe(1) + }) + + it('shows playback selector after selecting a sound', async () => { + const { wrapper } = mountSoundEditor() + + await wrapper.findAll('.sound-item')[0].trigger('click') + await nextTick() + + expect(wrapper.text()).toContain('Playback') + expect(wrapper.get('select').text()).toContain('One') + expect(wrapper.get('select').text()).toContain('Loop') + expect(wrapper.text()).toContain('Play Once') + + await wrapper.get('select').setValue('follow-animation') + + expect(wrapper.text()).toContain('Loop with Animation') + }) + + it('saves selected sound and one-shot playback on confirm', async () => { + const { wrapper, animation, sounds, doAction } = mountSoundEditor() + + await wrapper.findAll('.sound-item')[0].trigger('click') + await wrapper.get('select').setValue('once') + await wrapper.get('form').trigger('submit') + + expect(doAction).toHaveBeenCalledTimes(1) + expect(animation.sound).toBe(sounds[0].id) + expect(animation.soundLoop).toBe(false) + expect((wrapper.vm as any).closed).toBe(1) + }) + + it('saves selected sound and follow-animation playback on confirm', async () => { + const { wrapper, animation, sounds, doAction } = mountSoundEditor() + + await wrapper.findAll('.sound-item')[1].trigger('click') + await wrapper.get('select').setValue('follow-animation') + await wrapper.get('form').trigger('submit') + + expect(doAction).toHaveBeenCalledTimes(1) + expect(animation.sound).toBe(sounds[1].id) + expect(animation.soundLoop).toBe(true) + expect((wrapper.vm as any).closed).toBe(1) + }) + + it('resets playback to one-shot when switching sounds', async () => { + const { wrapper, animation, sounds } = mountSoundEditor() + + await wrapper.findAll('.sound-item')[0].trigger('click') + await wrapper.get('select').setValue('follow-animation') + await wrapper.findAll('.sound-item')[1].trigger('click') + await wrapper.get('form').trigger('submit') + + expect(animation.sound).toBe(sounds[1].id) + expect(animation.soundLoop).toBe(false) + }) +}) diff --git a/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue b/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue index c140bbdcb..6782af4f2 100644 --- a/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue +++ b/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue @@ -2,11 +2,11 @@ -
    +
    + diff --git a/spx-gui/src/components/ui/UIDropdownForm.vue b/spx-gui/src/components/ui/UIDropdownForm.vue index f2273a4c9..11b9c8238 100644 --- a/spx-gui/src/components/ui/UIDropdownForm.vue +++ b/spx-gui/src/components/ui/UIDropdownForm.vue @@ -14,21 +14,26 @@
    -