237 lines
12 KiB
TypeScript
237 lines
12 KiB
TypeScript
// Exercises MusicQueueService directly against fake parties - no Express/
|
|
// session/Emby involved. Covers the intricate bits: auto-start on first
|
|
// add, server-authoritative auto-advance via a real timer, pause/resume
|
|
// elapsed-time math, majority vote-skip, and - given how strongly memory
|
|
// leaks have mattered throughout this project (see docs/architecture.md) -
|
|
// that the advance timer is ALWAYS cleared (never left dangling) on skip,
|
|
// on destroy, and is never double-scheduled.
|
|
import { describe, it, mock } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
import { MusicQueueService } from '../src/services/MusicQueueService';
|
|
import { PlaystateService } from '../src/services/PlaystateService';
|
|
import type { AppSocket, Party, PartyMember, MusicState } from '../src/types';
|
|
|
|
function fakeParty(id: string): Party {
|
|
return {
|
|
id,
|
|
mediaType: 'music',
|
|
itemId: '',
|
|
itemName: '',
|
|
itemImageUrl: '',
|
|
queue: [],
|
|
queueIndex: 0,
|
|
hostUserId: 'u-alice',
|
|
createdUtc: Date.now(),
|
|
lastActivityUtc: Date.now(),
|
|
members: new Map(),
|
|
burnInSubtitleIndex: null,
|
|
burnInAudioIndex: null,
|
|
textSubtitleIndex: null,
|
|
streamAudioIndex: null,
|
|
streamQuality: null
|
|
};
|
|
}
|
|
|
|
/** A connected member with a known token - the two things
|
|
* playNext's own markPlayed loop (and index.ts's periodic sweep) both
|
|
* gate on. Not used by most tests in this file at all: `fakeParty`'s
|
|
* own `members` map starts empty, and MusicQueueService's methods never
|
|
* populate it themselves (that's PartyService.join's job) - so unless a
|
|
* test explicitly adds one via this, playNext's markPlayed loop simply
|
|
* has nothing to iterate, same as an empty party in production. */
|
|
function fakeMember(userId: string, userName: string, embyToken: string): PartyMember {
|
|
return {
|
|
userId,
|
|
userName,
|
|
connection: {} as unknown as AppSocket,
|
|
deviceId: null,
|
|
embyToken,
|
|
isPaused: false,
|
|
positionTicks: 0,
|
|
lastUpdatedUtc: Date.now(),
|
|
embySession: null
|
|
};
|
|
}
|
|
|
|
function music(party: Party): MusicState {
|
|
return party.music as MusicState;
|
|
}
|
|
|
|
describe('MusicQueueService', () => {
|
|
it('init() auto-starts the first track and mirrors it onto party.itemName', () => {
|
|
const party = fakeParty('p1');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
|
|
|
assert.equal(music(party).current?.track.itemId, 't1');
|
|
assert.equal(music(party).queue.length, 0);
|
|
assert.equal(party.itemName, 'Track One');
|
|
});
|
|
|
|
it('adding a track while one plays queues it instead of jumping the gun', () => {
|
|
const party = fakeParty('p2');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
|
MusicQueueService.addTrack(party, { itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 30_000_000 }, 'u-bob', 'Bob');
|
|
|
|
assert.equal(music(party).current?.track.itemId, 't1');
|
|
assert.equal(music(party).queue.length, 1);
|
|
});
|
|
|
|
it('voteSkip needs a strict majority of connected members, not any count', () => {
|
|
const party = fakeParty('p3');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
|
MusicQueueService.addTrack(party, { itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 30_000_000 }, 'u-bob', 'Bob');
|
|
|
|
assert.equal(MusicQueueService.voteSkip(party, 'u-alice', 'Alice', 2), false, 'one of two needed should not skip yet');
|
|
assert.equal(music(party).current?.track.itemId, 't1');
|
|
|
|
assert.equal(MusicQueueService.voteSkip(party, 'u-bob', 'Bob', 2), true, 'the second vote should reach the majority');
|
|
assert.equal(music(party).current?.track.itemId, 't2');
|
|
assert.equal(music(party).skipVotes.size, 0, 'skipping clears the tally for the new track');
|
|
});
|
|
|
|
it('a skip marks the OUTGOING track Played for every connected member with a token, under their own account', (t) => {
|
|
const calls: [string, string, string][] = [];
|
|
t.mock.method(PlaystateService, 'markPlayed', async (embyToken: string, userId: string, itemId: string) => {
|
|
calls.push([embyToken, userId, itemId]);
|
|
});
|
|
|
|
const party = fakeParty('p3b');
|
|
party.members.set('u-alice', fakeMember('u-alice', 'Alice', 'tok-alice'));
|
|
party.members.set('u-bob', fakeMember('u-bob', 'Bob', 'tok-bob'));
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
|
MusicQueueService.addTrack(party, { itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 30_000_000 }, 'u-bob', 'Bob');
|
|
assert.equal(calls.length, 0, 'starting the very first track has nothing outgoing to mark');
|
|
|
|
// connectedMemberCount=2 needs a strict majority (2) to actually
|
|
// skip - same arithmetic as the "needs a strict majority" test above.
|
|
MusicQueueService.voteSkip(party, 'u-alice', 'Alice', 2);
|
|
assert.equal(calls.length, 0, 'one vote of two is not a majority yet - nothing outgoing to mark');
|
|
MusicQueueService.voteSkip(party, 'u-bob', 'Bob', 2);
|
|
|
|
assert.deepEqual(calls.sort(), [['tok-alice', 'u-alice', 't1'], ['tok-bob', 'u-bob', 't1']].sort(), 'marks t1 (outgoing), not t2 (just started)');
|
|
});
|
|
|
|
it('never marks a member Played if they have no connection or no token yet', (t) => {
|
|
let called = false;
|
|
t.mock.method(PlaystateService, 'markPlayed', async () => { called = true; });
|
|
|
|
const party = fakeParty('p3c');
|
|
party.members.set('u-alice', { ...fakeMember('u-alice', 'Alice', 'tok-alice'), connection: null });
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
|
MusicQueueService.addTrack(party, { itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 30_000_000 }, 'u-bob', 'Bob');
|
|
|
|
MusicQueueService.voteSkip(party, 'u-alice', 'Alice', 1);
|
|
|
|
assert.equal(called, false);
|
|
});
|
|
|
|
it('pause freezes elapsed time; resume continues it from where it froze', async () => {
|
|
const party = fakeParty('p4');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 }, 'u-alice', 'Alice');
|
|
|
|
MusicQueueService.setPaused(party, true);
|
|
assert.equal(music(party).current?.isPaused, true);
|
|
const frozen = MusicQueueService.getElapsedSeconds(party);
|
|
await sleep(50);
|
|
assert.equal(MusicQueueService.getElapsedSeconds(party), frozen, 'time must not advance while paused');
|
|
|
|
MusicQueueService.setPaused(party, false);
|
|
assert.equal(music(party).current?.isPaused, false);
|
|
await sleep(50);
|
|
assert.ok(MusicQueueService.getElapsedSeconds(party) >= frozen, 'time should advance again after resuming');
|
|
|
|
MusicQueueService.destroy(party); // avoid leaking this test's own long-duration timer
|
|
});
|
|
|
|
it('seek while paused jumps the frozen elapsed time directly, without needing a resume', () => {
|
|
const party = fakeParty('p4b');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 /* 60s */ }, 'u-alice', 'Alice');
|
|
MusicQueueService.setPaused(party, true);
|
|
|
|
MusicQueueService.seek(party, 30);
|
|
|
|
assert.equal(music(party).current?.isPaused, true, 'seeking must not itself resume playback');
|
|
assert.equal(MusicQueueService.getElapsedSeconds(party), 30);
|
|
});
|
|
|
|
it('seek clamps into [0, duration] rather than trusting an out-of-range value verbatim', () => {
|
|
const party = fakeParty('p4c');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 100_000_000 /* 10s */ }, 'u-alice', 'Alice');
|
|
MusicQueueService.setPaused(party, true);
|
|
|
|
MusicQueueService.seek(party, 999);
|
|
assert.equal(MusicQueueService.getElapsedSeconds(party), 10, 'clamped to the track\'s own duration');
|
|
|
|
MusicQueueService.seek(party, -5);
|
|
assert.equal(MusicQueueService.getElapsedSeconds(party), 0, 'clamped to zero, not left negative');
|
|
});
|
|
|
|
it('seek while playing re-arms the auto-advance timer against the new position, not the old one', async () => {
|
|
const party = fakeParty('p4d');
|
|
// Exactly a 2s track (20_000_000 ticks / 10_000_000 per second - no
|
|
// rounding to worry about) - seeking to 1.7s leaves ~300ms remaining.
|
|
// If the timer were left un-re-armed (still counting down from the
|
|
// original full 2s mark instead), it would still be playing at the
|
|
// 600ms mark below - comfortably short of the original ~2000ms, so
|
|
// this genuinely distinguishes the two.
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 20_000_000 }, 'u-alice', 'Alice');
|
|
|
|
MusicQueueService.seek(party, 1.7);
|
|
assert.equal(music(party).current?.track.itemId, 't1', 'still the same track right after seeking');
|
|
|
|
await sleep(600); // past the re-armed ~300ms, comfortably short of the original ~2000ms
|
|
assert.equal(music(party).current, null, 'nothing queued behind it - should have auto-advanced off the re-armed timer');
|
|
});
|
|
|
|
it('seek while playing clears the old timer before scheduling a new one - never double-scheduled', (t) => {
|
|
const realClearTimeout = global.clearTimeout;
|
|
const cleared = mock.fn(realClearTimeout);
|
|
global.clearTimeout = cleared as typeof clearTimeout;
|
|
t.after(() => { global.clearTimeout = realClearTimeout; });
|
|
|
|
const party = fakeParty('p4e');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 /* 60s - stays pending */ }, 'u-alice', 'Alice');
|
|
|
|
MusicQueueService.seek(party, 10);
|
|
|
|
assert.equal(cleared.mock.callCount(), 1, 'the pre-seek timer must be cleared, not left running alongside the new one');
|
|
MusicQueueService.destroy(party); // avoid leaking this test's own long-duration timer
|
|
});
|
|
|
|
it('seek is a harmless no-op when nothing is currently playing', () => {
|
|
const party = fakeParty('p4f');
|
|
MusicQueueService.init(party, null, 'u-alice', 'Alice');
|
|
assert.doesNotThrow(() => MusicQueueService.seek(party, 30));
|
|
});
|
|
|
|
it('a short track auto-advances on its own via a real timer, with no client reporting "ended"', async () => {
|
|
const party = fakeParty('p5');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 /* 1s */ }, 'u-alice', 'Alice');
|
|
|
|
assert.notEqual(music(party).advanceTimer, null, 'a timer should be scheduled for the auto-advance');
|
|
await sleep(1300); // a bit over the 1s duration
|
|
assert.equal(music(party).current, null, 'nothing queued behind it, so current should now be empty');
|
|
});
|
|
|
|
it('destroy() clears a pending timer - the actual leak-prevention path', (t) => {
|
|
const realClearTimeout = global.clearTimeout;
|
|
const cleared = mock.fn(realClearTimeout);
|
|
global.clearTimeout = cleared as typeof clearTimeout;
|
|
t.after(() => { global.clearTimeout = realClearTimeout; });
|
|
|
|
const party = fakeParty('p6');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 /* 60s - stays pending */ }, 'u-alice', 'Alice');
|
|
assert.notEqual(music(party).advanceTimer, null);
|
|
|
|
MusicQueueService.destroy(party);
|
|
assert.equal(cleared.mock.callCount(), 1, 'clearTimeout should have been called exactly once');
|
|
assert.equal(music(party).advanceTimer, null);
|
|
});
|
|
|
|
it('removeVote() is a harmless no-op for a party with no music state at all', () => {
|
|
const videoParty: Party = { ...fakeParty('p7'), mediaType: 'video' };
|
|
delete videoParty.music;
|
|
assert.doesNotThrow(() => MusicQueueService.removeVote(videoParty, 'u-anyone'));
|
|
});
|
|
});
|