前端添加单元测试
Some checks failed
Go CI / test-and-build (push) Successful in 11s
Web CI / lint-test-build (push) Failing after 14s

This commit is contained in:
2026-04-06 14:48:21 +08:00
parent d5e0c4a0db
commit b98abab120
8 changed files with 989 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import AddLibraryDialog from '../AddLibraryDialog.vue'
describe('AddLibraryDialog.vue', () => {
it('should not render when modelValue is false', () => {
const wrapper = mount(AddLibraryDialog, {
props: { modelValue: false },
})
expect(wrapper.find('.dialog-overlay').exists()).toBe(false)
})
it('should render when modelValue is true', () => {
const wrapper = mount(AddLibraryDialog, {
props: { modelValue: true },
})
expect(wrapper.find('.dialog-overlay').exists()).toBe(true)
expect(wrapper.find('.dialog-header h3').text()).toBe('添加音乐库')
})
it('should reset form when opened', async () => {
const wrapper = mount(AddLibraryDialog, {
props: { modelValue: false },
})
await wrapper.setProps({ modelValue: true })
expect(wrapper.find('#lib-name').element.value).toBe('')
expect(wrapper.find('#lib-path').element.value).toBe('')
})
it('should emit confirm with name and path on confirm', async () => {
const wrapper = mount(AddLibraryDialog, {
props: { modelValue: true },
})
await wrapper.find('#lib-name').setValue('My Library')
await wrapper.find('#lib-path').setValue('/music')
const confirmBtn = wrapper.findAll('.btn')[1] // 确认添加 button
await confirmBtn.trigger('click')
expect(wrapper.emitted('confirm')).toBeTruthy()
expect(wrapper.emitted('confirm')[0]).toEqual(['My Library', '/music'])
})
it('should emit update:modelValue(false) on cancel', async () => {
const wrapper = mount(AddLibraryDialog, {
props: { modelValue: true },
})
const cancelBtn = wrapper.find('.btn-ghost')
await cancelBtn.trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')[0]).toEqual([false])
})
it('should have disabled confirm button when form is empty', () => {
const wrapper = mount(AddLibraryDialog, {
props: { modelValue: true },
})
const confirmBtn = wrapper.findAll('.btn')[1]
expect(confirmBtn.attributes('disabled')).toBeDefined()
})
it('should enable confirm button when both fields are filled', async () => {
const wrapper = mount(AddLibraryDialog, {
props: { modelValue: true },
})
await wrapper.find('#lib-name').setValue('Name')
await wrapper.find('#lib-path').setValue('/path')
const confirmBtn = wrapper.findAll('.btn')[1]
expect(confirmBtn.attributes('disabled')).toBeUndefined()
})
it('should disable confirm button if only name is filled', async () => {
const wrapper = mount(AddLibraryDialog, {
props: { modelValue: true },
})
await wrapper.find('#lib-name').setValue('Name')
const confirmBtn = wrapper.findAll('.btn')[1]
expect(confirmBtn.attributes('disabled')).toBeDefined()
})
it('should disable confirm button if only path is filled', async () => {
const wrapper = mount(AddLibraryDialog, {
props: { modelValue: true },
})
await wrapper.find('#lib-path').setValue('/path')
const confirmBtn = wrapper.findAll('.btn')[1]
expect(confirmBtn.attributes('disabled')).toBeDefined()
})
it('should emit update:modelValue(false) on dialog close event', async () => {
const wrapper = mount(AddLibraryDialog, {
props: { modelValue: true },
})
const dialog = wrapper.find('dialog')
await dialog.trigger('close')
expect(wrapper.emitted('update:modelValue')[0]).toEqual([false])
})
})

View File

