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$/); }); });