web-player: add Vitest, unit tests and pure queue operations
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { appendUnique, shuffle } from './collections';
|
||||
|
||||
describe('appendUnique', () => {
|
||||
const keyOf = (item: { id: number }) => item.id;
|
||||
it('appends only unseen keys', () => {
|
||||
const result = appendUnique([{ id: 1 }], [{ id: 1 }, { id: 2 }, { id: 2 }, { id: 3 }], keyOf);
|
||||
expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
|
||||
});
|
||||
it('returns the same array when nothing is new', () => {
|
||||
const current = [{ id: 1 }];
|
||||
expect(appendUnique(current, [{ id: 1 }], keyOf)).toBe(current);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shuffle', () => {
|
||||
it('keeps the same elements and does not mutate the input', () => {
|
||||
const input = [1, 2, 3, 4, 5, 6];
|
||||
const result = shuffle(input);
|
||||
expect([...result].sort()).toEqual(input);
|
||||
expect(input).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findConflict, genreHue } from './genres';
|
||||
|
||||
describe('genreHue', () => {
|
||||
it('is stable and within 0-359', () => {
|
||||
for (const genre of ['rock', 'something-unknown', 'ñandú']) {
|
||||
const hue = genreHue(genre);
|
||||
expect(hue).toBe(genreHue(genre));
|
||||
expect(hue).toBeGreaterThanOrEqual(0);
|
||||
expect(hue).toBeLessThan(360);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findConflict', () => {
|
||||
const options = [
|
||||
{ genre: 'a', incompatibleWith: ['b'] },
|
||||
{ genre: 'b', incompatibleWith: ['a'] },
|
||||
{ genre: 'c', incompatibleWith: [] }
|
||||
];
|
||||
it('returns the first incompatible pair', () => {
|
||||
expect(findConflict(['c', 'a', 'b'], options)).toEqual(['a', 'b']);
|
||||
});
|
||||
it('returns undefined when compatible or unknown', () => {
|
||||
expect(findConflict(['a', 'c'], options)).toBeUndefined();
|
||||
expect(findConflict(['x', 'y'], options)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findTopResult } from './search';
|
||||
|
||||
const empty = { tracks: [], albums: [], playlists: [], users: [] };
|
||||
|
||||
describe('findTopResult', () => {
|
||||
it('prefers prefix matches by kind: user, track, album, playlist', () => {
|
||||
const result = findTopResult({
|
||||
query: 'Ab',
|
||||
users: [{ name: 'Zed' }, { name: 'abel' }],
|
||||
tracks: [{ title: 'Abc' }],
|
||||
albums: [{ title: 'Abd' }],
|
||||
playlists: [{ name: 'Abe' }]
|
||||
});
|
||||
expect(result).toEqual({ kind: 'user', user: { name: 'abel' } });
|
||||
});
|
||||
it('matches case-insensitively and ignores surrounding spaces', () => {
|
||||
const result = findTopResult({ ...empty, query: ' SO ', tracks: [{ title: 'song' }] });
|
||||
expect(result).toEqual({ kind: 'track', track: { title: 'song' } });
|
||||
});
|
||||
it('falls back to the first available result', () => {
|
||||
const result = findTopResult({ ...empty, query: 'zzz', albums: [{ title: 'Other' }] });
|
||||
expect(result).toEqual({ kind: 'album', album: { title: 'Other' } });
|
||||
});
|
||||
it('returns null without results', () => {
|
||||
expect(findTopResult({ ...empty, query: 'x' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { extractAccent, type Accent } from '$lib/theme/palette';
|
||||
import { shuffle } from '$lib/data/collections';
|
||||
import { dialog } from '$lib/state/dialog.svelte';
|
||||
import { ListenTracker } from './listenTracker';
|
||||
import { append, insertNext, move, nextIndex, previousIndex } from './queue';
|
||||
|
||||
export interface PlayerTrack {
|
||||
id: string | number;
|
||||
@@ -398,9 +399,7 @@ class PlayerState {
|
||||
this.playQueue(items, 0);
|
||||
return;
|
||||
}
|
||||
const tracks = [...this.tracks];
|
||||
tracks.splice(this.#index() + 1, 0, ...items.map(toTrack));
|
||||
this.tracks = tracks;
|
||||
this.tracks = insertNext(this.tracks, this.#index(), items.map(toTrack));
|
||||
}
|
||||
|
||||
appendToQueue(items: QueueItem[]) {
|
||||
@@ -409,7 +408,7 @@ class PlayerState {
|
||||
this.playQueue(items, 0);
|
||||
return;
|
||||
}
|
||||
this.tracks = [...this.tracks, ...items.map(toTrack)];
|
||||
this.tracks = append(this.tracks, items.map(toTrack));
|
||||
}
|
||||
|
||||
playTrack(id: string | number) {
|
||||
@@ -460,13 +459,7 @@ class PlayerState {
|
||||
next() {
|
||||
if (this.tracks.length === 0) return;
|
||||
const idx = this.#index();
|
||||
let nextIdx = (idx + 1) % this.tracks.length;
|
||||
if (this.shuffle && this.tracks.length > 1) {
|
||||
do {
|
||||
nextIdx = Math.floor(Math.random() * this.tracks.length);
|
||||
} while (nextIdx === idx);
|
||||
}
|
||||
this.currentId = this.tracks[nextIdx].id;
|
||||
this.currentId = this.tracks[nextIndex(this.tracks.length, idx, this.shuffle)].id;
|
||||
this.#loadCurrent();
|
||||
}
|
||||
|
||||
@@ -490,13 +483,7 @@ class PlayerState {
|
||||
}
|
||||
|
||||
moveQueueItem(from: number, insertAt: number) {
|
||||
if (from < 0 || from >= this.tracks.length) return;
|
||||
const target = insertAt > from ? insertAt - 1 : insertAt;
|
||||
if (target === from) return;
|
||||
const tracks = [...this.tracks];
|
||||
const [item] = tracks.splice(from, 1);
|
||||
tracks.splice(target, 0, item);
|
||||
this.tracks = tracks;
|
||||
this.tracks = move(this.tracks, from, insertAt);
|
||||
}
|
||||
|
||||
clearUpcoming() {
|
||||
@@ -511,7 +498,7 @@ class PlayerState {
|
||||
return;
|
||||
}
|
||||
const idx = this.#index();
|
||||
this.currentId = this.tracks[(idx - 1 + this.tracks.length) % this.tracks.length].id;
|
||||
this.currentId = this.tracks[previousIndex(this.tracks.length, idx)].id;
|
||||
this.#loadCurrent();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { append, insertNext, move, nextIndex, previousIndex } from './queue';
|
||||
|
||||
describe('insertNext', () => {
|
||||
it('inserts right after the given index without mutating', () => {
|
||||
const queue = ['a', 'b', 'c'];
|
||||
expect(insertNext(queue, 0, ['x', 'y'])).toEqual(['a', 'x', 'y', 'b', 'c']);
|
||||
expect(queue).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
it('inserts at the start when index is -1', () => {
|
||||
expect(insertNext(['a'], -1, ['x'])).toEqual(['x', 'a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('append', () => {
|
||||
it('adds items at the end', () => {
|
||||
expect(append(['a'], ['b', 'c'])).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('move', () => {
|
||||
const queue = ['a', 'b', 'c', 'd'];
|
||||
it('moves an item forward using insert position semantics', () => {
|
||||
expect(move(queue, 0, 3)).toEqual(['b', 'c', 'a', 'd']);
|
||||
});
|
||||
it('moves an item backward', () => {
|
||||
expect(move(queue, 3, 1)).toEqual(['a', 'd', 'b', 'c']);
|
||||
});
|
||||
it('is a no-op when the target equals the origin', () => {
|
||||
expect(move(queue, 1, 1)).toBe(queue);
|
||||
expect(move(queue, 1, 2)).toBe(queue);
|
||||
});
|
||||
it('ignores out-of-range origins', () => {
|
||||
expect(move(queue, -1, 0)).toBe(queue);
|
||||
expect(move(queue, 4, 0)).toBe(queue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextIndex', () => {
|
||||
it('advances and wraps around', () => {
|
||||
expect(nextIndex(3, 0, false)).toBe(1);
|
||||
expect(nextIndex(3, 2, false)).toBe(0);
|
||||
});
|
||||
it('never repeats the current index when shuffling', () => {
|
||||
const values = [0.4, 0.5, 0.9];
|
||||
let call = 0;
|
||||
expect(nextIndex(3, 1, true, () => values[call++])).toBe(2);
|
||||
expect(call).toBe(3);
|
||||
});
|
||||
it('ignores shuffle with a single track', () => {
|
||||
expect(nextIndex(1, 0, true)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('previousIndex', () => {
|
||||
it('steps back and wraps around', () => {
|
||||
expect(previousIndex(3, 2)).toBe(1);
|
||||
expect(previousIndex(3, 0)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
export function insertNext<T>(queue: T[], afterIndex: number, items: T[]): T[] {
|
||||
const result = [...queue];
|
||||
result.splice(afterIndex + 1, 0, ...items);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function append<T>(queue: T[], items: T[]): T[] {
|
||||
return [...queue, ...items];
|
||||
}
|
||||
|
||||
export function move<T>(queue: T[], from: number, insertAt: number): T[] {
|
||||
if (from < 0 || from >= queue.length) return queue;
|
||||
const target = insertAt > from ? insertAt - 1 : insertAt;
|
||||
if (target === from) return queue;
|
||||
const result = [...queue];
|
||||
const [item] = result.splice(from, 1);
|
||||
result.splice(target, 0, item);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function nextIndex(
|
||||
length: number,
|
||||
index: number,
|
||||
shuffle: boolean,
|
||||
random: () => number = Math.random
|
||||
): number {
|
||||
if (shuffle && length > 1) {
|
||||
let next: number;
|
||||
do {
|
||||
next = Math.floor(random() * length);
|
||||
} while (next === index);
|
||||
return next;
|
||||
}
|
||||
return (index + 1) % length;
|
||||
}
|
||||
|
||||
export function previousIndex(length: number, index: number): number {
|
||||
return (index - 1 + length) % length;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
|
||||
import { parseAlbumForm } from './albumForm';
|
||||
|
||||
function form(fields: Record<string, string>): FormData {
|
||||
const data = new FormData();
|
||||
for (const [key, value] of Object.entries(fields)) data.set(key, value);
|
||||
return data;
|
||||
}
|
||||
|
||||
describe('parseAlbumForm', () => {
|
||||
it('parses a valid form and trims values', () => {
|
||||
expect(
|
||||
parseAlbumForm(form({ title: ' Title ', description: ' d ', releaseYear: '2020' }))
|
||||
).toEqual({ body: { title: 'Title', description: 'd', releaseYear: 2020 } });
|
||||
});
|
||||
it('turns empty optional fields into null', () => {
|
||||
expect(parseAlbumForm(form({ title: 'T' }))).toEqual({
|
||||
body: { title: 'T', description: null, releaseYear: null }
|
||||
});
|
||||
});
|
||||
it('rejects empty or too long titles', () => {
|
||||
expect(parseAlbumForm(form({ title: ' ' }))).toHaveProperty('failMessage');
|
||||
expect(parseAlbumForm(form({ title: 'x'.repeat(201) }))).toHaveProperty('failMessage');
|
||||
});
|
||||
it('rejects too long descriptions', () => {
|
||||
expect(parseAlbumForm(form({ title: 'T', description: 'x'.repeat(257) }))).toHaveProperty(
|
||||
'failMessage'
|
||||
);
|
||||
});
|
||||
it('rejects invalid years', () => {
|
||||
for (const releaseYear of ['abc', '2020.5', String(ALBUM_EARLIEST_YEAR - 1), '9999']) {
|
||||
expect(parseAlbumForm(form({ title: 'T', releaseYear }))).toHaveProperty('failMessage');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pickGreeting } from './greeting';
|
||||
|
||||
const BIRTHDAY = 'Hoy toca celebrar. Tú eliges la música.';
|
||||
|
||||
describe('pickGreeting', () => {
|
||||
it('uses the birthday message over special dates', () => {
|
||||
const date = new Date(2026, 11, 25, 10);
|
||||
expect(pickGreeting(date, { month: 12, day: 25 })).toBe(BIRTHDAY);
|
||||
});
|
||||
it('uses the special date message', () => {
|
||||
expect(pickGreeting(new Date(2026, 11, 25, 10))).toBe('Feliz Navidad. Pon tu banda sonora.');
|
||||
});
|
||||
it('returns a non-empty schedule message at every hour', () => {
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
expect(pickGreeting(new Date(2026, 2, 10, hour)).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
it('ignores a birthday on another day', () => {
|
||||
expect(pickGreeting(new Date(2026, 2, 10, 10), { month: 1, day: 2 })).not.toBe(BIRTHDAY);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { albumMeta, fmtDurationLong, fmtTime, plural } from './format';
|
||||
|
||||
describe('fmtTime', () => {
|
||||
it('formats minutes and padded seconds', () => {
|
||||
expect(fmtTime(0)).toBe('0:00');
|
||||
expect(fmtTime(65)).toBe('1:05');
|
||||
expect(fmtTime(600)).toBe('10:00');
|
||||
});
|
||||
it('floors fractions and clamps invalid input', () => {
|
||||
expect(fmtTime(59.9)).toBe('0:59');
|
||||
expect(fmtTime(-5)).toBe('0:00');
|
||||
expect(fmtTime(NaN)).toBe('0:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtDurationLong', () => {
|
||||
it('shows minutes only under an hour', () => {
|
||||
expect(fmtDurationLong(59)).toBe('0 Min');
|
||||
expect(fmtDurationLong(125)).toBe('2 Min');
|
||||
});
|
||||
it('shows hours and minutes', () => {
|
||||
expect(fmtDurationLong(3600)).toBe('1 H 0 Min');
|
||||
expect(fmtDurationLong(3600 * 2 + 60 * 5)).toBe('2 H 5 Min');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plural', () => {
|
||||
it('uses the singular only for 1', () => {
|
||||
expect(plural(1, 'canción', 'canciones')).toBe('1 canción');
|
||||
expect(plural(0, 'canción', 'canciones')).toBe('0 canciones');
|
||||
expect(plural(2, 'canción', 'canciones')).toBe('2 canciones');
|
||||
});
|
||||
});
|
||||
|
||||
describe('albumMeta', () => {
|
||||
it('joins year and track count', () => {
|
||||
expect(albumMeta(2020, 1)).toBe('2020 · 1 canción');
|
||||
expect(albumMeta(2020, 12)).toBe('2020 · 12 canciones');
|
||||
});
|
||||
it('omits missing parts', () => {
|
||||
expect(albumMeta(undefined, 3)).toBe('3 canciones');
|
||||
expect(albumMeta(2020)).toBe('2020');
|
||||
expect(albumMeta()).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user