@@ -0,0 +1,142 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import AudioPlayer from '../AudioPlayer.vue'
import { usePlayerStore } from '@/stores/player'
describe('AudioPlayer.vue', () => {
function setup(options = {}) {
const pinia = createPinia()
setActivePinia(pinia)
const store = usePlayerStore()
if (options.song) {
store.song = options.song
}
if (options.currentTime !== undefined) {
store.currentTime = options.currentTime
}
if (options.duration !== undefined) {
store.duration = options.duration
}
if (options.volume !== undefined) {
store.volume = options.volume
}
if (options.isMuted !== undefined) {
store.isMuted = options.isMuted
}
if (options.isPlaying !== undefined) {
store.isPlaying = options.isPlaying
}
return {
wrapper: mount(AudioPlayer, { global: { plugins: [pinia] } }),
store,
}
}
it('should render default track info when no song', () => {
const { wrapper } = setup()
expect(wrapper.find('.track-title').text()).toBe('未播放')
expect(wrapper.find('.track-artist').text()).toBe('-')
})
it('should render song info when a song is playing', () => {
const { wrapper } = setup({
song: { id: 1, title: 'Test Song', artist: 'Test Artist' },
})
expect(wrapper.find('.track-title').text()).toBe('Test Song')
expect(wrapper.find('.track-artist').text()).toBe('Test Artist')
})
it('should show formatted time', () => {
const { wrapper } = setup({
currentTime: 65,
duration: 200,
})
const times = wrapper.findAll('.time')
expect(times[0].text()).toBe('1:05')
expect(times[1].text()).toBe('3:20')
})
it('should render progress bar with correct value', () => {
const { wrapper } = setup({
currentTime: 50,
duration: 200,
})
const progressBar = wrapper.find('.progress-bar')
expect(progressBar.element.value).toBe('25')
})
it('should call seekTo when progress bar changes', async () => {
const { wrapper, store } = setup()
const seekSpy = vi.spyOn(store, 'seekTo')
const progressBar = wrapper.find('.progress-bar')
await progressBar.setValue(50)
expect(seekSpy).toHaveBeenCalledWith(50)
})
it('should call setVolume when volume slider changes', async () => {
const { wrapper, store } = setup()
const volumeSpy = vi.spyOn(store, 'setVolume')
const volumeSlider = wrapper.find('.volume-slider')
await volumeSlider.setValue(40)
expect(volumeSpy).toHaveBeenCalledWith(40)
})
it('should call togglePlay when play button is clicked', async () => {
const { wrapper, store } = setup()
const toggleSpy = vi.spyOn(store, 'togglePlay')
await wrapper.find('.play-btn').trigger('click')
expect(toggleSpy).toHaveBeenCalled()
})
it('should call toggleMute when volume button is clicked', async () => {
const { wrapper, store } = setup()
const muteSpy = vi.spyOn(store, 'toggleMute')
await wrapper.find('.volume-btn').trigger('click')
expect(muteSpy).toHaveBeenCalled()
})
it('should call seekBackward when skip back button is clicked', async () => {
const { wrapper, store } = setup()
const seekSpy = vi.spyOn(store, 'seekBackward')
const buttons = wrapper.findAll('.control-btn')
await buttons[0].trigger('click')
expect(seekSpy).toHaveBeenCalled()
})
it('should call seekForward when skip forward button is clicked', async () => {
const { wrapper, store } = setup()
const seekSpy = vi.spyOn(store, 'seekForward')
const buttons = wrapper.findAll('.control-btn')
await buttons[1].trigger('click')
expect(seekSpy).toHaveBeenCalled()
})
it('should show volume slider value as 0 when muted', () => {
const { wrapper } = setup({
volume: 50,
isMuted: true,
})
const volumeSlider = wrapper.find('.volume-slider')
expect(volumeSlider.element.value).toBe('0')
})
it('should show volume slider value as current volume when not muted', () => {
const { wrapper } = setup({
volume: 70,
isMuted: false,
})
const volumeSlider = wrapper.find('.volume-slider')
expect(volumeSlider.element.value).toBe('70')
})
})

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import DropDown from '../DropDown.vue'
describe('DropDown.vue', () => {
function createWrapper() {
return mount(DropDown, {
slots: {
trigger: '<button class="my-trigger">Open</button>',
default: '<button class="item">Item 1</button>',
},
attachTo: document.body,
})
}
it('should render trigger slot content', () => {
const wrapper = createWrapper()
expect(wrapper.find('.my-trigger').exists()).toBe(true)
expect(wrapper.find('.my-trigger').text()).toBe('Open')
wrapper.unmount()
})
it('should render default slot content in dropdown menu', async () => {
const wrapper = createWrapper()
await wrapper.find('.dropdown-trigger').trigger('click')
expect(wrapper.find('.dropdown-menu .item').exists()).toBe(true)
expect(wrapper.find('.dropdown-menu .item').text()).toBe('Item 1')
wrapper.unmount()
})
it('should toggle dropdown when trigger is clicked', async () => {
const wrapper = createWrapper()
expect(wrapper.find('.dropdown-menu').exists()).toBe(false)
await wrapper.find('.dropdown-trigger').trigger('click')
expect(wrapper.find('.dropdown-menu').exists()).toBe(true)
await wrapper.find('.dropdown-trigger').trigger('click')
expect(wrapper.find('.dropdown-menu').exists()).toBe(false)
wrapper.unmount()
})
it('should close dropdown when clicking outside', async () => {
const wrapper = createWrapper()
await wrapper.find('.dropdown-trigger').trigger('click')
expect(wrapper.find('.dropdown-menu').exists()).toBe(true)
const outsideEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
})
document.dispatchEvent(outsideEvent)
await flushPromises()
expect(wrapper.find('.dropdown-menu').exists()).toBe(false)
wrapper.unmount()
})
it('should close dropdown on Escape key', async () => {
const wrapper = createWrapper()
await wrapper.find('.dropdown-trigger').trigger('click')
expect(wrapper.find('.dropdown-menu').exists()).toBe(true)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await flushPromises()
expect(wrapper.find('.dropdown-menu').exists()).toBe(false)
wrapper.unmount()
})
it('should not close on other key presses', async () => {
const wrapper = createWrapper()
await wrapper.find('.dropdown-trigger').trigger('click')
expect(wrapper.find('.dropdown-menu').exists()).toBe(true)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(wrapper.find('.dropdown-menu').exists()).toBe(true)
wrapper.unmount()
})
it('should clean up event listeners on unmount', () => {
const removeSpy = vi.spyOn(document, 'removeEventListener')
const wrapper = createWrapper()
wrapper.unmount()
expect(removeSpy).toHaveBeenCalledWith('click', expect.any(Function))
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
})
})

