web-player: add Vitest, unit tests and pure queue operations

This commit is contained in:
2026-09-24 04:54:09 +02:00
parent 87c3f97529
commit b2f450dcf7
12 changed files with 586 additions and 87 deletions
+6 -19
View File
@@ -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();
}
+60
View File
@@ -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);
});
});
+39
View File
@@ -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;
}