test(mukke): add vitest setup and 34 frontend tests for player & library stores

- Set up vitest with jsdom, testing-library/svelte, and SvelteKit mocks
- Player store: 16 tests covering playSong, queue, shuffle, repeat,
  volume, error handling, clearQueue, removeFromQueue
- Library store: 18 tests covering loadSongs, loadCoverUrls (including
  non-image path filtering), albums, artists, genres, stats, favorites,
  tabs, upload, and delete

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-20 17:16:47 +01:00
parent 67326b738a
commit 70b1c4429d
9 changed files with 658 additions and 2 deletions

View file

@ -11,7 +11,9 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "prettier --write .",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@manacore/shared-pwa": "workspace:*",
@ -20,6 +22,7 @@
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",
"@testing-library/svelte": "^5.2.6",
"@types/node": "^20.0.0",
"@vite-pwa/sveltekit": "^1.1.0",
"prettier": "^3.1.1",
@ -29,7 +32,8 @@
"tailwindcss": "^4.1.7",
"tslib": "^2.4.1",
"typescript": "^5.9.3",
"vite": "^6.0.0"
"vite": "^6.0.0",
"vitest": "^4.1.0"
},
"dependencies": {
"@manacore/shared-api-client": "workspace:*",

View file

@ -0,0 +1,264 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock auth store
vi.mock('./auth.svelte', () => ({
authStore: {
getAuthHeaders: vi.fn().mockResolvedValue({ Authorization: 'Bearer test-token' }),
},
}));
let libraryStore: typeof import('./library.svelte').libraryStore;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
const mod = await import('./library.svelte');
libraryStore = mod.libraryStore;
});
function mockFetchResponse(data: unknown) {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(data),
});
}
function mockFetchError(message = 'Request failed') {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ message }),
});
}
describe('libraryStore', () => {
describe('initial state', () => {
it('starts empty', () => {
expect(libraryStore.songs).toEqual([]);
expect(libraryStore.albums).toEqual([]);
expect(libraryStore.artists).toEqual([]);
expect(libraryStore.genres).toEqual([]);
expect(libraryStore.stats).toBeNull();
expect(libraryStore.activeTab).toBe('songs');
expect(libraryStore.isLoading).toBe(false);
expect(libraryStore.error).toBeNull();
});
});
describe('loadSongs', () => {
it('fetches songs and sets state', async () => {
const songs = [
{ id: '1', title: 'Song 1', coverArtPath: null },
{ id: '2', title: 'Song 2', coverArtPath: null },
];
mockFetchResponse({ songs });
await libraryStore.loadSongs();
expect(libraryStore.songs).toEqual(songs);
expect(libraryStore.isLoading).toBe(false);
expect(libraryStore.error).toBeNull();
});
it('sets error on failure', async () => {
mockFetchError('Server error');
await libraryStore.loadSongs();
expect(libraryStore.songs).toEqual([]);
expect(libraryStore.error).toBe('Server error');
expect(libraryStore.isLoading).toBe(false);
});
it('loads cover URLs for songs with cover art', async () => {
const songs = [
{ id: '1', title: 'Song 1', coverArtPath: 'users/test/covers/1.jpg' },
{ id: '2', title: 'Song 2', coverArtPath: null },
];
mockFetchResponse({ songs });
// Cover URLs fetch
mockFetchResponse({ urls: { 'users/test/covers/1.jpg': 'https://minio.test/cover.jpg' } });
await libraryStore.loadSongs();
// Wait for cover URLs to load (async, non-blocking)
await vi.waitFor(() => {
expect(libraryStore.coverUrls['users/test/covers/1.jpg']).toBe(
'https://minio.test/cover.jpg'
);
});
});
});
describe('loadCoverUrls', () => {
it('filters out non-image paths', async () => {
// Should not make any fetch for .mp3 paths
await libraryStore.loadCoverUrls(['users/test/song.mp3', 'users/test/audio.wav']);
expect(global.fetch).not.toHaveBeenCalled();
});
it('loads valid image paths', async () => {
mockFetchResponse({
urls: { 'users/test/covers/1.jpg': 'https://minio.test/cover.jpg' },
});
await libraryStore.loadCoverUrls(['users/test/covers/1.jpg']);
expect(libraryStore.coverUrls['users/test/covers/1.jpg']).toBe(
'https://minio.test/cover.jpg'
);
});
it('does not refetch cached URLs', async () => {
mockFetchResponse({
urls: { 'users/test/covers/1.jpg': 'https://minio.test/cover.jpg' },
});
await libraryStore.loadCoverUrls(['users/test/covers/1.jpg']);
// Second call with same path should not fetch
await libraryStore.loadCoverUrls(['users/test/covers/1.jpg']);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('silently handles errors', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
await libraryStore.loadCoverUrls(['users/test/covers/1.png']);
// Should not throw or set error
expect(libraryStore.error).toBeNull();
});
it('accepts various image extensions', async () => {
const paths = [
'covers/1.jpg',
'covers/2.jpeg',
'covers/3.png',
'covers/4.webp',
'covers/5.gif',
'covers/6.avif',
'covers/7.svg',
];
const urls: Record<string, string> = {};
paths.forEach((p) => (urls[p] = `https://minio.test/${p}`));
mockFetchResponse({ urls });
await libraryStore.loadCoverUrls(paths);
expect(global.fetch).toHaveBeenCalledTimes(1);
paths.forEach((p) => {
expect(libraryStore.coverUrls[p]).toBe(`https://minio.test/${p}`);
});
});
});
describe('loadAlbums', () => {
it('fetches albums and sets state', async () => {
const albums = [{ album: 'Album 1', songCount: 5, coverArtPath: null }];
mockFetchResponse({ albums });
await libraryStore.loadAlbums();
expect(libraryStore.albums).toEqual(albums);
});
});
describe('loadArtists', () => {
it('fetches artists and sets state', async () => {
const artists = [{ artist: 'Artist 1', songCount: 3, albumCount: 1 }];
mockFetchResponse({ artists });
await libraryStore.loadArtists();
expect(libraryStore.artists).toEqual(artists);
});
});
describe('loadGenres', () => {
it('fetches genres and sets state', async () => {
const genres = [{ genre: 'Rock', songCount: 10 }];
mockFetchResponse({ genres });
await libraryStore.loadGenres();
expect(libraryStore.genres).toEqual(genres);
});
});
describe('loadStats', () => {
it('fetches stats and sets state', async () => {
const stats = { totalSongs: 50, totalArtists: 10, totalAlbums: 5, totalGenres: 3 };
mockFetchResponse({ stats });
await libraryStore.loadStats();
expect(libraryStore.stats).toEqual(stats);
});
});
describe('toggleFavorite', () => {
it('updates song in list', async () => {
const songs = [{ id: '1', title: 'Song 1', favorite: false, coverArtPath: null }];
mockFetchResponse({ songs });
await libraryStore.loadSongs();
mockFetchResponse({ song: { ...songs[0], favorite: true } });
await libraryStore.toggleFavorite('1');
expect(libraryStore.songs[0].favorite).toBe(true);
});
});
describe('setActiveTab', () => {
it('changes active tab', () => {
expect(libraryStore.activeTab).toBe('songs');
libraryStore.setActiveTab('albums');
expect(libraryStore.activeTab).toBe('albums');
});
it('triggers load for empty tabs', async () => {
mockFetchResponse({ albums: [] });
libraryStore.setActiveTab('albums');
// setActiveTab triggers an async load internally
await vi.waitFor(() => {
expect(global.fetch).toHaveBeenCalled();
});
expect(libraryStore.activeTab).toBe('albums');
});
});
describe('uploadSong', () => {
it('creates song and uploads file', async () => {
const song = { id: '1', title: 'New Song' };
mockFetchResponse({ song, uploadUrl: 'https://minio.test/upload' });
// Upload PUT
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const file = new File(['audio data'], 'song.mp3', { type: 'audio/mpeg' });
const result = await libraryStore.uploadSong(file);
expect(result).toEqual(song);
expect(libraryStore.songs).toContainEqual(song);
expect(global.fetch).toHaveBeenCalledTimes(2);
});
});
describe('deleteSong', () => {
it('removes song from list', async () => {
const songs = [
{ id: '1', title: 'Song 1', coverArtPath: null },
{ id: '2', title: 'Song 2', coverArtPath: null },
];
mockFetchResponse({ songs });
await libraryStore.loadSongs();
mockFetchResponse({});
await libraryStore.deleteSong('1');
expect(libraryStore.songs).toHaveLength(1);
expect(libraryStore.songs[0].id).toBe('2');
});
});
});

View file

@ -0,0 +1,254 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock auth store before importing player store
vi.mock('./auth.svelte', () => ({
authStore: {
getAuthHeaders: vi.fn().mockResolvedValue({ Authorization: 'Bearer test-token' }),
},
}));
// Dynamic import to allow mock setup first
let playerStore: typeof import('./player.svelte').playerStore;
beforeEach(async () => {
vi.clearAllMocks();
// Reset module registry so each test gets a fresh store
vi.resetModules();
const mod = await import('./player.svelte');
playerStore = mod.playerStore;
});
function makeSong(overrides: Partial<{ id: string; title: string; artist: string }> = {}) {
return {
id: overrides.id ?? '1',
title: overrides.title ?? 'Test Song',
artist: overrides.artist ?? 'Test Artist',
album: null,
albumArtist: null,
genre: null,
trackNumber: null,
year: null,
duration: 180,
storagePath: 'users/test/1.mp3',
coverArtPath: null,
fileSize: null,
bpm: null,
favorite: false,
playCount: 0,
lastPlayedAt: null,
addedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
userId: 'user-1',
} as any;
}
describe('playerStore', () => {
describe('initial state', () => {
it('starts with no song playing', () => {
expect(playerStore.currentSong).toBeNull();
expect(playerStore.isPlaying).toBe(false);
expect(playerStore.currentTime).toBe(0);
expect(playerStore.duration).toBe(0);
expect(playerStore.error).toBeNull();
});
it('starts with default settings', () => {
expect(playerStore.volume).toBe(1);
expect(playerStore.repeatMode).toBe('off');
expect(playerStore.shuffleOn).toBe(false);
expect(playerStore.queue).toEqual([]);
expect(playerStore.showFullPlayer).toBe(false);
expect(playerStore.showQueue).toBe(false);
});
});
describe('playSong', () => {
it('sets current song and fetches download URL', async () => {
const song = makeSong();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ url: 'https://minio.test/song.mp3' }),
});
await playerStore.playSong(song);
expect(playerStore.currentSong).toEqual(song);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/songs/1/download-url'),
expect.any(Object)
);
});
it('sets up queue when provided', async () => {
const songs = [makeSong({ id: '1' }), makeSong({ id: '2' }), makeSong({ id: '3' })];
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ url: 'https://minio.test/song.mp3' }),
});
await playerStore.playSong(songs[1], songs, 1);
expect(playerStore.currentSong?.id).toBe('2');
expect(playerStore.queue).toHaveLength(3);
expect(playerStore.currentIndex).toBe(1);
});
it('sets error when download URL fetch fails', async () => {
const song = makeSong();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ message: 'Not found' }),
});
await playerStore.playSong(song);
expect(playerStore.isPlaying).toBe(false);
expect(playerStore.error).toBeTruthy();
});
});
describe('togglePlay', () => {
it('does nothing when no song is loaded', () => {
playerStore.togglePlay();
expect(playerStore.isPlaying).toBe(false);
});
});
describe('toggleRepeat', () => {
it('cycles through off -> all -> one -> off', () => {
expect(playerStore.repeatMode).toBe('off');
playerStore.toggleRepeat();
expect(playerStore.repeatMode).toBe('all');
playerStore.toggleRepeat();
expect(playerStore.repeatMode).toBe('one');
playerStore.toggleRepeat();
expect(playerStore.repeatMode).toBe('off');
});
});
describe('toggleShuffle', () => {
it('toggles shuffle state', () => {
expect(playerStore.shuffleOn).toBe(false);
playerStore.toggleShuffle();
expect(playerStore.shuffleOn).toBe(true);
playerStore.toggleShuffle();
expect(playerStore.shuffleOn).toBe(false);
});
});
describe('toggleFullPlayer', () => {
it('toggles full player visibility', () => {
expect(playerStore.showFullPlayer).toBe(false);
playerStore.toggleFullPlayer();
expect(playerStore.showFullPlayer).toBe(true);
playerStore.toggleFullPlayer();
expect(playerStore.showFullPlayer).toBe(false);
});
});
describe('toggleQueue', () => {
it('toggles queue panel visibility', () => {
expect(playerStore.showQueue).toBe(false);
playerStore.toggleQueue();
expect(playerStore.showQueue).toBe(true);
playerStore.toggleQueue();
expect(playerStore.showQueue).toBe(false);
});
});
describe('error handling', () => {
it('clearError resets error state', async () => {
const song = makeSong();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ message: 'Not found' }),
});
await playerStore.playSong(song);
expect(playerStore.error).toBeTruthy();
playerStore.clearError();
expect(playerStore.error).toBeNull();
});
});
describe('clearQueue', () => {
it('resets all state', async () => {
const song = makeSong();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ url: 'https://minio.test/song.mp3' }),
});
await playerStore.playSong(song, [song], 0);
playerStore.clearQueue();
expect(playerStore.currentSong).toBeNull();
expect(playerStore.isPlaying).toBe(false);
expect(playerStore.queue).toEqual([]);
expect(playerStore.error).toBeNull();
expect(playerStore.showFullPlayer).toBe(false);
expect(playerStore.showQueue).toBe(false);
});
});
describe('playQueue', () => {
it('starts playing from given index', async () => {
const songs = [makeSong({ id: '1' }), makeSong({ id: '2' }), makeSong({ id: '3' })];
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ url: 'https://minio.test/song2.mp3' }),
});
await playerStore.playQueue(songs, 1);
expect(playerStore.currentSong?.id).toBe('2');
expect(playerStore.queue).toHaveLength(3);
});
});
describe('setVolume', () => {
it('clamps volume between 0 and 1', () => {
playerStore.setVolume(0.5);
expect(playerStore.volume).toBe(0.5);
playerStore.setVolume(-1);
expect(playerStore.volume).toBe(0);
playerStore.setVolume(2);
expect(playerStore.volume).toBe(1);
});
});
describe('removeFromQueue', () => {
it('does not remove current song', async () => {
const songs = [makeSong({ id: '1' }), makeSong({ id: '2' })];
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ url: 'https://minio.test/song.mp3' }),
});
await playerStore.playSong(songs[0], songs, 0);
playerStore.removeFromQueue(0);
expect(playerStore.queue).toHaveLength(2);
});
it('removes non-current song from queue', async () => {
const songs = [makeSong({ id: '1' }), makeSong({ id: '2' }), makeSong({ id: '3' })];
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ url: 'https://minio.test/song.mp3' }),
});
await playerStore.playSong(songs[0], songs, 0);
playerStore.removeFromQueue(2);
expect(playerStore.queue).toHaveLength(2);
});
});
});

