From d8f9bbbf606ec98ede03ba5ec53c553ef8170a79 Mon Sep 17 00:00:00 2001 From: qingqing-ux Date: Wed, 27 May 2026 22:59:36 +0800 Subject: [PATCH 1/6] feat: add animation sound playback selector demo --- .../sprite/animation/SoundEditor.test.ts | 144 ++++++++++++++++++ .../editor/sprite/animation/SoundEditor.vue | 77 ++++++++-- 2 files changed, 212 insertions(+), 9 deletions(-) create mode 100644 spx-gui/src/components/editor/sprite/animation/SoundEditor.test.ts 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 0000000000..9dea11a467 --- /dev/null +++ b/spx-gui/src/components/editor/sprite/animation/SoundEditor.test.ts @@ -0,0 +1,144 @@ +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('Follow 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) + }) +}) diff --git a/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue b/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue index c140bbdcb1..6249daae2f 100644 --- a/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue +++ b/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue @@ -2,11 +2,12 @@ -
    +
    +
    + + {{ $t({ en: 'Playback', zh: '播放' }) }} + + + {{ $t(playbackTooltip) }} + + +
    From 4599b78d14574ea9757b8b8535bfec576176de98 Mon Sep 17 00:00:00 2001 From: qingqing-ux Date: Wed, 27 May 2026 23:10:58 +0800 Subject: [PATCH 2/6] fix: refine animation sound playback selector --- .../components/editor/sprite/animation/SoundEditor.test.ts | 7 ++++++- .../src/components/editor/sprite/animation/SoundEditor.vue | 5 ++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/spx-gui/src/components/editor/sprite/animation/SoundEditor.test.ts b/spx-gui/src/components/editor/sprite/animation/SoundEditor.test.ts index 9dea11a467..ff44c8e01e 100644 --- a/spx-gui/src/components/editor/sprite/animation/SoundEditor.test.ts +++ b/spx-gui/src/components/editor/sprite/animation/SoundEditor.test.ts @@ -113,7 +113,12 @@ describe('SoundEditor', () => { expect(wrapper.text()).toContain('Playback') expect(wrapper.get('select').text()).toContain('One') - expect(wrapper.get('select').text()).toContain('Follow animation') + 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 () => { diff --git a/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue b/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue index 6249daae2f..8d384576d0 100644 --- a/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue +++ b/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue @@ -56,14 +56,13 @@ desc: 'Select how the selected sound plays with the animation' }" :value="selectedPlayback" - class="w-[118px] px-3" @update:value="handlePlaybackUpdate" > {{ $t({ en: 'One', zh: '一次' }) }} - {{ $t({ en: 'Follow animation', zh: '跟随动画' }) }} + {{ $t({ en: 'Loop', zh: '循环' }) }} @@ -108,7 +107,7 @@ type Playback = 'once' | 'follow-animation' const selectedPlayback = ref(props.animation.soundLoop ? 'follow-animation' : 'once') const playbackTooltip = computed(() => selectedPlayback.value === 'follow-animation' - ? { en: 'Follow animation', zh: '跟随动画' } + ? { en: 'Loop with Animation', zh: '随动画循环' } : { en: 'Play Once', zh: '播放一次' } ) From 50eb7b031c9ad251b20af5705f5cdc1a6774ff4f Mon Sep 17 00:00:00 2001 From: qingqing-ux Date: Wed, 27 May 2026 23:19:01 +0800 Subject: [PATCH 3/6] fix: stabilize playback selector width --- .../src/components/editor/sprite/animation/SoundEditor.test.ts | 1 + spx-gui/src/components/editor/sprite/animation/SoundEditor.vue | 1 + 2 files changed, 2 insertions(+) diff --git a/spx-gui/src/components/editor/sprite/animation/SoundEditor.test.ts b/spx-gui/src/components/editor/sprite/animation/SoundEditor.test.ts index ff44c8e01e..c9fa3f14ff 100644 --- a/spx-gui/src/components/editor/sprite/animation/SoundEditor.test.ts +++ b/spx-gui/src/components/editor/sprite/animation/SoundEditor.test.ts @@ -112,6 +112,7 @@ describe('SoundEditor', () => { await nextTick() expect(wrapper.text()).toContain('Playback') + expect(wrapper.get('.ui-select').classes()).toContain('min-w-[80px]') expect(wrapper.get('select').text()).toContain('One') expect(wrapper.get('select').text()).toContain('Loop') expect(wrapper.text()).toContain('Play Once') diff --git a/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue b/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue index 8d384576d0..c24e2ff9ff 100644 --- a/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue +++ b/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue @@ -56,6 +56,7 @@ desc: 'Select how the selected sound plays with the animation' }" :value="selectedPlayback" + class="min-w-[80px]" @update:value="handlePlaybackUpdate" > From 03c95170e00c3454c93542581c95a2608861aa89 Mon Sep 17 00:00:00 2001 From: qingqing-ux Date: Wed, 27 May 2026 23:28:07 +0800 Subject: [PATCH 4/6] fix: move playback selector into dropdown footer --- .../editor/sprite/animation/SoundEditor.vue | 55 ++++++++++--------- spx-gui/src/components/ui/UIDropdownForm.vue | 35 +++++++----- 2 files changed, 48 insertions(+), 42 deletions(-) diff --git a/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue b/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue index c24e2ff9ff..7201e4d506 100644 --- a/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue +++ b/spx-gui/src/components/editor/sprite/animation/SoundEditor.vue @@ -2,7 +2,6 @@
-
- - {{ $t({ en: 'Playback', zh: '播放' }) }} - - - {{ $t(playbackTooltip) }} - - -
+
diff --git a/spx-gui/src/components/ui/UIDropdownForm.vue b/spx-gui/src/components/ui/UIDropdownForm.vue index f2273a4c92..11b9c82382 100644 --- a/spx-gui/src/components/ui/UIDropdownForm.vue +++ b/spx-gui/src/components/ui/UIDropdownForm.vue @@ -14,21 +14,26 @@
-