- core: PlayQueue with shuffle and repeat, PlayAttempt counting only time actually played, EventBuffer with batching and backoff, uuidv7 - web: Player over HTMLAudioElement with Media Session, preload of the next track, expired-link refresh, local and server playback state, hotkeys; PlayerProvider is not wired into the shell yet - ru/en strings and icons for the player and Collection Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
111 lines
3.5 KiB
TypeScript
111 lines
3.5 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import { EventBuffer, PlayAttempt, PlayQueue, uuidv7, type Track } from './player';
|
|
|
|
const track = (id: string, duration_ms = 200_000): Track => ({
|
|
id,
|
|
title: id,
|
|
duration_ms,
|
|
artists: [],
|
|
album: { id: 'a', title: 'A', year: null, cover_url: null, cover_colors: [] },
|
|
genres: [],
|
|
disc_no: null,
|
|
track_no: null,
|
|
available: true,
|
|
stream_url: `/s/${id}`,
|
|
reaction: null,
|
|
});
|
|
|
|
const ids = (q: PlayQueue) => q.order.map((i) => q.tracks[i]!.id);
|
|
|
|
describe('PlayQueue', () => {
|
|
it('walks forward and back, stops at the end', () => {
|
|
const q = new PlayQueue();
|
|
q.set([track('a'), track('b'), track('c')], 1);
|
|
expect(q.current?.id).toBe('b');
|
|
expect(q.next()?.id).toBe('c');
|
|
expect(q.peekNext()).toBeNull();
|
|
expect(q.next()).toBeNull();
|
|
expect(q.prev()?.id).toBe('b');
|
|
});
|
|
|
|
it('repeats one track only when it ends by itself', () => {
|
|
const q = new PlayQueue();
|
|
q.set([track('a'), track('b')]);
|
|
q.repeat = 'one';
|
|
expect(q.next(true)?.id).toBe('a');
|
|
expect(q.next()?.id).toBe('b');
|
|
});
|
|
|
|
it('wraps around with repeat all', () => {
|
|
const q = new PlayQueue();
|
|
q.set([track('a'), track('b')], 1);
|
|
q.repeat = 'all';
|
|
expect(q.peekNext()?.id).toBe('a');
|
|
expect(q.next()?.id).toBe('a');
|
|
expect(q.prev()?.id).toBe('b');
|
|
});
|
|
|
|
it('shuffles keeping the current track first and restores order', () => {
|
|
let seed = 0.3;
|
|
const q = new PlayQueue(() => (seed = (seed * 9301 + 0.49297) % 1));
|
|
q.set(['a', 'b', 'c', 'd', 'e'].map((id) => track(id)), 2);
|
|
q.setShuffle(true);
|
|
expect(q.current?.id).toBe('c');
|
|
expect(ids(q)[0]).toBe('c');
|
|
expect([...ids(q)].sort()).toEqual(['a', 'b', 'c', 'd', 'e']);
|
|
q.next();
|
|
const cur = q.current!.id;
|
|
q.setShuffle(false);
|
|
expect(q.current?.id).toBe(cur);
|
|
expect(ids(q)).toEqual(['a', 'b', 'c', 'd', 'e']);
|
|
});
|
|
});
|
|
|
|
describe('PlayAttempt', () => {
|
|
it('counts only time actually played', () => {
|
|
const a = new PlayAttempt(track('a'));
|
|
for (let t = 250; t <= 10_000; t += 250) a.tick(t);
|
|
expect(a.playedMs).toBe(10_000);
|
|
expect(a.tick(60_000)).toBe(true); // перемотка вперёд
|
|
a.tick(60_250);
|
|
expect(a.playedMs).toBe(10_250);
|
|
a.seek(5000);
|
|
a.tick(5250);
|
|
expect(a.playedMs).toBe(10_500);
|
|
});
|
|
|
|
it('treats the last seconds as finished', () => {
|
|
const a = new PlayAttempt(track('a', 200_000));
|
|
expect(a.outcome(196_000)).toBe('play_end');
|
|
expect(a.outcome(120_000)).toBe('skip');
|
|
});
|
|
});
|
|
|
|
describe('EventBuffer', () => {
|
|
it('sends in batches and retries with backoff', async () => {
|
|
vi.useFakeTimers();
|
|
const send = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue(undefined);
|
|
const buf = new EventBuffer({ send }, () => Date.UTC(2026, 0, 1));
|
|
buf.push({ type: 'play_start', track_id: 't1' });
|
|
buf.push({ type: 'skip', track_id: 't1', played_ms: 1000 });
|
|
|
|
expect(await buf.flush()).toBe(false);
|
|
expect(buf.pending).toHaveLength(2);
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
expect(send).toHaveBeenCalledTimes(2);
|
|
expect(buf.pending).toHaveLength(0);
|
|
const sent = send.mock.calls[1]![0];
|
|
expect(sent[0].occurred_at).toBe('2026-01-01T00:00:00.000Z');
|
|
expect(sent[0].id).not.toBe(sent[1].id);
|
|
vi.useRealTimers();
|
|
});
|
|
});
|
|
|
|
describe('uuidv7', () => {
|
|
it('encodes time and version', () => {
|
|
const id = uuidv7(0x0190_0000_0000, () => new Uint8Array(16).fill(0xff));
|
|
expect(id).toMatch(/^01900000-0000-7fff-bfff-ffffffffffff$/);
|
|
});
|
|
});
|