test: Add first row of tests for the site

This commit is contained in:
Samidy 2026-04-06 15:55:30 +03:00
parent f3b9cfd2f0
commit d7642ff78e
7 changed files with 665 additions and 18 deletions

View file

@ -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);
});
}

View file

@ -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();
}

View file

@ -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());
},

107
js/tests/db.test.js Normal file
View file

@ -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);
});
});

195
js/tests/player.test.js Normal file
View file

@ -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 = `
<audio id="audio-player"></audio>
<video id="video-player"></video>
<div class="now-playing-bar">
<img class="cover" src="">
<div class="title"></div>
<div class="artist"></div>
<div class="album"></div>
</div>
<div id="total-duration"></div>
`;
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);
});
});

125
js/tests/storage.test.js Normal file
View file

@ -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);
});
});
});

211
js/tests/utils.test.js Normal file
View file

@ -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: () => '<svg>atmos</svg>',
}));
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<g>h|i')).toBe('a_b_c_d_e_f_g_h_i');
});
test('collapses multiple spaces and trims', () => {
expect(utils.sanitizeForFilename(' hello world ')).toBe('hello world');
});
test('returns "Unknown" for empty input', () => {
expect(utils.sanitizeForFilename('')).toBe('Unknown');
expect(utils.sanitizeForFilename(null)).toBe('Unknown');
});
});
describe('replaceTokens', () => {
test('replaces tokens in template', () => {
const template = '{artist} - {title}';
const tokens = { artist: 'Artist', title: 'Title' };
expect(utils.replaceTokens(template, tokens)).toBe('Artist - Title');
});
test('leaves unknown tokens as is', () => {
const template = '{artist} - {unknown}';
const tokens = { artist: 'Artist' };
expect(utils.replaceTokens(template, tokens)).toBe('Artist - {unknown}');
});
});
describe('formatPathTemplate', () => {
test('formats path correctly', () => {
const data = {
artist: 'Artist',
album: 'Album',
trackNumber: 1,
title: 'Title',
discNumber: 1,
};
const template = '{artist}/{album}/{trackNumber} - {title}';
expect(utils.formatPathTemplate(template, data)).toBe('Artist/Album/01 - Title');
});
test('strips . and .. segments', () => {
const data = { artist: '..', title: '.' };
const template = '{artist}/{title}/song';
expect(utils.formatPathTemplate(template, data)).toBe('song');
});
});
describe('detectAudioFormat', () => {
test('detects flac', () => {
const view = new DataView(new Uint8Array([0x66, 0x4c, 0x61, 0x43]).buffer);
expect(utils.detectAudioFormat(view)).toBe('flac');
});
test('detects mp4', () => {
const view = new DataView(new Uint8Array([0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70]).buffer);
expect(utils.detectAudioFormat(view)).toBe('mp4');
});
test('detects mp3 (ID3)', () => {
const view = new DataView(new Uint8Array([0x49, 0x44, 0x33]).buffer);
expect(utils.detectAudioFormat(view)).toBe('mp3');
});
test('detects ogg', () => {
const view = new DataView(new Uint8Array([0x4f, 0x67, 0x67, 0x53]).buffer);
expect(utils.detectAudioFormat(view)).toBe('ogg');
});
test('returns null for unknown format', () => {
const view = new DataView(new Uint8Array([0, 0, 0, 0]).buffer);
expect(utils.detectAudioFormat(view)).toBeNull();
});
});
describe('normalizeQualityToken', () => {
test('normalizes various quality strings', () => {
expect(utils.normalizeQualityToken('HI_RES_LOSSLESS')).toBe('HI_RES_LOSSLESS');
expect(utils.normalizeQualityToken('MASTER')).toBe('HI_RES_LOSSLESS');
expect(utils.normalizeQualityToken('HIFI')).toBe('LOSSLESS');
expect(utils.normalizeQualityToken('ATMOS')).toBe('DOLBY_ATMOS');
});
test('returns null for unknown quality', () => {
expect(utils.normalizeQualityToken('UNKNOWN')).toBeNull();
});
});
describe('pickBestQuality', () => {
test('picks the highest quality from list', () => {
expect(utils.pickBestQuality(['LOSSLESS', 'HI_RES_LOSSLESS', 'HIGH'])).toBe('HI_RES_LOSSLESS');
expect(utils.pickBestQuality(['LOW', 'HIGH'])).toBe('HIGH');
expect(utils.pickBestQuality(['DOLBY_ATMOS', 'HI_RES_LOSSLESS'])).toBe('DOLBY_ATMOS');
});
});
describe('getTrackTitle', () => {
test('returns title with version if present', () => {
expect(utils.getTrackTitle({ title: 'Song', version: 'Remix' })).toBe('Song (Remix)');
});
test('returns just title if no version', () => {
expect(utils.getTrackTitle({ title: 'Song' })).toBe('Song');
});
test('returns fallback if no title', () => {
expect(utils.getTrackTitle({}, { fallback: 'No Title' })).toBe('No Title');
});
});
describe('getTrackArtists', () => {
test('joins multiple artists', () => {
const track = { artists: [{ name: 'A' }, { name: 'B' }] };
expect(utils.getTrackArtists(track)).toBe('A, B');
});
test('returns fallback if no artists', () => {
expect(utils.getTrackArtists({})).toBe('Unknown Artist');
});
});
describe('getTrackDiscNumber', () => {
test('extracts disc number from various properties', () => {
expect(utils.getTrackDiscNumber({ discNumber: 2 })).toBe(2);
expect(utils.getTrackDiscNumber({ volumeNumber: 3 })).toBe(3);
expect(utils.getTrackDiscNumber({ mediaNumber: 4 })).toBe(4);
});
test('returns null for invalid values', () => {
expect(utils.getTrackDiscNumber({ discNumber: 0 })).toBeNull();
expect(utils.getTrackDiscNumber({ discNumber: 'abc' })).toBeNull();
});
});
describe('tryCatch', () => {
test('executes sync function', () => {
const fn = vi.fn(() => 'success');
const onError = vi.fn();
expect(utils.tryCatch(fn, onError)).toBe('success');
expect(onError).not.toHaveBeenCalled();
});
test('handles sync error', () => {
const error = new Error('fail');
const fn = vi.fn(() => {
throw error;
});
const onError = vi.fn((err) => err.message);
expect(utils.tryCatch(fn, onError)).toBe('fail');
expect(onError).toHaveBeenCalledWith(error);
});
test('executes async function', async () => {
const fn = vi.fn(async () => 'success');
const onError = vi.fn();
const result = await utils.tryCatch(fn, onError);
expect(result).toBe('success');
expect(onError).not.toHaveBeenCalled();
});
test('handles async error', async () => {
const error = new Error('fail');
const fn = vi.fn(async () => {
throw error;
});
const onError = vi.fn(async (err) => err.message);
const result = await utils.tryCatch(fn, onError);
expect(result).toBe('fail');
expect(onError).toHaveBeenCalledWith(error);
});
});
});