View File

@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import LibraryCard from '../LibraryCard.vue'
describe('LibraryCard.vue', () => {
function setup(props = {}) {
return mount(LibraryCard, {
props: {
id: 1,
name: 'My Library',
...props,
},
})
}
it('should render library name', () => {
const wrapper = setup()
expect(wrapper.find('.card-name').text()).toBe('My Library')
})
it('should render the folder icon', () => {
const wrapper = setup()
expect(wrapper.find('.card-icon').exists()).toBe(true)
})
it('should emit scan event with id when scan button clicked', async () => {
const wrapper = setup()
// Need to open dropdown first to reveal buttons
await wrapper.find('.card-menu-btn').trigger('click')
const buttons = wrapper.findAll('.dropdown-menu button')
// Second button is "扫描"
await buttons[1].trigger('click')
expect(wrapper.emitted('scan')).toBeTruthy()
expect(wrapper.emitted('scan')[0]).toEqual([1])
})
it('should emit delete event with id when delete button clicked', async () => {
const wrapper = setup()
await wrapper.find('.card-menu-btn').trigger('click')
const buttons = wrapper.findAll('.dropdown-menu button')
// Third button (type=reset) is "删除"
await buttons[2].trigger('click')
expect(wrapper.emitted('delete')).toBeTruthy()
expect(wrapper.emitted('delete')[0]).toEqual([1])
})
it('should emit viewSongs event with id when view button clicked', async () => {
const wrapper = setup()
await wrapper.find('.card-menu-btn').trigger('click')
const buttons = wrapper.findAll('.dropdown-menu button')
// First button is "查看歌曲"
await buttons[0].trigger('click')
expect(wrapper.emitted('viewSongs')).toBeTruthy()
expect(wrapper.emitted('viewSongs')[0]).toEqual([1])
})
it('should render with correct id prop', () => {
const wrapper = setup({ id: 42 })
expect(wrapper.props('id')).toBe(42)
})
})

View File

