feat: replace polling with Tauri events and add unit tests
- Backend now emits playback:state events every 250ms when state changes - PlayerStore uses listen() instead of setInterval for real-time updates - Added Vitest testing framework with mocked Tauri APIs - Added 53 unit tests for playerStore and queueStore
This commit is contained in:
1 parent
6dfcc8adaa
commit
71156e9d0a
8 files changed
+1872
-48
No files matched your search
Generated
+1094
-1
File diff suppressed because it is too large.
Load diff
+6
-1
@@ -9,6 +9,8 @@
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"license": "MIT",
|
||||
@@ -25,9 +27,12 @@
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3"
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ pub mod queue;
|
||||
pub mod share;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use api::QobuzClient;
|
||||
@@ -113,6 +114,51 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.manage(AppState::new())
|
||||
.setup(|app| {
|
||||
// Start background task to emit playback events
|
||||
let app_handle = app.handle().clone();
|
||||
let player_state = app.state::<AppState>().player.state.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut last_position: u64 = 0;
|
||||
let mut last_is_playing: bool = false;
|
||||
let mut last_track_id: u64 = 0;
|
||||
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
|
||||
let is_playing = player_state.is_playing();
|
||||
let position = player_state.current_position();
|
||||
let duration = player_state.duration();
|
||||
let track_id = player_state.current_track_id();
|
||||
let volume = player_state.volume();
|
||||
|
||||
// Only emit if state changed or position advanced
|
||||
let should_emit = track_id != 0 && (
|
||||
is_playing != last_is_playing
|
||||
|| track_id != last_track_id
|
||||
|| (is_playing && position != last_position)
|
||||
);
|
||||
|
||||
if should_emit {
|
||||
let event = player::PlaybackEvent {
|
||||
is_playing,
|
||||
position,
|
||||
duration,
|
||||
track_id,
|
||||
volume,
|
||||
};
|
||||
let _ = app_handle.emit("playback:state", &event);
|
||||
|
||||
last_position = position;
|
||||
last_is_playing = is_playing;
|
||||
last_track_id = track_id;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.manage(library_state)
|
||||
.manage(cast_state)
|
||||
.manage(dlna_state)
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Player Store Unit Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import {
|
||||
subscribe,
|
||||
getCurrentTrack,
|
||||
getIsPlaying,
|
||||
getCurrentTime,
|
||||
getDuration,
|
||||
getVolume,
|
||||
getIsFavorite,
|
||||
getIsSkipping,
|
||||
getPlayerState,
|
||||
setCurrentTrack,
|
||||
setIsFavorite,
|
||||
setIsSkipping,
|
||||
setIsPlaying,
|
||||
setQueueEnded,
|
||||
togglePlay,
|
||||
seek,
|
||||
setVolume,
|
||||
stop,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
isPollingActive,
|
||||
reset,
|
||||
type PlayingTrack
|
||||
} from './playerStore';
|
||||
|
||||
const mockedInvoke = vi.mocked(invoke);
|
||||
const mockedListen = vi.mocked(listen);
|
||||
|
||||
const mockTrack: PlayingTrack = {
|
||||
id: 123,
|
||||
title: 'Test Song',
|
||||
artist: 'Test Artist',
|
||||
album: 'Test Album',
|
||||
artwork: 'http://art.jpg',
|
||||
duration: 180,
|
||||
quality: 'CD Quality',
|
||||
bitDepth: 16,
|
||||
samplingRate: 44100
|
||||
};
|
||||
|
||||
describe('playerStore', () => {
|
||||
beforeEach(() => {
|
||||
reset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have no current track', () => {
|
||||
expect(getCurrentTrack()).toBeNull();
|
||||
});
|
||||
|
||||
it('should not be playing', () => {
|
||||
expect(getIsPlaying()).toBe(false);
|
||||
});
|
||||
|
||||
it('should have zero current time', () => {
|
||||
expect(getCurrentTime()).toBe(0);
|
||||
});
|
||||
|
||||
it('should have zero duration', () => {
|
||||
expect(getDuration()).toBe(0);
|
||||
});
|
||||
|
||||
it('should have default volume of 75', () => {
|
||||
expect(getVolume()).toBe(75);
|
||||
});
|
||||
|
||||
it('should not be favorite', () => {
|
||||
expect(getIsFavorite()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not be skipping', () => {
|
||||
expect(getIsSkipping()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribe', () => {
|
||||
it('should notify listener immediately', () => {
|
||||
const listener = vi.fn();
|
||||
subscribe(listener);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return unsubscribe function', () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribe(listener);
|
||||
expect(typeof unsubscribe).toBe('function');
|
||||
});
|
||||
|
||||
it('should stop notifying after unsubscribe', () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribe(listener);
|
||||
listener.mockClear();
|
||||
unsubscribe();
|
||||
setIsPlaying(true);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlayerState', () => {
|
||||
it('should return complete state', () => {
|
||||
setCurrentTrack(mockTrack);
|
||||
setIsPlaying(true);
|
||||
setIsFavorite(true);
|
||||
|
||||
const state = getPlayerState();
|
||||
|
||||
expect(state).toEqual({
|
||||
currentTrack: mockTrack,
|
||||
isPlaying: true,
|
||||
currentTime: 0,
|
||||
duration: 180,
|
||||
volume: 75,
|
||||
isFavorite: true,
|
||||
isSkipping: false
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCurrentTrack', () => {
|
||||
it('should set track and update duration', () => {
|
||||
setCurrentTrack(mockTrack);
|
||||
|
||||
expect(getCurrentTrack()).toEqual(mockTrack);
|
||||
expect(getDuration()).toBe(180);
|
||||
expect(getCurrentTime()).toBe(0);
|
||||
});
|
||||
|
||||
it('should reset times when clearing track', () => {
|
||||
setCurrentTrack(mockTrack);
|
||||
setCurrentTrack(null);
|
||||
|
||||
expect(getCurrentTrack()).toBeNull();
|
||||
expect(getDuration()).toBe(0);
|
||||
expect(getCurrentTime()).toBe(0);
|
||||
});
|
||||
|
||||
it('should notify listeners', () => {
|
||||
const listener = vi.fn();
|
||||
subscribe(listener);
|
||||
listener.mockClear();
|
||||
|
||||
setCurrentTrack(mockTrack);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setIsFavorite', () => {
|
||||
it('should update favorite status', () => {
|
||||
setIsFavorite(true);
|
||||
expect(getIsFavorite()).toBe(true);
|
||||
|
||||
setIsFavorite(false);
|
||||
expect(getIsFavorite()).toBe(false);
|
||||
});
|
||||
|
||||
it('should notify listeners', () => {
|
||||
const listener = vi.fn();
|
||||
subscribe(listener);
|
||||
listener.mockClear();
|
||||
|
||||
setIsFavorite(true);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setIsSkipping', () => {
|
||||
it('should update skipping status', () => {
|
||||
setIsSkipping(true);
|
||||
expect(getIsSkipping()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setIsPlaying', () => {
|
||||
it('should update playing status', () => {
|
||||
setIsPlaying(true);
|
||||
expect(getIsPlaying()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('togglePlay', () => {
|
||||
it('should do nothing without current track', async () => {
|
||||
await togglePlay();
|
||||
expect(mockedInvoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should resume playback when paused', async () => {
|
||||
setCurrentTrack(mockTrack);
|
||||
mockedInvoke.mockResolvedValueOnce(undefined);
|
||||
|
||||
await togglePlay();
|
||||
|
||||
expect(getIsPlaying()).toBe(true);
|
||||
expect(mockedInvoke).toHaveBeenCalledWith('resume_playback');
|
||||
});
|
||||
|
||||
it('should pause playback when playing', async () => {
|
||||
setCurrentTrack(mockTrack);
|
||||
setIsPlaying(true);
|
||||
mockedInvoke.mockResolvedValueOnce(undefined);
|
||||
|
||||
await togglePlay();
|
||||
|
||||
expect(getIsPlaying()).toBe(false);
|
||||
expect(mockedInvoke).toHaveBeenCalledWith('pause_playback');
|
||||
});
|
||||
|
||||
it('should revert on error', async () => {
|
||||
setCurrentTrack(mockTrack);
|
||||
mockedInvoke.mockRejectedValueOnce(new Error('Failed'));
|
||||
|
||||
await togglePlay();
|
||||
|
||||
// Should revert to false after failure
|
||||
expect(getIsPlaying()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seek', () => {
|
||||
it('should clamp position to valid range', async () => {
|
||||
setCurrentTrack(mockTrack); // duration = 180
|
||||
mockedInvoke.mockResolvedValue(undefined);
|
||||
|
||||
// Test negative
|
||||
await seek(-10);
|
||||
expect(getCurrentTime()).toBe(0);
|
||||
|
||||
// Test beyond duration
|
||||
await seek(200);
|
||||
expect(getCurrentTime()).toBe(180);
|
||||
|
||||
// Test valid position
|
||||
await seek(90);
|
||||
expect(getCurrentTime()).toBe(90);
|
||||
});
|
||||
|
||||
it('should invoke backend with floored position', async () => {
|
||||
setCurrentTrack(mockTrack);
|
||||
mockedInvoke.mockResolvedValueOnce(undefined);
|
||||
|
||||
await seek(45.7);
|
||||
|
||||
expect(mockedInvoke).toHaveBeenCalledWith('seek', { position: 45 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setVolume', () => {
|
||||
it('should clamp volume to 0-100', async () => {
|
||||
mockedInvoke.mockResolvedValue(undefined);
|
||||
|
||||
await setVolume(-10);
|
||||
expect(getVolume()).toBe(0);
|
||||
|
||||
await setVolume(150);
|
||||
expect(getVolume()).toBe(100);
|
||||
|
||||
await setVolume(50);
|
||||
expect(getVolume()).toBe(50);
|
||||
});
|
||||
|
||||
it('should invoke backend with normalized volume', async () => {
|
||||
mockedInvoke.mockResolvedValueOnce(undefined);
|
||||
|
||||
await setVolume(80);
|
||||
|
||||
expect(mockedInvoke).toHaveBeenCalledWith('set_volume', { volume: 0.8 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop', () => {
|
||||
it('should stop playback and clear state', async () => {
|
||||
setCurrentTrack(mockTrack);
|
||||
setIsPlaying(true);
|
||||
mockedInvoke.mockResolvedValueOnce(undefined);
|
||||
|
||||
await stop();
|
||||
|
||||
expect(getIsPlaying()).toBe(false);
|
||||
expect(getCurrentTrack()).toBeNull();
|
||||
expect(getCurrentTime()).toBe(0);
|
||||
expect(getDuration()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('event listening', () => {
|
||||
it('should start listening for events', async () => {
|
||||
const mockUnlisten = vi.fn();
|
||||
mockedListen.mockResolvedValueOnce(mockUnlisten);
|
||||
|
||||
await startPolling();
|
||||
|
||||
expect(mockedListen).toHaveBeenCalledWith('playback:state', expect.any(Function));
|
||||
expect(isPollingActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not start if already listening', async () => {
|
||||
const mockUnlisten = vi.fn();
|
||||
mockedListen.mockResolvedValue(mockUnlisten);
|
||||
|
||||
await startPolling();
|
||||
await startPolling();
|
||||
|
||||
expect(mockedListen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stop listening', async () => {
|
||||
const mockUnlisten = vi.fn();
|
||||
mockedListen.mockResolvedValueOnce(mockUnlisten);
|
||||
|
||||
await startPolling();
|
||||
stopPolling();
|
||||
|
||||
expect(mockUnlisten).toHaveBeenCalled();
|
||||
expect(isPollingActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset all state', async () => {
|
||||
setCurrentTrack(mockTrack);
|
||||
setIsPlaying(true);
|
||||
setIsFavorite(true);
|
||||
setIsSkipping(true);
|
||||
|
||||
const mockUnlisten = vi.fn();
|
||||
mockedListen.mockResolvedValueOnce(mockUnlisten);
|
||||
await startPolling();
|
||||
|
||||
reset();
|
||||
|
||||
expect(getCurrentTrack()).toBeNull();
|
||||
expect(getIsPlaying()).toBe(false);
|
||||
expect(getCurrentTime()).toBe(0);
|
||||
expect(getDuration()).toBe(0);
|
||||
expect(getIsFavorite()).toBe(false);
|
||||
expect(getIsSkipping()).toBe(false);
|
||||
expect(isPollingActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,11 @@
|
||||
* Player State Store
|
||||
*
|
||||
* Manages playback state including current track, play/pause, position, volume.
|
||||
* Currently uses polling to sync with backend - designed to be replaced with
|
||||
* Tauri events in the future.
|
||||
* Uses Tauri events for real-time updates from the backend.
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
@@ -31,6 +31,15 @@ interface BackendPlaybackState {
|
||||
volume: number;
|
||||
}
|
||||
|
||||
// Event payload from backend
|
||||
interface PlaybackEvent {
|
||||
is_playing: boolean;
|
||||
position: number;
|
||||
duration: number;
|
||||
track_id: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
// ============ State ============
|
||||
|
||||
let currentTrack: PlayingTrack | null = null;
|
||||
@@ -40,8 +49,8 @@ let duration = 0;
|
||||
let volume = 75;
|
||||
let isFavorite = false;
|
||||
|
||||
// Polling state
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
// Event listener state (replaces polling)
|
||||
let eventUnlisten: UnlistenFn | null = null;
|
||||
let isAdvancingTrack = false;
|
||||
let isSkipping = false;
|
||||
let queueEnded = false;
|
||||
@@ -242,7 +251,7 @@ export async function stop(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Polling ============
|
||||
// ============ Event-Based Updates ============
|
||||
|
||||
/**
|
||||
* Set callback for when track ends (for auto-advance)
|
||||
@@ -252,71 +261,72 @@ export function setOnTrackEnded(callback: () => Promise<void>): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll playback state from backend
|
||||
* Handle playback event from backend
|
||||
*/
|
||||
async function pollPlaybackState(): Promise<void> {
|
||||
async function handlePlaybackEvent(event: PlaybackEvent): Promise<void> {
|
||||
if (!currentTrack) return;
|
||||
|
||||
try {
|
||||
const state = await invoke<BackendPlaybackState>('get_playback_state');
|
||||
// Only update if we have a matching track
|
||||
if (event.track_id === currentTrack.id) {
|
||||
currentTime = event.position;
|
||||
isPlaying = event.is_playing;
|
||||
notifyListeners();
|
||||
|
||||
// Only update if we have a matching track
|
||||
if (state.track_id === currentTrack.id) {
|
||||
currentTime = state.position;
|
||||
isPlaying = state.is_playing;
|
||||
notifyListeners();
|
||||
// Check if track ended - auto-advance to next
|
||||
if (
|
||||
event.duration > 0 &&
|
||||
event.position >= event.duration - 1 &&
|
||||
!event.is_playing &&
|
||||
!isAdvancingTrack &&
|
||||
!queueEnded &&
|
||||
onTrackEnded
|
||||
) {
|
||||
console.log('Track finished, advancing to next...');
|
||||
isAdvancingTrack = true;
|
||||
|
||||
// Check if track ended - auto-advance to next
|
||||
if (
|
||||
state.duration > 0 &&
|
||||
state.position >= state.duration - 1 &&
|
||||
!state.is_playing &&
|
||||
!isAdvancingTrack &&
|
||||
!queueEnded &&
|
||||
onTrackEnded
|
||||
) {
|
||||
console.log('Track finished, advancing to next...');
|
||||
isAdvancingTrack = true;
|
||||
|
||||
try {
|
||||
await onTrackEnded();
|
||||
} catch (err) {
|
||||
console.error('Failed to auto-advance:', err);
|
||||
} finally {
|
||||
isAdvancingTrack = false;
|
||||
}
|
||||
try {
|
||||
await onTrackEnded();
|
||||
} catch (err) {
|
||||
console.error('Failed to auto-advance:', err);
|
||||
} finally {
|
||||
isAdvancingTrack = false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to poll playback state:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for playback state
|
||||
* Start listening for playback events from backend
|
||||
*/
|
||||
export function startPolling(): void {
|
||||
if (pollInterval) return;
|
||||
export async function startPolling(): Promise<void> {
|
||||
if (eventUnlisten) return;
|
||||
|
||||
pollInterval = setInterval(pollPlaybackState, 500);
|
||||
pollPlaybackState(); // Poll immediately
|
||||
try {
|
||||
eventUnlisten = await listen<PlaybackEvent>('playback:state', (event) => {
|
||||
handlePlaybackEvent(event.payload);
|
||||
});
|
||||
console.log('Started listening for playback events');
|
||||
} catch (err) {
|
||||
console.error('Failed to start playback event listener:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling
|
||||
* Stop listening for playback events
|
||||
*/
|
||||
export function stopPolling(): void {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
if (eventUnlisten) {
|
||||
eventUnlisten();
|
||||
eventUnlisten = null;
|
||||
console.log('Stopped listening for playback events');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if polling is active
|
||||
* Check if event listener is active
|
||||
*/
|
||||
export function isPollingActive(): boolean {
|
||||
return pollInterval !== null;
|
||||
return eventUnlisten !== null;
|
||||
}
|
||||
|
||||
// ============ Cleanup ============
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Queue Store Unit Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
subscribe,
|
||||
getQueue,
|
||||
getQueueTotalTracks,
|
||||
getIsShuffle,
|
||||
getRepeatMode,
|
||||
getQueueState,
|
||||
isLocalTrack,
|
||||
setLocalTrackIds,
|
||||
clearLocalTrackIds,
|
||||
reset,
|
||||
syncQueueState,
|
||||
toggleShuffle,
|
||||
toggleRepeat,
|
||||
type BackendQueueTrack
|
||||
} from './queueStore';
|
||||
|
||||
const mockedInvoke = vi.mocked(invoke);
|
||||
|
||||
describe('queueStore', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store state between tests
|
||||
reset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have empty queue', () => {
|
||||
expect(getQueue()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should have zero total tracks', () => {
|
||||
expect(getQueueTotalTracks()).toBe(0);
|
||||
});
|
||||
|
||||
it('should have shuffle off', () => {
|
||||
expect(getIsShuffle()).toBe(false);
|
||||
});
|
||||
|
||||
it('should have repeat mode off', () => {
|
||||
expect(getRepeatMode()).toBe('off');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribe', () => {
|
||||
it('should notify listener immediately on subscribe', () => {
|
||||
const listener = vi.fn();
|
||||
subscribe(listener);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return unsubscribe function', () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribe(listener);
|
||||
expect(typeof unsubscribe).toBe('function');
|
||||
});
|
||||
|
||||
it('should not notify after unsubscribe', () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribe(listener);
|
||||
listener.mockClear();
|
||||
unsubscribe();
|
||||
reset(); // This normally triggers notifications
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQueueState', () => {
|
||||
it('should return complete state object', () => {
|
||||
const state = getQueueState();
|
||||
expect(state).toEqual({
|
||||
queue: [],
|
||||
queueTotalTracks: 0,
|
||||
isShuffle: false,
|
||||
repeatMode: 'off'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a copy of queue array', () => {
|
||||
const state1 = getQueueState();
|
||||
const state2 = getQueueState();
|
||||
expect(state1.queue).not.toBe(state2.queue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local track management', () => {
|
||||
it('should track local track IDs', () => {
|
||||
setLocalTrackIds([1, 2, 3]);
|
||||
expect(isLocalTrack(1)).toBe(true);
|
||||
expect(isLocalTrack(2)).toBe(true);
|
||||
expect(isLocalTrack(3)).toBe(true);
|
||||
expect(isLocalTrack(4)).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear local track IDs', () => {
|
||||
setLocalTrackIds([1, 2, 3]);
|
||||
clearLocalTrackIds();
|
||||
expect(isLocalTrack(1)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset local tracks on full reset', () => {
|
||||
setLocalTrackIds([1, 2, 3]);
|
||||
reset();
|
||||
expect(isLocalTrack(1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncQueueState', () => {
|
||||
it('should sync state from backend', async () => {
|
||||
mockedInvoke.mockResolvedValueOnce({
|
||||
current_track: null,
|
||||
current_index: null,
|
||||
upcoming: [
|
||||
{ id: 1, title: 'Song 1', artist: 'Artist', album: 'Album', duration_secs: 180, artwork_url: 'http://art.jpg' },
|
||||
{ id: 2, title: 'Song 2', artist: 'Artist', album: 'Album', duration_secs: 240, artwork_url: null }
|
||||
],
|
||||
history: [],
|
||||
shuffle: true,
|
||||
repeat: 'All',
|
||||
total_tracks: 5
|
||||
});
|
||||
|
||||
await syncQueueState();
|
||||
|
||||
expect(getQueue()).toHaveLength(2);
|
||||
expect(getQueue()[0]).toEqual({
|
||||
id: '1',
|
||||
artwork: 'http://art.jpg',
|
||||
title: 'Song 1',
|
||||
artist: 'Artist',
|
||||
duration: '3:00'
|
||||
});
|
||||
expect(getQueue()[1].duration).toBe('4:00');
|
||||
expect(getQueueTotalTracks()).toBe(5);
|
||||
expect(getIsShuffle()).toBe(true);
|
||||
expect(getRepeatMode()).toBe('all');
|
||||
});
|
||||
|
||||
it('should handle empty artwork gracefully', async () => {
|
||||
mockedInvoke.mockResolvedValueOnce({
|
||||
current_track: null,
|
||||
current_index: null,
|
||||
upcoming: [
|
||||
{ id: 1, title: 'Song', artist: 'Artist', album: 'Album', duration_secs: 60, artwork_url: null }
|
||||
],
|
||||
history: [],
|
||||
shuffle: false,
|
||||
repeat: 'Off',
|
||||
total_tracks: 1
|
||||
});
|
||||
|
||||
await syncQueueState();
|
||||
|
||||
expect(getQueue()[0].artwork).toBe('');
|
||||
});
|
||||
|
||||
it('should handle sync errors gracefully', async () => {
|
||||
mockedInvoke.mockRejectedValueOnce(new Error('Backend error'));
|
||||
|
||||
// Should not throw
|
||||
await syncQueueState();
|
||||
|
||||
// State remains unchanged
|
||||
expect(getQueue()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleShuffle', () => {
|
||||
it('should toggle shuffle on', async () => {
|
||||
mockedInvoke.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await toggleShuffle();
|
||||
|
||||
expect(result).toEqual({ success: true, enabled: true });
|
||||
expect(getIsShuffle()).toBe(true);
|
||||
expect(mockedInvoke).toHaveBeenCalledWith('set_shuffle', { enabled: true });
|
||||
});
|
||||
|
||||
it('should toggle shuffle off', async () => {
|
||||
// First toggle on
|
||||
mockedInvoke.mockResolvedValueOnce(undefined);
|
||||
await toggleShuffle();
|
||||
|
||||
// Then toggle off
|
||||
mockedInvoke.mockResolvedValueOnce(undefined);
|
||||
const result = await toggleShuffle();
|
||||
|
||||
expect(result).toEqual({ success: true, enabled: false });
|
||||
expect(getIsShuffle()).toBe(false);
|
||||
});
|
||||
|
||||
it('should revert on error', async () => {
|
||||
mockedInvoke.mockRejectedValueOnce(new Error('Failed'));
|
||||
|
||||
const result = await toggleShuffle();
|
||||
|
||||
expect(result).toEqual({ success: false, enabled: false });
|
||||
expect(getIsShuffle()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleRepeat', () => {
|
||||
it('should cycle through repeat modes', async () => {
|
||||
mockedInvoke.mockResolvedValue(undefined);
|
||||
|
||||
// off -> all
|
||||
let result = await toggleRepeat();
|
||||
expect(result.mode).toBe('all');
|
||||
|
||||
// all -> one
|
||||
result = await toggleRepeat();
|
||||
expect(result.mode).toBe('one');
|
||||
|
||||
// one -> off
|
||||
result = await toggleRepeat();
|
||||
expect(result.mode).toBe('off');
|
||||
});
|
||||
|
||||
it('should not change mode on error', async () => {
|
||||
mockedInvoke.mockRejectedValueOnce(new Error('Failed'));
|
||||
|
||||
const result = await toggleRepeat();
|
||||
|
||||
expect(result).toEqual({ success: false, mode: 'off' });
|
||||
expect(getRepeatMode()).toBe('off');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset all state', async () => {
|
||||
// Set up some state
|
||||
mockedInvoke.mockResolvedValueOnce({
|
||||
current_track: null,
|
||||
current_index: null,
|
||||
upcoming: [{ id: 1, title: 'Song', artist: 'A', album: 'B', duration_secs: 60, artwork_url: null }],
|
||||
history: [],
|
||||
shuffle: true,
|
||||
repeat: 'One',
|
||||
total_tracks: 1
|
||||
});
|
||||
await syncQueueState();
|
||||
setLocalTrackIds([1, 2, 3]);
|
||||
|
||||
// Verify state was set
|
||||
expect(getQueue()).toHaveLength(1);
|
||||
expect(getIsShuffle()).toBe(true);
|
||||
|
||||
// Reset
|
||||
reset();
|
||||
|
||||
// Verify everything is cleared
|
||||
expect(getQueue()).toEqual([]);
|
||||
expect(getQueueTotalTracks()).toBe(0);
|
||||
expect(getIsShuffle()).toBe(false);
|
||||
expect(getRepeatMode()).toBe('off');
|
||||
expect(isLocalTrack(1)).toBe(false);
|
||||
});
|
||||
|
||||
it('should notify listeners on reset', () => {
|
||||
const listener = vi.fn();
|
||||
subscribe(listener);
|
||||
listener.mockClear();
|
||||
|
||||
reset();
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Vitest Setup
|
||||
*
|
||||
* Mocks Tauri APIs for unit testing frontend stores.
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock @tauri-apps/api/core
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock @tauri-apps/api/event
|
||||
vi.mock('@tauri-apps/api/event', () => ({
|
||||
listen: vi.fn(() => Promise.resolve(() => {})),
|
||||
emit: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
length: 0,
|
||||
key: vi.fn()
|
||||
};
|
||||
Object.defineProperty(global, 'localStorage', { value: localStorageMock });
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.ts'],
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['src/test/setup.ts'],
|
||||
alias: {
|
||||
'$lib': '/src/lib',
|
||||
'$app': '/src/test/mocks/app'
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user