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'], }, }), )