@@ -0,0 +1,224 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useButterfliuStore } from '../butterfliu'
describe('butterfliu store', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
describe('fetchLibraries', () => {
it('should fetch and set libraries on success', async () => {
const mockLibs = [{ id: 1, name: 'Test Lib', path: '/music' }]
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockLibs),
})
const store = useButterfliuStore()
await store.fetchLibraries()
expect(store.libraries).toEqual(mockLibs)
expect(store.error).toBe(null)
})
it('should set error on failure', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
})
const store = useButterfliuStore()
await store.fetchLibraries()
expect(store.error).toBe('HTTP 500')
})
it('should set error on network failure', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
const store = useButterfliuStore()
await store.fetchLibraries()
expect(store.error).toBe('Network error')
})
})
describe('scanLibrary', () => {
it('should POST to scan endpoint and return result', async () => {
const mockResult = { added: 5, updated: 2 }
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResult),
})
const store = useButterfliuStore()
const result = await store.scanLibrary(1)
expect(result).toEqual(mockResult)
expect(global.fetch).toHaveBeenCalledWith('/api/libraries/1/scan', {
method: 'POST',
})
})
it('should set error on failure', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
})
const store = useButterfliuStore()
const result = await store.scanLibrary(99)
expect(store.error).toBe('HTTP 404')
expect(result).toBeUndefined()
})
})
describe('addLibrary', () => {
it('should POST to libraries endpoint with name and path', async () => {
const mockLib = { id: 1, name: 'New Lib', path: '/new' }
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockLib),
})
const store = useButterfliuStore()
const result = await store.addLibrary('New Lib', '/new')
expect(result).toEqual(mockLib)
expect(global.fetch).toHaveBeenCalledWith('/api/libraries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New Lib', path: '/new' }),
})
})
it('should set error on failure', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
})
const store = useButterfliuStore()
const result = await store.addLibrary('Bad', '/path')
expect(store.error).toBe('HTTP 400')
expect(result).toBeUndefined()
})
})
describe('deleteLibrary', () => {
it('should DELETE the library', async () => {
const mockResult = { success: true }
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResult),
})
const store = useButterfliuStore()
const result = await store.deleteLibrary(1)
expect(result).toEqual(mockResult)
expect(global.fetch).toHaveBeenCalledWith('/api/libraries/1', {
method: 'DELETE',
})
})
it('should set error on failure', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
})
const store = useButterfliuStore()
const result = await store.deleteLibrary(1)
expect(store.error).toBe('HTTP 500')
expect(result).toBeUndefined()
})
})
describe('fetchLibrarySongs', () => {
it('should fetch songs for a library', async () => {
const mockSongs = [{ id: 1, title: 'Song 1' }]
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockSongs),
})
const store = useButterfliuStore()
const songs = await store.fetchLibrarySongs(1)
expect(songs).toEqual(mockSongs)
})
it('should throw on failure', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
})
const store = useButterfliuStore()
await expect(store.fetchLibrarySongs(1)).rejects.toThrow('HTTP 500')
})
})
describe('fetchAllSongs', () => {
it('should fetch all songs', async () => {
const mockSongs = [
{ id: 1, title: 'Song 1' },
{ id: 2, title: 'Song 2' },
]
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockSongs),
})
const store = useButterfliuStore()
const songs = await store.fetchAllSongs()
expect(songs).toEqual(mockSongs)
expect(global.fetch).toHaveBeenCalledWith('/api/songs')
})
it('should throw on failure', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
})
const store = useButterfliuStore()
await expect(store.fetchAllSongs()).rejects.toThrow('HTTP 503')
})
})
describe('fetchArtists', () => {
it('should fetch artists', async () => {
const mockArtists = ['Artist 1', 'Artist 2']
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockArtists),
})
const store = useButterfliuStore()
const artists = await store.fetchArtists()
expect(artists).toEqual(mockArtists)
})
})
describe('fetchAlbums', () => {
it('should fetch albums', async () => {
const mockAlbums = ['Album 1', 'Album 2']
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockAlbums),
})
const store = useButterfliuStore()
const albums = await store.fetchAlbums()
expect(albums).toEqual(mockAlbums)
})
})
})

View File

