Files
pipu-music/packages/core/src/player.test.ts
T
mrleo1nidandClaude Opus 5.5 eaedd1fc62
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 41s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m13s
ci / ts / ts (packages + web) (push) Successful in 1m14s
ci / api / api (push) Successful in 36s
feat(web): My Wave button, mood picker and endless wave playback (M4)
- WaveButton: TypeScript port of the prototype's canvas animation
  (orb and liquid), breathing at rest, pulsing to the track's BPM and
  energy in the cover palette, following the cursor, pressing with a
  spring; reduced motion and hidden tabs slow or stop it
- home: the wave in the centre with activity and mood chips; changing
  them while playing rebuilds the upcoming tracks without stopping
- player wave mode: asks for the next batch when two tracks are left,
  sends complete / skip / early skip / like / dislike as feedback and
  refreshes the queue right after a reaction or an early skip
- tracks expose bpm, energy and loudness once analysed; library page
  shows analysis progress and 7-day wave metrics; wave style setting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:29:38 +03:00

129 lines
4.0 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,
features: 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('PlayQueue in the wave', () => {
it('appends batches and drops upcoming tracks', () => {
const q = new PlayQueue();
q.set([track('a'), track('b'), track('c')]);
q.next();
expect(q.remaining).toBe(1);
q.append([track('d'), track('e')]);
expect(q.remaining).toBe(3);
q.dropUpcoming();
expect(q.current?.id).toBe('b');
expect(q.remaining).toBe(0);
expect(q.tracks.map((t) => t.id)).toEqual(['a', 'b']);
q.append([track('x')]);
expect(q.next()?.id).toBe('x');
});
});
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$/);
});
});