diff --git a/js/db.js b/js/db.js index 95c306b..acd548e 100644 --- a/js/db.js +++ b/js/db.js @@ -102,8 +102,6 @@ export class MusicDatabase { async addToHistory(track) { const storeName = 'history_tracks'; const minified = this._minifyItem(track.type || 'track', track); - const timestamp = Date.now(); - const entry = { ...minified, timestamp }; const db = await this.open(); @@ -112,25 +110,34 @@ export class MusicDatabase { const store = transaction.objectStore(storeName); const index = store.index('timestamp'); - const cursorReq = index.openCursor(null, 'prev'); + const lastReq = index.openCursor(null, 'prev'); + let lastTimestamp = 0; - cursorReq.onsuccess = (e) => { + lastReq.onsuccess = (e) => { const cursor = e.target.result; - if (cursor) { - const lastTrack = cursor.value; - if (lastTrack.id === track.id) { - store.delete(cursor.primaryKey); - } + if (cursor && lastTimestamp === 0) { + lastTimestamp = cursor.value.timestamp; } - store.put(entry); + + const timestamp = Math.max(Date.now(), lastTimestamp + 1); + const entry = { ...minified, timestamp }; + + const dedupeReq = index.openCursor(null, 'prev'); + dedupeReq.onsuccess = (e2) => { + const dedupeCursor = e2.target.result; + if (dedupeCursor) { + const trackInHistory = dedupeCursor.value; + if (trackInHistory.id === track.id) { + store.delete(dedupeCursor.primaryKey); + } + dedupeCursor.continue(); + } else { + store.put(entry); + resolve(entry); + } + }; }; - cursorReq.onerror = (_e) => { - // If cursor fails, just try to put (fallback) - store.put(entry); - }; - - transaction.oncomplete = () => resolve(entry); transaction.onerror = (e) => reject(e.target.error); }); } diff --git a/js/player.js b/js/player.js index 81f57fe..508e9d5 100644 --- a/js/player.js +++ b/js/player.js @@ -291,7 +291,8 @@ export class Player { } setPlaybackSpeed(speed) { - const validSpeed = Math.max(0.01, Math.min(100, parseFloat(speed) || 1.0)); + const parsed = parseFloat(speed); + const validSpeed = Math.max(0.01, Math.min(100, isNaN(parsed) ? 1.0 : parsed)); audioEffectsSettings.setSpeed(validSpeed); this.applyAudioEffects(); } diff --git a/js/storage.js b/js/storage.js index 222fa10..f702eac 100644 --- a/js/storage.js +++ b/js/storage.js @@ -1830,7 +1830,8 @@ export const audioEffectsSettings = { }, setSpeed(speed) { - const validSpeed = Math.max(0.01, Math.min(100, parseFloat(speed) || 1.0)); + const parsed = parseFloat(speed); + const validSpeed = Math.max(0.01, Math.min(100, isNaN(parsed) ? 1.0 : parsed)); localStorage.setItem(this.SPEED_KEY, validSpeed.toString()); }, diff --git a/js/tests/db.test.js b/js/tests/db.test.js new file mode 100644 index 0000000..8f24d57 --- /dev/null +++ b/js/tests/db.test.js @@ -0,0 +1,107 @@ +import { expect, test, describe, beforeEach, afterEach, vi } from 'vitest'; +import { MusicDatabase } from '../db.js'; + +describe('MusicDatabase', () => { + let db; + const TEST_DB_NAME = 'TestMonochromeDB'; + + beforeEach(async () => { + db = new MusicDatabase(); + db.dbName = TEST_DB_NAME; + const req = indexedDB.deleteDatabase(TEST_DB_NAME); + await new Promise((resolve) => { + req.onsuccess = resolve; + req.onerror = resolve; + }); + }); + + afterEach(async () => { + if (db.db) { + db.db.close(); + } + const req = indexedDB.deleteDatabase(TEST_DB_NAME); + await new Promise((resolve) => { + req.onsuccess = resolve; + req.onerror = resolve; + }); + }); + + test('opens database and creates stores', async () => { + const openedDb = await db.open(); + expect(openedDb.name).toBe(TEST_DB_NAME); + expect(openedDb.objectStoreNames.contains('favorites_tracks')).toBe(true); + expect(openedDb.objectStoreNames.contains('history_tracks')).toBe(true); + expect(openedDb.objectStoreNames.contains('user_playlists')).toBe(true); + }); + + test('toggleFavorite adds and removes items', async () => { + const track = { id: 'track1', title: 'Test Track', artist: { name: 'Artist' } }; + + const added = await db.toggleFavorite('track', track); + expect(added).toBe(true); + const favorites = await db.getFavorites('track'); + expect(favorites.length).toBe(1); + expect(favorites[0].id).toBe('track1'); + + const removed = await db.toggleFavorite('track', track); + expect(removed).toBe(false); + const favoritesAfter = await db.getFavorites('track'); + expect(favoritesAfter.length).toBe(0); + }); + + test('addToHistory manages recent tracks and avoids duplicates', async () => { + const track1 = { id: 't1', title: 'Track 1' }; + const track2 = { id: 't2', title: 'Track 2' }; + + await db.addToHistory(track1); + await db.addToHistory(track2); + await db.addToHistory(track1); + + const history = await db.getHistory(); + expect(history.length).toBe(2); + expect(history[0].id).toBe('t1'); + expect(history[1].id).toBe('t2'); + }); + + test('playlist operations: create, add, remove, delete', async () => { + const track = { id: 'track1', title: 'Test Track' }; + + const playlist = await db.createPlaylist('My Playlist', [track]); + expect(playlist.name).toBe('My Playlist'); + expect(playlist.tracks.length).toBe(1); + + const track2 = { id: 'track2', title: 'Track 2' }; + await db.addTrackToPlaylist(playlist.id, track2); + + const updated = await db.getPlaylist(playlist.id); + expect(updated.tracks.length).toBe(2); + expect(updated.tracks[1].id).toBe('track2'); + + await db.removeTrackFromPlaylist(playlist.id, 'track1'); + const afterRemove = await db.getPlaylist(playlist.id); + expect(afterRemove.tracks.length).toBe(1); + expect(afterRemove.tracks[0].id).toBe('track2'); + + await db.deletePlaylist(playlist.id); + const deleted = await db.getPlaylist(playlist.id); + expect(deleted).toBeUndefined(); + }); + + test('pinned items management', async () => { + const album = { id: 'album1', title: 'Album 1', type: 'album' }; + + await db.togglePinned(album, 'album'); + let pinned = await db.getPinned(); + expect(pinned.length).toBe(1); + expect(pinned[0].id).toBe('album1'); + + await db.togglePinned({ id: 'a2', title: 'A2' }, 'album'); + await db.togglePinned({ id: 'a3', title: 'A3' }, 'album'); + await db.togglePinned({ id: 'a4', title: 'A4' }, 'album'); + + pinned = await db.getPinned(); + expect(pinned.length).toBe(3); + expect(pinned.some((p) => p.id === 'a4')).toBe(true); + expect(pinned.some((p) => p.id === 'album1')).toBe(false); + }); +}); diff --git a/js/tests/player.test.js b/js/tests/player.test.js new file mode 100644 index 0000000..07821a9 --- /dev/null +++ b/js/tests/player.test.js @@ -0,0 +1,195 @@ +import { expect, test, describe, beforeEach, vi, afterEach } from 'vitest'; +import { Player } from '../player.js'; +import { REPEAT_MODE } from '../utils.js'; +import { audioEffectsSettings } from '../storage.js'; + +vi.mock('../audio-context.js', () => ({ + audioContextManager: { + init: vi.fn(), + resume: vi.fn(() => Promise.resolve()), + isReady: vi.fn(() => false), + setVolume: vi.fn(), + changeSource: vi.fn(), + }, +})); + +vi.mock('../storage.js', () => ({ + queueManager: { + getQueue: vi.fn(() => null), + saveQueue: vi.fn(), + }, + replayGainSettings: { getMode: vi.fn(() => 'off'), getPreamp: vi.fn(() => 0) }, + trackDateSettings: { useAlbumYear: vi.fn(() => true) }, + exponentialVolumeSettings: { applyCurve: vi.fn((v) => v) }, + audioEffectsSettings: { + getSpeed: vi.fn(() => 1.0), + setSpeed: vi.fn(), + isPreservePitchEnabled: vi.fn(() => true), + setPreservePitch: vi.fn(), + }, + radioSettings: { isEnabled: vi.fn(() => false) }, + contentBlockingSettings: { + shouldHideTrack: vi.fn(() => false), + shouldHideAlbum: vi.fn(() => false), + shouldHideArtist: vi.fn(() => false), + }, + qualityBadgeSettings: { isEnabled: vi.fn(() => true) }, + coverArtSizeSettings: { getSize: vi.fn(() => '1280') }, + apiSettings: { + loadInstancesFromGitHub: vi.fn(() => Promise.resolve([])), + getInstances: vi.fn(() => Promise.resolve([])), + }, + recentActivityManager: { addArtist: vi.fn(), addAlbum: vi.fn() }, + themeManager: { getTheme: vi.fn(() => 'dark'), setTheme: vi.fn() }, + lastFMStorage: { isEnabled: vi.fn(() => false) }, + nowPlayingSettings: { getMode: vi.fn(() => 'cover') }, + gaplessPlaybackSettings: { isEnabled: vi.fn(() => true) }, +})); + +vi.mock('../db.js', () => ({ + db: { + get: vi.fn(), + put: vi.fn(), + }, +})); + +vi.mock('../ui.js', () => ({ + UIRenderer: { + renderQueue: vi.fn(), + }, +})); + +vi.mock('shaka-player', () => ({ + default: { + polyfill: { installAll: vi.fn() }, + Player: { + isBrowserSupported: vi.fn(() => true), + prototype: { + configure: vi.fn(), + addEventListener: vi.fn(), + load: vi.fn(), + unload: vi.fn(), + }, + }, + }, + polyfill: { installAll: vi.fn() }, + Player: class { + static isBrowserSupported() { + return true; + } + configure() {} + addEventListener() {} + load() { + return Promise.resolve(); + } + unload() { + return Promise.resolve(); + } + destroy() { + return Promise.resolve(); + } + }, +})); + +describe('Player', () => { + let audioElement; + let api; + let player; + + beforeEach(async () => { + document.body.innerHTML = ` + + +
+ + `; + + audioElement = document.getElementById('audio-player'); + api = { + getCoverUrl: vi.fn((id) => `url-${id}`), + getCoverSrcset: vi.fn(), + getStreamUrl: vi.fn(), + }; + + Player._instance = null; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test('initialization sets up initial state', async () => { + player = new Player(audioElement, api); + expect(player.audio).toBe(audioElement); + expect(player.api).toBe(api); + expect(player.queue).toEqual([]); + expect(player.shuffleActive).toBe(false); + }); + + test('setVolume updates userVolume and localStorage', () => { + player = new Player(audioElement, api); + player.setVolume(0.5); + expect(player.userVolume).toBe(0.5); + expect(localStorage.getItem('volume')).toBe('0.5'); + }); + + test('shuffle toggles correctly', () => { + player = new Player(audioElement, api); + player.queue = [{ id: 1 }, { id: 2 }, { id: 3 }]; + + player.toggleShuffle(); + expect(player.shuffleActive).toBe(true); + expect(player.shuffledQueue.length).toBe(3); + + player.toggleShuffle(); + expect(player.shuffleActive).toBe(false); + }); + + test('repeat mode cycles correctly', () => { + player = new Player(audioElement, api); + expect(player.repeatMode).toBe(REPEAT_MODE.OFF); + + player.toggleRepeat(); + expect(player.repeatMode).toBe(REPEAT_MODE.ALL); + + player.toggleRepeat(); + expect(player.repeatMode).toBe(REPEAT_MODE.ONE); + + player.toggleRepeat(); + expect(player.repeatMode).toBe(REPEAT_MODE.OFF); + }); + + test('addToQueue adds tracks to the end', async () => { + player = new Player(audioElement, api); + player.queue = [{ id: 1 }]; + + await player.addToQueue([{ id: 2 }, { id: 3 }]); + expect(player.queue.length).toBe(3); + expect(player.queue[2].id).toBe(3); + }); + + test('clearQueue resets queue state', async () => { + player = new Player(audioElement, api); + player.queue = [{ id: 1 }]; + player.currentQueueIndex = 0; + + await player.clearQueue(); + expect(player.queue).toEqual([]); + expect(player.currentQueueIndex).toBe(-1); + }); + + test('setPlaybackSpeed clamps values', () => { + player = new Player(audioElement, api); + + player.setPlaybackSpeed(2.0); + expect(audioEffectsSettings.setSpeed).toHaveBeenCalledWith(2.0); + + player.setPlaybackSpeed(0); + expect(audioEffectsSettings.setSpeed).toHaveBeenCalledWith(0.01); + }); +}); diff --git a/js/tests/storage.test.js b/js/tests/storage.test.js new file mode 100644 index 0000000..3162623 --- /dev/null +++ b/js/tests/storage.test.js @@ -0,0 +1,125 @@ +import { expect, test, describe, beforeEach, vi } from 'vitest'; +import { + recentActivityManager, + themeManager, + lastFMStorage, + nowPlayingSettings, + gaplessPlaybackSettings, + exponentialVolumeSettings, + audioEffectsSettings, +} from '../storage.js'; + +describe('storage.js', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + describe('recentActivityManager', () => { + test('initializes with empty arrays', () => { + const recents = recentActivityManager.getRecents(); + expect(recents.artists).toEqual([]); + expect(recents.albums).toEqual([]); + }); + + test('adds artist and maintains limit', () => { + for (let i = 0; i < 15; i++) { + recentActivityManager.addArtist({ id: i, name: `Artist ${i}` }); + } + const recents = recentActivityManager.getRecents(); + expect(recents.artists.length).toBe(10); + expect(recents.artists[0].id).toBe(14); + }); + + test('clears recents', () => { + recentActivityManager.addArtist({ id: 1, name: 'Artist' }); + recentActivityManager.clear(); + const recents = recentActivityManager.getRecents(); + expect(recents.artists).toEqual([]); + }); + }); + + describe('themeManager', () => { + test('gets and sets theme', () => { + themeManager.setTheme('dark'); + expect(themeManager.getTheme()).toBe('dark'); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + }); + + test('handles custom theme', () => { + const colors = { primary: '#ff0000', background: '#000000' }; + themeManager.setCustomTheme(colors); + expect(themeManager.getTheme()).toBe('custom'); + expect(themeManager.getCustomTheme()).toEqual(colors); + expect(document.documentElement.style.getPropertyValue('--primary')).toBe('#ff0000'); + }); + }); + + describe('lastFMStorage', () => { + test('handles enabled state', () => { + lastFMStorage.setEnabled(true); + expect(lastFMStorage.isEnabled()).toBe(true); + lastFMStorage.setEnabled(false); + expect(lastFMStorage.isEnabled()).toBe(false); + }); + + test('obfuscates sensitive data', () => { + const key = 'test-api-key'; + lastFMStorage.setCustomApiKey(key); + expect(localStorage.getItem(lastFMStorage.CUSTOM_API_KEY)).not.toBe(key); + expect(lastFMStorage.getCustomApiKey()).toBe(key); + }); + }); + + describe('nowPlayingSettings', () => { + test('gets and sets mode', () => { + expect(nowPlayingSettings.getMode()).toBe('cover'); + nowPlayingSettings.setMode('visualizer'); + expect(nowPlayingSettings.getMode()).toBe('visualizer'); + }); + }); + + describe('gaplessPlaybackSettings', () => { + test('defaults to true', () => { + expect(gaplessPlaybackSettings.isEnabled()).toBe(true); + }); + + test('sets enabled state', () => { + gaplessPlaybackSettings.setEnabled(false); + expect(gaplessPlaybackSettings.isEnabled()).toBe(false); + }); + }); + + describe('exponentialVolumeSettings', () => { + test('applies curve when enabled', () => { + exponentialVolumeSettings.setEnabled(true); + expect(exponentialVolumeSettings.applyCurve(0.5)).toBeCloseTo(0.125); + expect(exponentialVolumeSettings.inverseCurve(0.125)).toBeCloseTo(0.5); + }); + + test('does not apply curve when disabled', () => { + exponentialVolumeSettings.setEnabled(false); + expect(exponentialVolumeSettings.applyCurve(0.5)).toBe(0.5); + expect(exponentialVolumeSettings.inverseCurve(0.5)).toBe(0.5); + }); + }); + + describe('audioEffectsSettings', () => { + test('gets and sets speed within bounds', () => { + audioEffectsSettings.setSpeed(2.0); + expect(audioEffectsSettings.getSpeed()).toBe(2.0); + + audioEffectsSettings.setSpeed(200); + expect(audioEffectsSettings.getSpeed()).toBe(100); + + audioEffectsSettings.setSpeed(0); + expect(audioEffectsSettings.getSpeed()).toBe(0.01); + }); + + test('resets speed', () => { + audioEffectsSettings.setSpeed(2.0); + audioEffectsSettings.resetSpeed(); + expect(audioEffectsSettings.getSpeed()).toBe(1.0); + }); + }); +}); diff --git a/js/tests/utils.test.js b/js/tests/utils.test.js new file mode 100644 index 0000000..edacfbc --- /dev/null +++ b/js/tests/utils.test.js @@ -0,0 +1,211 @@ +import { expect, test, describe, vi } from 'vitest'; +import * as utils from '../utils.js'; + +vi.mock('../ModernSettings.js', () => ({ + modernSettings: { + filenameTemplate: '{artist} - {album} - {trackNumber} - {title}', + }, +})); + +vi.mock('../icons.js', () => ({ + SVG_ATMOS: () => '', +})); + +vi.mock('../storage.js', () => ({ + qualityBadgeSettings: { isEnabled: vi.fn(() => true) }, + coverArtSizeSettings: { getSize: vi.fn(() => '1280') }, + trackDateSettings: { useAlbumYear: vi.fn(() => false) }, +})); + +describe('utils.js', () => { + describe('formatTime', () => { + test('formats seconds into M:SS', () => { + expect(utils.formatTime(0)).toBe('0:00'); + expect(utils.formatTime(5)).toBe('0:05'); + expect(utils.formatTime(60)).toBe('1:00'); + expect(utils.formatTime(65)).toBe('1:05'); + }); + + test('formats seconds into H:MM:SS', () => { + expect(utils.formatTime(3600)).toBe('1:00:00'); + expect(utils.formatTime(3665)).toBe('1:01:05'); + }); + + test('handles NaN', () => { + expect(utils.formatTime(NaN)).toBe('0:00'); + }); + }); + + describe('sanitizeForFilename', () => { + test('replaces invalid characters with underscores', () => { + expect(utils.sanitizeForFilename('a/b:c*d?e"f