View file

@ -0,0 +1,4 @@
export const browser = true;
export const building = false;
export const dev = true;
export const version = 'test';

View file

@ -0,0 +1,9 @@
import { vi } from 'vitest';
export const goto = vi.fn();
export const invalidate = vi.fn();
export const invalidateAll = vi.fn();
export const prefetch = vi.fn();
export const prefetchRoutes = vi.fn();
export const beforeNavigate = vi.fn();
export const afterNavigate = vi.fn();

View file

@ -0,0 +1,17 @@
import { writable, readable } from 'svelte/store';
export const page = readable({
url: new URL('http://localhost'),
params: {},
route: { id: '/' },
status: 200,
error: null,
data: {},
form: null,
});
export const navigating = readable(null);
export const updated = {
subscribe: writable(false).subscribe,
check: async () => false,
};

View file

@ -0,0 +1,2 @@
export const PUBLIC_BACKEND_URL = 'http://localhost:3010';
export const PUBLIC_MANA_CORE_AUTH_URL = 'http://localhost:3001';

View file

@ -0,0 +1,82 @@
import { vi, beforeEach } from 'vitest';
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
Object.defineProperty(global, 'localStorage', { value: localStorageMock });
// Mock fetch
global.fetch = vi.fn();
// Mock Audio
class MockAudio {
src = '';
currentTime = 0;
duration = 0;
volume = 1;
paused = true;
error: MediaError | null = null;
private listeners: Record<string, Function[]> = {};
addEventListener(event: string, handler: Function) {
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event].push(handler);
}
removeEventListener(event: string, handler: Function) {
if (this.listeners[event]) {
this.listeners[event] = this.listeners[event].filter((h) => h !== handler);
}
}
dispatchEvent(event: Event) {
const handlers = this.listeners[event.type] || [];
handlers.forEach((h) => h(event));
return true;
}
emit(eventName: string) {
const handlers = this.listeners[eventName] || [];
handlers.forEach((h) => h());
}
async play() {
this.paused = false;
return Promise.resolve();
}
pause() {
this.paused = true;
}
}
global.Audio = MockAudio as unknown as typeof Audio;
// Mock MediaError
if (typeof global.MediaError === 'undefined') {
(global as Record<string, unknown>).MediaError = {
MEDIA_ERR_ABORTED: 1,
MEDIA_ERR_NETWORK: 2,
MEDIA_ERR_DECODE: 3,
MEDIA_ERR_SRC_NOT_SUPPORTED: 4,
};
}
// Mock MediaSession
if (typeof navigator !== 'undefined' && !('mediaSession' in navigator)) {
Object.defineProperty(navigator, 'mediaSession', {
value: {
metadata: null,
setActionHandler: vi.fn(),
},
});
}
// Reset mocks before each test
beforeEach(() => {
vi.clearAllMocks();
localStorageMock.getItem.mockReturnValue(null);
});

View file

@ -0,0 +1,20 @@
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import path from 'path';
export default defineConfig({
plugins: [svelte({ hot: !process.env.VITEST })],
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
resolve: {
alias: {
$lib: path.resolve(__dirname, './src/lib'),
$app: path.resolve(__dirname, './src/test/mocks/app'),
'$env/static/public': path.resolve(__dirname, './src/test/mocks/env/static/public.ts'),
},
},
});