@@ -0,0 +1,287 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { usePlayerStore } from '../player'
describe('player store', () => {
beforeEach(() => {
localStorage.clear()
})
describe('initial state', () => {
it('should have correct defaults', () => {
const store = usePlayerStore()
expect(store.currentTime).toBe(0)
expect(store.duration).toBe(0)
expect(store.volume).toBe(80)
expect(store.isMuted).toBe(false)
expect(store.isPlaying).toBe(false)
expect(store.song).toBe(null)
expect(store.playlist).toEqual([])
})
it('should compute progress as 0 when duration is 0', () => {
const store = usePlayerStore()
expect(store.progress).toBe(0)
})
it('should compute currentTrack defaults', () => {
const store = usePlayerStore()
expect(store.currentTrack).toEqual({ title: '未播放', artist: '-' })
})
})
describe('formatTime', () => {
it('should format seconds correctly', () => {
const store = usePlayerStore()
expect(store.formatTime(0)).toBe('0:00')
expect(store.formatTime(65)).toBe('1:05')
expect(store.formatTime(120)).toBe('2:00')
expect(store.formatTime(3661)).toBe('61:01')
})
it('should handle invalid input', () => {
const store = usePlayerStore()
expect(store.formatTime(NaN)).toBe('0:00')
expect(store.formatTime(undefined)).toBe('0:00')
})
})
describe('playSong', () => {
it('should set the song and create audio', () => {
const store = usePlayerStore()
const song = { id: 1, title: 'Test Song', artist: 'Test Artist' }
store.playSong(song)
expect(store.song).toEqual(song)
expect(store.audio).not.toBeNull()
expect(store.audio.src).toContain('/api/songs/1/stream')
})
it('should reset currentTime to 0', () => {
const store = usePlayerStore()
store.currentTime = 50
store.playSong({ id: 1, title: 'Test' })
expect(store.currentTime).toBe(0)
})
it('should set playlist when provided', () => {
const store = usePlayerStore()
const playlist = [
{ id: 1, title: 'Song 1' },
{ id: 2, title: 'Song 2' },
]
store.playSong(playlist[0], playlist)
expect(store.playlist).toEqual(playlist)
})
})
describe('togglePlay', () => {
it('should call pause when playing', () => {
const store = usePlayerStore()
const song = { id: 1, title: 'Test' }
store.playSong(song)
const pauseSpy = vi.spyOn(store.audio, 'pause')
store.togglePlay()
expect(pauseSpy).toHaveBeenCalled()
})
it('should call play when paused', () => {
const store = usePlayerStore()
const song = { id: 1, title: 'Test' }
store.playSong(song)
// After play, isPlaying should be true via event
store.isPlaying = false
const playSpy = vi.spyOn(store.audio, 'play')
store.togglePlay()
expect(playSpy).toHaveBeenCalled()
})
it('should do nothing when no audio', () => {
const store = usePlayerStore()
expect(() => store.togglePlay()).not.toThrow()
})
})
describe('seekTo', () => {
it('should set audio currentTime', () => {
const store = usePlayerStore()
store.playSong({ id: 1, title: 'Test' })
store.duration = 200
store.seekTo(50)
expect(store.audio.currentTime).toBe(100)
})
it('should do nothing when no audio', () => {
const store = usePlayerStore()
expect(() => store.seekTo(50)).not.toThrow()
})
})
describe('seekForward and seekBackward', () => {
it('should move forward by given seconds', () => {
const store = usePlayerStore()
store.playSong({ id: 1, title: 'Test' })
store.audio.currentTime = 30
store.duration = 200
store.seekForward(10)
expect(store.audio.currentTime).toBe(40)
})
it('should not exceed duration', () => {
const store = usePlayerStore()
store.playSong({ id: 1, title: 'Test' })
store.audio.currentTime = 195
store.duration = 200
store.seekForward(10)
expect(store.audio.currentTime).toBe(200)
})
it('should move backward by given seconds', () => {
const store = usePlayerStore()
store.playSong({ id: 1, title: 'Test' })
store.audio.currentTime = 30
store.seekBackward(10)
expect(store.audio.currentTime).toBe(20)
})
it('should not go below 0', () => {
const store = usePlayerStore()
store.playSong({ id: 1, title: 'Test' })
store.audio.currentTime = 5
store.seekBackward(10)
expect(store.audio.currentTime).toBe(0)
})
})
describe('setVolume', () => {
it('should update volume', () => {
const store = usePlayerStore()
store.setVolume(50)
expect(store.volume).toBe(50)
})
it('should unmute if volume set above 0', () => {
const store = usePlayerStore()
store.isMuted = true
store.setVolume(50)
expect(store.isMuted).toBe(false)
})
it('should update audio volume', () => {
const store = usePlayerStore()
store.playSong({ id: 1, title: 'Test' })
store.setVolume(50)
expect(store.audio.volume).toBe(0.5)
})
})
describe('toggleMute', () => {
it('should toggle isMuted', () => {
const store = usePlayerStore()
expect(store.isMuted).toBe(false)
store.toggleMute()
expect(store.isMuted).toBe(true)
store.toggleMute()
expect(store.isMuted).toBe(false)
})
it('should set audio volume to 0 when muted', () => {
const store = usePlayerStore()
store.playSong({ id: 1, title: 'Test' })
store.toggleMute()
expect(store.audio.volume).toBe(0)
})
it('should restore volume when unmuted', () => {
const store = usePlayerStore()
store.playSong({ id: 1, title: 'Test' })
store.volume = 60
store.toggleMute() // mute
store.toggleMute() // unmute
expect(store.audio.volume).toBe(0.6)
})
})
describe('playNext and playPrevious', () => {
it('should play next song in playlist', () => {
const store = usePlayerStore()
const playlist = [
{ id: 1, title: 'Song 1' },
{ id: 2, title: 'Song 2' },
{ id: 3, title: 'Song 3' },
]
store.playSong(playlist[0], playlist)
store.playNext()
expect(store.song.id).toBe(2)
})
it('should wrap around at end of playlist', () => {
const store = usePlayerStore()
const playlist = [
{ id: 1, title: 'Song 1' },
{ id: 2, title: 'Song 2' },
]
store.playSong(playlist[1], playlist)
store.playNext()
expect(store.song.id).toBe(1)
})
it('should play previous song in playlist', () => {
const store = usePlayerStore()
const playlist = [
{ id: 1, title: 'Song 1' },
{ id: 2, title: 'Song 2' },
]
store.playSong(playlist[1], playlist)
store.playPrevious()
expect(store.song.id).toBe(1)
})
it('should wrap to last song when at beginning', () => {
const store = usePlayerStore()
const playlist = [
{ id: 1, title: 'Song 1' },
{ id: 2, title: 'Song 2' },
]
store.playSong(playlist[0], playlist)
store.playPrevious()
expect(store.song.id).toBe(2)
})
it('should do nothing with empty playlist', () => {
const store = usePlayerStore()
expect(() => store.playNext()).not.toThrow()
expect(() => store.playPrevious()).not.toThrow()
})
})
describe('localStorage persistence', () => {
it('should save state to localStorage on playback', () => {
const store = usePlayerStore()
store.playSong({ id: 1, title: 'Test', artist: 'Artist' })
expect(localStorage.setItem).toHaveBeenCalled()
})
it('should restore state from localStorage', () => {
const savedState = {
song: { id: 42, title: 'Restored Song', artist: 'Artist' },
currentTime: 120,
volume: 60,
isMuted: true,
playlist: [{ id: 42, title: 'Restored Song', artist: 'Artist' }],
}
localStorage.setItem('butterfliu_player_state', JSON.stringify(savedState))
const store = usePlayerStore()
store.restorePlaybackState()
expect(store.song.id).toBe(42)
expect(store.volume).toBe(60)
expect(store.isMuted).toBe(true)
})
it('should handle no saved state', () => {
const store = usePlayerStore()
expect(() => store.restorePlaybackState()).not.toThrow()
expect(store.song).toBe(null)
})
})
})

