Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions spx-gui/src/components/editor/sprite/animation/SoundEditor.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@/utils/exception')>()),
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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

animationInits?.sound ?? undefined converts a potential null to undefined unnecessarily. Per project conventions, null represents absence — use ?? null here:

Suggested change
project.addSound(sound2)
sound: animationInits?.sound ?? null,


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: '<SoundEditor :animation="animation" @close="closed += 1" />'
})

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:
'<button class="sound-item" :data-selected="selectable && selectable.selected" @click="$emit(\'click\')">{{ sound.name }}</button>'
},
UIDropdown: {
template: '<div><slot name="trigger"></slot><slot></slot></div>'
},
UITooltip: {
template: '<div class="tooltip"><slot name="trigger"></slot><slot></slot></div>'
},
UIMenu: {
template: '<div><slot></slot></div>'
},
UIMenuItem: {
emits: ['click'],
template: '<button type="button" @click="$emit(\'click\')"><slot></slot></button>'
}
}
}
})

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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion couples the test to UISelect's internal root class name (.ui-select). If UISelect is ever refactored, this line breaks for a reason unrelated to SoundEditor. Consider removing it or scoping it to just expect(wrapper.find('.ui-select').exists()).toBe(true) to verify the select is rendered, not its internal class structure.

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)
})
})
78 changes: 69 additions & 9 deletions spx-gui/src/components/editor/sprite/animation/SoundEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
<UIDropdownForm
v-radar="{ name: 'Sound editor dropdown form', desc: 'Dropdown form for selecting animation sound' }"
:title="$t(actionName)"
style="width: 320px; max-height: 400px"
style="width: 408px; max-height: 400px"
@cancel="emit('close')"
@confirm="handleConfirm"
>
<ul class="flex-[1_1_0] flex flex-wrap content-start gap-3">
<ul class="flex-[1_1_0] flex flex-wrap content-start gap-2">
Comment thread
nighca marked this conversation as resolved.
<SoundItem
v-for="sound in editorCtx.project.sounds"
:key="sound.id"
Expand Down Expand Up @@ -42,13 +42,51 @@
</UIMenu>
</UIDropdown>
</ul>
<template #footer-left>
<div v-if="selected != null" class="flex h-8 items-center gap-2">
<span class="text-base text-grey-900">
{{ $t({ en: 'Playback', zh: '播放' }) }}
</span>
<UITooltip>
{{ $t(playbackTooltip) }}
<template #trigger>
<UISelect
v-radar="{
name: 'Animation sound playback selector',
desc: 'Select how the selected sound plays with the animation'
}"
:value="selectedPlayback"
class="min-w-[80px]"
@update:value="handlePlaybackUpdate"
>
<UISelectOption value="once">
{{ $t({ en: 'One', zh: '一次' }) }}
</UISelectOption>
<UISelectOption value="follow-animation">
{{ $t({ en: 'Loop', zh: '循环' }) }}
</UISelectOption>
</UISelect>
</template>
</UITooltip>
</div>
</template>
</UIDropdownForm>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import type { Animation } from '@/models/spx/animation'
import { UIDropdownForm, UIDropdown, UIMenu, UIMenuItem, UIBlockItem, UIIcon } from '@/components/ui'
import {
UIDropdownForm,
UIDropdown,
UIMenu,
UIMenuItem,
UIBlockItem,
UIIcon,
UISelect,
UISelectOption,
UITooltip
} from '@/components/ui'
Comment on lines +86 to +89
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Remove the unused UITooltip import from @/components/ui since the tooltip wrapper has been removed.

  UISelect,
  UISelectOption
} from '@/components/ui'

import { useEditorCtx } from '@/components/editor/EditorContextProvider.vue'
import SoundItem from '@/components/editor/stage/sound/SoundItem.vue'
import { useAddAssetFromLibrary, useAddSoundFromLocalFile, useAddSoundByRecording } from '@/components/asset'
Expand All @@ -67,15 +105,34 @@ const editorCtx = useEditorCtx()

