diff --git a/web/src/components/__tests__/AddLibraryDialog.spec.js b/web/src/components/__tests__/AddLibraryDialog.spec.js
new file mode 100644
index 0000000..4d5ffc9
--- /dev/null
+++ b/web/src/components/__tests__/AddLibraryDialog.spec.js
@@ -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])
+ })
+})
diff --git a/web/src/components/__tests__/AudioPlayer.spec.js b/web/src/components/__tests__/AudioPlayer.spec.js
new file mode 100644
index 0000000..1fae2a6
--- /dev/null
+++ b/web/src/components/__tests__/AudioPlayer.spec.js
@@ -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')
+ })
+})
diff --git a/web/src/components/__tests__/DropDown.spec.js b/web/src/components/__tests__/DropDown.spec.js
new file mode 100644
index 0000000..c6afedf
--- /dev/null
+++ b/web/src/components/__tests__/DropDown.spec.js
@@ -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: '',
+ default: '',
+ },
+ 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))
+ })
+})
diff --git a/web/src/components/__tests__/LibraryCard.spec.js b/web/src/components/__tests__/LibraryCard.spec.js
new file mode 100644
index 0000000..c9bddb7
--- /dev/null
+++ b/web/src/components/__tests__/LibraryCard.spec.js
@@ -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)
+ })
+})
diff --git a/web/src/stores/__tests__/butterfliu.spec.js b/web/src/stores/__tests__/butterfliu.spec.js
new file mode 100644
index 0000000..bae1a90
--- /dev/null
+++ b/web/src/stores/__tests__/butterfliu.spec.js
@@ -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)
+ })
+ })
+})
diff --git a/web/src/stores/__tests__/player.spec.js b/web/src/stores/__tests__/player.spec.js
new file mode 100644
index 0000000..dca337c
--- /dev/null
+++ b/web/src/stores/__tests__/player.spec.js
@@ -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)
+ })
+ })
+})
diff --git a/web/src/test/setup.js b/web/src/test/setup.js
new file mode 100644
index 0000000..4c0a10c
--- /dev/null
+++ b/web/src/test/setup.js
@@ -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()
+})
diff --git a/web/vitest.config.js b/web/vitest.config.js
index c328717..8d51c5b 100644
--- a/web/vitest.config.js
+++ b/web/vitest.config.js
@@ -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'],
},
}),
)