60
web/src/test/setup.js Normal file
View File

@@ -0,0 +1,60 @@
import { beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
// Mock localStorage
const localStorageMock = (() => {
let store = {}
return {
getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key, value) => { store[key] = String(value) }),
removeItem: vi.fn((key) => { delete store[key] }),
clear: vi.fn(() => { store = {} }),
}
})()
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
// Mock Audio for test environment
class MockAudio {
constructor(src) {
this.src = src
this.volume = 0.8
this.currentTime = 0
this.duration = 200
this.paused = true
}
play() {
this.paused = false
this.dispatchEvent(new Event('play'))
return Promise.resolve()
}
pause() {
this.paused = true
this.dispatchEvent(new Event('pause'))
return Promise.resolve()
}
addEventListener(event, handler, options) {
if (!this._listeners) this._listeners = {}
if (!this._listeners[event]) this._listeners[event] = []
this._listeners[event].push({ handler, options })
}
removeEventListener(event, handler) {
if (!this._listeners) return
this._listeners[event] = this._listeners[event]?.filter(l => l.handler !== handler)
}
dispatchEvent(event) {
if (!this._listeners) return
const listeners = this._listeners[event.type] || []
listeners.forEach(({ handler }) => handler.call(this, event))
}
_fireLoadedmetadata() {
this.dispatchEvent(new Event('loadedmetadata'))
}
}
global.Audio = MockAudio
beforeEach(() => {
setActivePinia(createPinia())
localStorageMock.clear()
})

View File

@@ -9,6 +9,7 @@ export default mergeConfig(
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
setupFiles: ['src/test/setup.js'],
},
}),
)