watchparty/server/test/musicQueueService.test.ts
Nicolas fafee359b9
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Sticky catalog search bar, and let anyone seek within a music track
Two small, unrelated requests handled together.

The search bar (tiles.ts's shared .searchBar, used by views/library/
artist/album) scrolled away with the page - asked to stay reachable
while scrolling a long grid. position: sticky; top: 0 on the input
itself; base.css's own generic input rule already gives it an opaque
background, which is what keeps tiles scrolling underneath from
showing through once it's stuck - nothing else needed changing.

Seeking within the current music track was explicit, deliberate scope
this session's own "participative playlist" work had cut - the
progress bar was marked read-only specifically because MusicQueueService
had no seek concept, just play/pause/advance. The permission model was
already decided by that class's own documented reasoning for why
*anyone* can pause/resume (reversible, doesn't change what's playing) -
a seek is exactly that same shape, continuous instead of binary, so it
gets the same treatment, not voteSkip's majority gate.

MusicQueueService.seek mirrors setPaused's own elapsed-time math
(clamped into [0, durationSeconds]), with one real difference: setPaused's
resume branch gets away with calling scheduleAdvance directly only
because pausing already cleared the timer first - scheduleAdvance
itself never clears a prior one. Seeking while still playing has no
such guarantee, so skipping that clear first would leave the OLD timer
(counting down against the old position) running alongside the new
one, eventually firing playNext at the wrong moment. New musicSeek
socket event, no host check (same as musicPlaystate). Client-side,
MusicBar's progress track gained the same pointerdown/move/up
drag-to-seek handlers the video player's seek bar already has - no host
branch to port this time, since a listen party has none - plus a
local, optimistic activeDriver.currentTime write before broadcasting,
for the same instant-feedback reason the video player's own
seekFromPointerEvent does that.

Verified: full typecheck/test/build on both projects (128/128 server
tests - new coverage for seek's clamping, its paused-vs-playing
branches, and a real-timer test proving the old advance timer is
cleared rather than left running alongside a re-armed one - the exact
bug the naive "just call scheduleAdvance again" version would have
shipped). No server needed for the sticky search bar - checked by
reading .mainContent's own layout directly (no nested scroll container
between it and the document, confirming position: sticky resolves
against the page scroll as intended).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 23:27:23 +02:00

236 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()
};
}
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'));
});
});