const actionName = { en: 'Select sound', zh: '选择声音' }
const selected = ref(props.animation.sound)
type Playback = 'once' | 'follow-animation'
const selectedPlayback = ref<Playback>(props.animation.soundLoop ? 'follow-animation' : 'once')
const playbackTooltip = computed(() =>
selectedPlayback.value === 'follow-animation'
? { en: 'Loop with Animation', zh: '随动画循环' }
: { en: 'Play Once', zh: '播放一次' }
)
Comment on lines +110 to +114
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since the UITooltip is removed, the playbackTooltip computed property is no longer needed and can be safely removed to clean up the code.


function selectSound(sound: string) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Playback mode resets to 'once' only on the very first selection (selected == null). When the user switches from one sound to another, the previous playback setting silently carries over. If this is intentional, please add a comment; otherwise reset selectedPlayback on every sound switch:

Suggested change
function selectSound(sound: string) {
function selectSound(sound: string) {
if (selected.value == null || selected.value !== sound) selectedPlayback.value = 'once'
selected.value = sound
}

if (selected.value !== sound) selectedPlayback.value = 'once'
selected.value = sound
}

function handlePlaybackUpdate(playback: string | null) {
if (playback !== 'once' && playback !== 'follow-animation') return
selectedPlayback.value = playback
}

async function handleSoundClick(sound: string) {
selected.value = selected.value === sound ? null : sound
if (selected.value === sound) selected.value = null
else selectSound(sound)
}

const addFromLocalFile = useAddSoundFromLocalFile()
const handleAddFromLocalFile = useMessageHandle(
async () => {
const sound = await addFromLocalFile(editorCtx.project)
selected.value = sound.id
selectSound(sound.id)
},
{
en: 'Failed to add sound from local file',
Expand All @@ -87,7 +144,7 @@ const addAssetFromLibrary = useAddAssetFromLibrary()
const handleAddFromAssetLibrary = useMessageHandle(
async () => {
const sounds = await addAssetFromLibrary(editorCtx.project, AssetType.Sound)
selected.value = sounds[0].id
selectSound(sounds[0].id)
},
{ en: 'Failed to add sound from asset library', zh: '从素材库添加失败' }
).fn
Expand All @@ -96,13 +153,16 @@ const addSoundFromRecording = useAddSoundByRecording()
const handleRecord = useMessageHandle(
async () => {
const sound = await addSoundFromRecording(editorCtx.project)
selected.value = sound.id
selectSound(sound.id)
},
{ en: 'Failed to record sound', zh: '录音失败' }
).fn

async function handleConfirm() {
await editorCtx.state.history.doAction({ name: actionName }, () => props.animation.setSound(selected.value))
await editorCtx.state.history.doAction({ name: actionName }, () => {
props.animation.setSound(selected.value)
props.animation.setSoundLoop(selectedPlayback.value === 'follow-animation')
})
emit('close')
}
</script>
35 changes: 20 additions & 15 deletions spx-gui/src/components/ui/UIDropdownForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,26 @@
<main class="flex-auto min-h-0 overflow-y-auto px-4 py-3">
<slot></slot>
</main>
<footer class="flex-none flex justify-end gap-3 p-4">
<UIButton
v-radar="{ name: 'Cancel button', desc: 'Click to cancel the operation in dropdown' }"
type="neutral"
@click="emit('cancel')"
>
{{ $t({ en: 'Cancel', zh: '取消' }) }}
</UIButton>
<UIButton
v-radar="{ name: 'Confirm button', desc: 'Click to submit the dropdown' }"
type="primary"
html-type="submit"
>
{{ $t({ en: 'Confirm', zh: '确认' }) }}
</UIButton>
<footer class="flex-none flex items-center justify-between gap-3 p-4">
<div class="min-w-0 flex items-center">
<slot name="footer-left"></slot>
</div>
<div class="flex justify-end gap-3">
<UIButton
v-radar="{ name: 'Cancel button', desc: 'Click to cancel the operation in dropdown' }"
type="neutral"
@click="emit('cancel')"
>
{{ $t({ en: 'Cancel', zh: '取消' }) }}
</UIButton>
<UIButton
v-radar="{ name: 'Confirm button', desc: 'Click to submit the dropdown' }"
type="primary"
html-type="submit"
>
{{ $t({ en: 'Confirm', zh: '确认' }) }}
</UIButton>
</div>
</footer>
</form>
</template>
Expand Down