前端添加单元测试
This commit is contained in:
110
web/src/components/__tests__/AddLibraryDialog.spec.js
Normal file
110
web/src/components/__tests__/AddLibraryDialog.spec.js
Normal 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])
|
||||
})
|
||||
})
|
||||
142
web/src/components/__tests__/AudioPlayer.spec.js
Normal file
142
web/src/components/__tests__/AudioPlayer.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
95
web/src/components/__tests__/DropDown.spec.js
Normal file
95
web/src/components/__tests__/DropDown.spec.js
Normal 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))
|
||||
})
|
||||
})
|
||||
70
web/src/components/__tests__/LibraryCard.spec.js
Normal file
70
web/src/components/__tests__/LibraryCard.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
224
web/src/stores/__tests__/butterfliu.spec.js
Normal file
224
web/src/stores/__tests__/butterfliu.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
287
web/src/stores/__tests__/player.spec.js
Normal file
287
web/src/stores/__tests__/player.spec.js
Normal 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
60
web/src/test/setup.js
Normal 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()
|
||||
})
|
||||
@@ -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'],
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user