mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Final review of caeef65 caught a stale-currentIndex bug in
/play-next-song and cmdPlayNext: when the player is idle but
queue.currentIndex >= 0 (natural end-of-track, or playNext gave
up after retries without queue.clear), addNext splices mid-queue
and playAt(size-1) jumps PAST the inserted song to whatever
was last. Capture the insertion slot before calling addNext and
promote that exact index instead.
Add a regression test covering the [a,b,c,d] queue with
currentIndex=1 case -- splice at 2 yields x at index 2, but
size-1 would point at d (index 4).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
488 lines
16 KiB
TypeScript
488 lines
16 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
import { PlayQueue, type QueuedSong, PlayMode } from "./queue.js";
|
|
|
|
function makeSong(id: string, name: string = id): QueuedSong {
|
|
return {
|
|
id,
|
|
name,
|
|
artist: "Artist",
|
|
album: "Album",
|
|
platform: "netease",
|
|
url: `https://example.com/${id}.mp3`,
|
|
coverUrl: `https://example.com/${id}.jpg`,
|
|
duration: 240,
|
|
};
|
|
}
|
|
|
|
describe("PlayQueue", () => {
|
|
let queue: PlayQueue;
|
|
|
|
beforeEach(() => {
|
|
queue = new PlayQueue();
|
|
});
|
|
|
|
it("starts empty", () => {
|
|
expect(queue.isEmpty()).toBe(true);
|
|
expect(queue.current()).toBeNull();
|
|
expect(queue.size()).toBe(0);
|
|
});
|
|
|
|
it("adds and retrieves songs", () => {
|
|
queue.add(makeSong("1", "Song A"));
|
|
queue.add(makeSong("2", "Song B"));
|
|
expect(queue.size()).toBe(2);
|
|
expect(queue.list()[0].name).toBe("Song A");
|
|
expect(queue.list()[1].name).toBe("Song B");
|
|
});
|
|
|
|
it("plays first song when starting", () => {
|
|
queue.add(makeSong("1"));
|
|
queue.add(makeSong("2"));
|
|
queue.play();
|
|
expect(queue.current()?.id).toBe("1");
|
|
});
|
|
|
|
it("advances to next song in sequential mode", () => {
|
|
queue.setMode(PlayMode.Sequential);
|
|
queue.add(makeSong("1"));
|
|
queue.add(makeSong("2"));
|
|
queue.add(makeSong("3"));
|
|
queue.play();
|
|
expect(queue.current()?.id).toBe("1");
|
|
const next = queue.next();
|
|
expect(next?.id).toBe("2");
|
|
expect(queue.current()?.id).toBe("2");
|
|
});
|
|
|
|
it("returns null at end in sequential mode", () => {
|
|
queue.setMode(PlayMode.Sequential);
|
|
queue.add(makeSong("1"));
|
|
queue.play();
|
|
const next = queue.next();
|
|
expect(next).toBeNull();
|
|
});
|
|
|
|
it("loops in loop mode", () => {
|
|
queue.setMode(PlayMode.Loop);
|
|
queue.add(makeSong("1"));
|
|
queue.play();
|
|
const next = queue.next();
|
|
expect(next?.id).toBe("1");
|
|
});
|
|
|
|
it("goes to previous song", () => {
|
|
queue.add(makeSong("1"));
|
|
queue.add(makeSong("2"));
|
|
queue.play();
|
|
queue.next();
|
|
expect(queue.current()?.id).toBe("2");
|
|
queue.prev();
|
|
expect(queue.current()?.id).toBe("1");
|
|
});
|
|
|
|
it("removes song by index", () => {
|
|
queue.add(makeSong("1"));
|
|
queue.add(makeSong("2"));
|
|
queue.add(makeSong("3"));
|
|
queue.remove(1);
|
|
expect(queue.size()).toBe(2);
|
|
expect(queue.list()[1].id).toBe("3");
|
|
});
|
|
|
|
it("removing a song before current shifts current index", () => {
|
|
queue.setMode(PlayMode.Sequential);
|
|
queue.add(makeSong("A"));
|
|
queue.add(makeSong("B"));
|
|
queue.add(makeSong("C"));
|
|
queue.playAt(2); // playing C at index 2
|
|
queue.remove(0); // remove A (before current)
|
|
expect(queue.current()?.id).toBe("C"); // still on C
|
|
expect(queue.getCurrentIndex()).toBe(1);
|
|
});
|
|
|
|
it("removing the currently-playing song lets next() advance to the shifted song", () => {
|
|
queue.setMode(PlayMode.Sequential);
|
|
queue.add(makeSong("A"));
|
|
queue.add(makeSong("B"));
|
|
queue.add(makeSong("C"));
|
|
queue.add(makeSong("D"));
|
|
queue.playAt(2); // playing C
|
|
queue.remove(2); // remove C — D shifts into slot 2
|
|
// Before the fix this returned null (D was silently skipped)
|
|
expect(queue.next()?.id).toBe("D");
|
|
});
|
|
|
|
it("removing the only song clears the queue", () => {
|
|
queue.add(makeSong("only"));
|
|
queue.playAt(0);
|
|
queue.remove(0);
|
|
expect(queue.size()).toBe(0);
|
|
expect(queue.current()).toBeNull();
|
|
expect(queue.next()).toBeNull();
|
|
});
|
|
|
|
it("removing the last song while playing it advances to null in sequential mode", () => {
|
|
queue.setMode(PlayMode.Sequential);
|
|
queue.add(makeSong("A"));
|
|
queue.add(makeSong("B"));
|
|
queue.playAt(1); // playing B (last)
|
|
queue.remove(1);
|
|
expect(queue.size()).toBe(1);
|
|
// currentIndex moved to 0, so next() should try to advance past the end
|
|
expect(queue.next()).toBeNull();
|
|
});
|
|
|
|
it("clears all songs", () => {
|
|
queue.add(makeSong("1"));
|
|
queue.add(makeSong("2"));
|
|
queue.clear();
|
|
expect(queue.isEmpty()).toBe(true);
|
|
expect(queue.current()).toBeNull();
|
|
});
|
|
|
|
it("random mode returns a song", () => {
|
|
queue.setMode(PlayMode.Random);
|
|
queue.add(makeSong("1"));
|
|
queue.add(makeSong("2"));
|
|
queue.add(makeSong("3"));
|
|
queue.play();
|
|
const next = queue.next();
|
|
expect(next).not.toBeNull();
|
|
});
|
|
|
|
it("random mode with single song returns null on next", () => {
|
|
queue.setMode(PlayMode.Random);
|
|
queue.add(makeSong("1"));
|
|
queue.play();
|
|
expect(queue.next()).toBeNull();
|
|
});
|
|
|
|
it("random mode plays each song exactly once then stops", () => {
|
|
queue.setMode(PlayMode.Random);
|
|
queue.add(makeSong("A"));
|
|
queue.add(makeSong("B"));
|
|
queue.add(makeSong("C"));
|
|
queue.play();
|
|
const played = new Set<string>();
|
|
played.add(queue.current()!.id);
|
|
for (let i = 0; i < 3; i++) {
|
|
const song = queue.next();
|
|
if (!song) break;
|
|
played.add(song.id);
|
|
}
|
|
// All 3 songs should have been played
|
|
expect(played).toEqual(new Set(["A", "B", "C"]));
|
|
// next() after all played should return null
|
|
expect(queue.next()).toBeNull();
|
|
});
|
|
|
|
it("random mode: removing currently-playing song does not skip others", () => {
|
|
queue.setMode(PlayMode.Random);
|
|
queue.add(makeSong("A"));
|
|
queue.add(makeSong("B"));
|
|
queue.add(makeSong("C"));
|
|
queue.add(makeSong("D"));
|
|
queue.play(); // plays A (index 0)
|
|
const second = queue.next()!; // plays some song
|
|
// Remove the currently-playing song
|
|
const curIdx = queue.getCurrentIndex();
|
|
queue.remove(curIdx);
|
|
// Remaining songs (excluding A and the removed song) should all be reachable
|
|
const played = new Set<string>();
|
|
played.add("A"); // already played via play()
|
|
played.add(second.id); // played and then removed
|
|
let song = queue.next();
|
|
while (song) {
|
|
played.add(song.id);
|
|
song = queue.next();
|
|
}
|
|
// All 4 original songs should have been played or accounted for
|
|
expect(played).toEqual(new Set(["A", "B", "C", "D"]));
|
|
});
|
|
|
|
it("random mode: prev does not cause duplicate plays", () => {
|
|
queue.setMode(PlayMode.Random);
|
|
queue.add(makeSong("A"));
|
|
queue.add(makeSong("B"));
|
|
queue.add(makeSong("C"));
|
|
queue.play(); // plays A
|
|
queue.next(); // plays B or C
|
|
queue.prev(); // go back — this song is now marked as played
|
|
// Exhaust remaining songs
|
|
const ids: string[] = [];
|
|
let song = queue.next();
|
|
while (song) {
|
|
ids.push(song.id);
|
|
song = queue.next();
|
|
}
|
|
// No song ID should appear more than once across the entire session
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
});
|
|
|
|
it("random mode: adding song mid-playback includes the new song", () => {
|
|
queue.setMode(PlayMode.Random);
|
|
queue.add(makeSong("A"));
|
|
queue.add(makeSong("B"));
|
|
queue.play(); // plays A
|
|
queue.next(); // plays B
|
|
// Add a new song while all existing songs have been played
|
|
queue.add(makeSong("C"));
|
|
const song = queue.next();
|
|
expect(song).not.toBeNull();
|
|
expect(song!.id).toBe("C");
|
|
// After C, should stop
|
|
expect(queue.next()).toBeNull();
|
|
});
|
|
|
|
it("random mode: setMode preserves current song as played", () => {
|
|
queue.add(makeSong("A"));
|
|
queue.add(makeSong("B"));
|
|
queue.play(); // plays A in sequential mode
|
|
queue.setMode(PlayMode.Random); // switch to random — A should be marked played
|
|
// next() should only return B, never A again
|
|
const song = queue.next();
|
|
expect(song?.id).toBe("B");
|
|
expect(queue.next()).toBeNull();
|
|
});
|
|
|
|
it("random-loop mode never returns null", () => {
|
|
queue.setMode(PlayMode.RandomLoop);
|
|
queue.add(makeSong("1"));
|
|
queue.play();
|
|
for (let i = 0; i < 10; i++) {
|
|
expect(queue.next()).not.toBeNull();
|
|
}
|
|
});
|
|
|
|
it("playAt jumps to specific index", () => {
|
|
queue.add(makeSong("1"));
|
|
queue.add(makeSong("2"));
|
|
queue.add(makeSong("3"));
|
|
queue.playAt(2);
|
|
expect(queue.current()?.id).toBe("3");
|
|
});
|
|
|
|
describe("history-aware prev", () => {
|
|
it("walks back through played indices in random mode", () => {
|
|
queue.setMode(PlayMode.Random);
|
|
queue.add(makeSong("a"));
|
|
queue.add(makeSong("b"));
|
|
queue.add(makeSong("c"));
|
|
queue.add(makeSong("d"));
|
|
queue.add(makeSong("e"));
|
|
|
|
// Force a deterministic random sequence: a → c → e
|
|
queue.playAt(0);
|
|
queue.playAt(2);
|
|
queue.playAt(4);
|
|
expect(queue.current()?.id).toBe("e");
|
|
|
|
// prev pops back through history: e → c → a
|
|
expect(queue.prev()?.id).toBe("c");
|
|
expect(queue.prev()?.id).toBe("a");
|
|
});
|
|
|
|
it("returns null when history is empty in random mode", () => {
|
|
queue.setMode(PlayMode.Random);
|
|
queue.add(makeSong("a"));
|
|
queue.add(makeSong("b"));
|
|
queue.playAt(0);
|
|
// No further moves → history is empty (only 'a' is current, never pushed)
|
|
expect(queue.prev()).toBeNull();
|
|
});
|
|
|
|
it("preserves sequential prev when history is empty", () => {
|
|
queue.setMode(PlayMode.Sequential);
|
|
queue.add(makeSong("a"));
|
|
queue.add(makeSong("b"));
|
|
queue.add(makeSong("c"));
|
|
queue.play();
|
|
queue.next(); // currentIndex = 1
|
|
// Sequential next() pushed 0 to history → prev pops back to 0
|
|
expect(queue.prev()?.id).toBe("a");
|
|
});
|
|
|
|
it("clears history on play()", () => {
|
|
queue.setMode(PlayMode.Random);
|
|
queue.add(makeSong("a"));
|
|
queue.add(makeSong("b"));
|
|
queue.playAt(0);
|
|
queue.playAt(1);
|
|
queue.play(); // resets to index 0 and clears history
|
|
expect(queue.prev()).toBeNull();
|
|
});
|
|
|
|
it("clears history on clear()", () => {
|
|
queue.add(makeSong("a"));
|
|
queue.add(makeSong("b"));
|
|
queue.play();
|
|
queue.next();
|
|
queue.clear();
|
|
queue.add(makeSong("c"));
|
|
queue.play();
|
|
// History was wiped — no prev path available beyond index 0
|
|
expect(queue.prev()).toBeNull();
|
|
});
|
|
|
|
it("clears history on setMode()", () => {
|
|
queue.setMode(PlayMode.Sequential);
|
|
queue.add(makeSong("a"));
|
|
queue.add(makeSong("b"));
|
|
queue.play();
|
|
queue.next();
|
|
// Mode change resets context
|
|
queue.setMode(PlayMode.Random);
|
|
expect(queue.prev()).toBeNull();
|
|
});
|
|
|
|
it("drops history entries pointing at a removed song", () => {
|
|
queue.setMode(PlayMode.Random);
|
|
queue.add(makeSong("a"));
|
|
queue.add(makeSong("b"));
|
|
queue.add(makeSong("c"));
|
|
queue.playAt(0);
|
|
queue.playAt(1); // history: [0]
|
|
queue.playAt(2); // history: [0, 1]
|
|
// Remove song at index 1 → history entry 1 dropped
|
|
queue.remove(1);
|
|
// queue is now [a, c], history should be [0]
|
|
// current was at 2 → after remove shifts to 1 → song "c"
|
|
expect(queue.current()?.id).toBe("c");
|
|
expect(queue.prev()?.id).toBe("a");
|
|
});
|
|
|
|
it("does not push to history on prev itself", () => {
|
|
queue.setMode(PlayMode.Random);
|
|
queue.add(makeSong("a"));
|
|
queue.add(makeSong("b"));
|
|
queue.add(makeSong("c"));
|
|
queue.playAt(0);
|
|
queue.playAt(1);
|
|
queue.playAt(2); // history: [0, 1]
|
|
queue.prev(); // pops 1, history: [0]
|
|
queue.prev(); // pops 0, history: []
|
|
expect(queue.prev()).toBeNull(); // no fallback target in random mode
|
|
});
|
|
|
|
it("caps history at HISTORY_LIMIT (50) entries, dropping oldest", () => {
|
|
queue.setMode(PlayMode.Random);
|
|
// Build a queue large enough to overflow HISTORY_LIMIT
|
|
for (let i = 0; i < 60; i++) queue.add(makeSong(`s${i}`));
|
|
// Walk through 60 explicit picks → 59 pushes to history
|
|
// (playAt pushes the previous currentIndex; first call has -1
|
|
// which pushHistory rejects). After 60 playAts, history holds
|
|
// the last 50 of those 59 entries.
|
|
for (let i = 0; i < 60; i++) queue.playAt(i);
|
|
|
|
// Walk back through history. The first prev returns whatever the
|
|
// 50th-most-recent push was (= index 9, since pushes 0..58 happened
|
|
// and the oldest 9 fell off). We can verify by counting prevs that
|
|
// succeed before history exhausts and prev returns null in random.
|
|
let count = 0;
|
|
while (queue.prev() !== null) {
|
|
count++;
|
|
if (count > 100) break; // safety
|
|
}
|
|
expect(count).toBe(50);
|
|
});
|
|
});
|
|
|
|
describe("addNext", () => {
|
|
it("appends when queue is empty (no current)", () => {
|
|
queue.addNext(makeSong("a"));
|
|
expect(queue.size()).toBe(1);
|
|
expect(queue.list()[0].id).toBe("a");
|
|
});
|
|
|
|
it("appends when nothing is currently playing (currentIndex < 0)", () => {
|
|
queue.add(makeSong("a"));
|
|
queue.add(makeSong("b"));
|
|
// No play() yet → currentIndex still -1
|
|
queue.addNext(makeSong("c"));
|
|
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "c"]);
|
|
});
|
|
|
|
it("inserts at currentIndex+1 mid-queue", () => {
|
|
queue.add(makeSong("a"));
|
|
queue.add(makeSong("b"));
|
|
queue.add(makeSong("c"));
|
|
queue.add(makeSong("d"));
|
|
queue.play(); // current = 0 (a)
|
|
queue.next(); // current = 1 (b)
|
|
queue.addNext(makeSong("x"));
|
|
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "x", "c", "d"]);
|
|
expect(queue.current()?.id).toBe("b"); // current unchanged
|
|
});
|
|
|
|
it("makes the inserted song play next when next() is called", () => {
|
|
queue.setMode(PlayMode.Sequential);
|
|
queue.add(makeSong("a"));
|
|
queue.add(makeSong("b"));
|
|
queue.play(); // current = 0 (a)
|
|
queue.addNext(makeSong("x"));
|
|
expect(queue.next()?.id).toBe("x");
|
|
});
|
|
|
|
it("shifts playedIndices entries > currentIndex by +1", () => {
|
|
queue.setMode(PlayMode.Random);
|
|
queue.add(makeSong("a"));
|
|
queue.add(makeSong("b"));
|
|
queue.add(makeSong("c"));
|
|
queue.add(makeSong("d"));
|
|
queue.playAt(2); // current = 2 (c), played = {2}
|
|
queue.playAt(3); // current = 3 (d), played = {2, 3}
|
|
queue.playAt(2); // current = 2 (c), played = {2, 3}
|
|
// Now insert after c — d's index 3 should become 4
|
|
queue.addNext(makeSong("x"));
|
|
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "c", "x", "d"]);
|
|
// After addNext: currentIndex still 2; played should be {2, 4}
|
|
// (the previously-played 'd' is now at index 4)
|
|
// Verify by removing 'x' (index 3) — d should remain played at index 3
|
|
queue.remove(3);
|
|
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "c", "d"]);
|
|
});
|
|
|
|
it("shifts history entries > currentIndex by +1", () => {
|
|
queue.setMode(PlayMode.Random);
|
|
queue.add(makeSong("a"));
|
|
queue.add(makeSong("b"));
|
|
queue.add(makeSong("c"));
|
|
queue.add(makeSong("d"));
|
|
queue.playAt(0); // current = 0
|
|
queue.playAt(3); // current = 3 (d), history = [0]
|
|
queue.playAt(1); // current = 1 (b), history = [0, 3]
|
|
queue.addNext(makeSong("x"));
|
|
// Insert at index 2 → entries > 1 shift +1 → history becomes [0, 4]
|
|
// queue: [a, b, x, c, d]; d is now at index 4
|
|
// prev → pop 4 → song at index 4 = d
|
|
expect(queue.prev()?.id).toBe("d");
|
|
// prev again → pop 0 → song at index 0 = a
|
|
expect(queue.prev()?.id).toBe("a");
|
|
});
|
|
|
|
it("idle player + stale currentIndex: insertion target is currentIndex+1, not size-1", () => {
|
|
// Reproduces the scenario where the player has gone idle but the
|
|
// queue still has a non-negative currentIndex (e.g., after natural
|
|
// track end without queue.clear()).
|
|
queue.add(makeSong("a"));
|
|
queue.add(makeSong("b"));
|
|
queue.add(makeSong("c"));
|
|
queue.add(makeSong("d"));
|
|
queue.play(); // current = 0 (a)
|
|
queue.next(); // current = 1 (b)
|
|
// Simulate idle-with-stale-currentIndex: the player has gone idle
|
|
// but queue still points at b.
|
|
// Caller pre-captures insertedAt:
|
|
const insertedAt = queue.getCurrentIndex() + 1; // = 2
|
|
queue.addNext(makeSong("x"));
|
|
// queue is now [a, b, x, c, d]
|
|
// size-1 would be 4 (d) — WRONG.
|
|
// insertedAt is 2 (x) — RIGHT.
|
|
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "x", "c", "d"]);
|
|
expect(queue.size() - 1).toBe(4); // proves size-1 strategy would pick d
|
|
const promoted = queue.playAt(insertedAt);
|
|
expect(promoted?.id).toBe("x");
|
|
});
|
|
});
|
|
});
|