import { describe, it, expect, vi } from "vitest"; import { EventEmitter } from "node:events"; import { BotInstance, COMMAND_DENIED_MESSAGE, spotifyPortsForBotId } from "./instance.js"; import type { BotInstanceOptions } from "./instance.js"; import { PlayQueue, PlayMode } from "../audio/queue.js"; import { createDatabase, SHARED_QUEUE_OWNER } from "../data/database.js"; import { parseCommand } from "./commands.js"; import type { TS3TextMessage } from "../ts-protocol/client.js"; import type { SpotifyController } from "../music/spotify/controller.js"; import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; import type { MusicProvider } from "../music/provider.js"; import type { BotDatabase } from "../data/database.js"; import type { AvatarStore } from "../data/avatars.js"; import type { BotConfig } from "../data/config.js"; import { ManagedVoiceClientRegistry } from "./managed-voice-clients.js"; // Constructing a real BotInstance is heavy (spawns a TS3Client, AudioPlayer, // reads avatars, etc.), and runExclusive only touches a single private field // (`playGate`). So we exercise the ACTUAL shipped method via its prototype, // bound to a minimal object carrying just that field. This proves the real // serializer logic without standing up a full bot. type Gate = { playGate: Promise }; const runExclusive = BotInstance.prototype.runExclusive as ( this: Gate, fn: () => Promise, ) => Promise; function makeGate(): Gate { return { playGate: Promise.resolve() }; } /** An explicit, timer-free deferred so ordering is deterministic. */ function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } describe("BotInstance.runExclusive — serialization", () => { it("does not start fnB until fnA settles", async () => { const gate = makeGate(); const order: string[] = []; const gateA = deferred(); const pA = runExclusive.call(gate, async () => { order.push("A-start"); await gateA.promise; // suspend A until we explicitly release it order.push("A-end"); }); const pB = runExclusive.call(gate, async () => { order.push("B-start"); order.push("B-end"); }); // Give the microtask queue a chance: B must NOT have started while A is // still suspended on gateA. await Promise.resolve(); await Promise.resolve(); expect(order).toEqual(["A-start"]); gateA.resolve(); await pA; await pB; expect(order).toEqual(["A-start", "A-end", "B-start", "B-end"]); }); it("runs fnB even if fnA rejects (chain survives rejection)", async () => { const gate = makeGate(); const order: string[] = []; const gateA = deferred(); const pA = runExclusive.call(gate, async () => { order.push("A-start"); await gateA.promise; throw new Error("A blew up"); }); const pB = runExclusive.call(gate, async () => { order.push("B-start"); order.push("B-end"); return "B-result"; }); await Promise.resolve(); await Promise.resolve(); expect(order).toEqual(["A-start"]); gateA.reject(new Error("A blew up")); await expect(pA).rejects.toThrow("A blew up"); // B still runs, only after A has fully settled. await expect(pB).resolves.toBe("B-result"); expect(order).toEqual(["A-start", "B-start", "B-end"]); }); it("preserves call order across three serialized tasks", async () => { const gate = makeGate(); const order: string[] = []; const tasks = ["X", "Y", "Z"]; const promises = tasks.map((t) => runExclusive.call(gate, async () => { order.push(`${t}-start`); await Promise.resolve(); order.push(`${t}-end`); }), ); await Promise.all(promises); expect(order).toEqual([ "X-start", "X-end", "Y-start", "Y-end", "Z-start", "Z-end", ]); }); }); describe("BotInstance voice-ducking lifecycle integration", () => { const connect = BotInstance.prototype.connect as unknown as ( this: Record, ) => Promise; function makeConnectContext(connectPromise: Promise) { return { disconnectEmitted: false, connected: false, tsClient: { connect: vi.fn(() => connectPromise), getResolvedVoiceEndpoint: vi.fn(() => ({ host: "203.0.113.20", port: 12000 })), }, configuredVoiceServerScope: { host: "voice-alias.example.com", voicePort: 9987, }, voiceServerScope: { host: "voice-alias.example.com", voicePort: 9987 }, voiceDucking: { reset: vi.fn() }, registerManagedVoiceClient: vi.fn(), profileManager: { onConnect: vi.fn() }, emit: vi.fn(), restoreQueueFromSnapshot: vi.fn(async () => {}), }; } it("registers its managed client only after a successful outer connect", async () => { const ctx = makeConnectContext(Promise.resolve()); await connect.call(ctx); expect(ctx.connected).toBe(true); expect(ctx.voiceServerScope).toEqual({ host: "203.0.113.20", voicePort: 12000 }); expect(ctx.voiceDucking.reset).toHaveBeenCalledWith(true); expect(ctx.registerManagedVoiceClient).toHaveBeenCalledOnce(); expect(ctx.profileManager.onConnect).toHaveBeenCalledOnce(); }); it("does not register a late handshake after disconnect aborted it", async () => { const handshake = deferred(); const ctx = makeConnectContext(handshake.promise); const result = connect.call(ctx); ctx.disconnectEmitted = true; handshake.resolve(); await expect(result).rejects.toThrow("Connect aborted by concurrent disconnect"); expect(ctx.connected).toBe(false); expect(ctx.registerManagedVoiceClient).not.toHaveBeenCalled(); expect(ctx.voiceDucking.reset).not.toHaveBeenCalled(); }); it("falls back to the configured endpoint when identity discovery is unavailable", async () => { const ctx = makeConnectContext(Promise.resolve()); ctx.tsClient.getResolvedVoiceEndpoint.mockReturnValue(null as any); await connect.call(ctx); expect(ctx.voiceServerScope).toEqual({ host: "voice-alias.example.com", voicePort: 9987, }); }); it("routes human voice activity but filters another managed bot", () => { const tsClient = new EventEmitter() as EventEmitter & { getClientId(): number; }; tsClient.getClientId = () => 10; const managedVoiceClients = new ManagedVoiceClientRegistry(); const voiceServerScope = { host: "voice.example.com", voicePort: 9987 }; managedVoiceClients.register( { host: "192.168.1.10", voicePort: 20_000 }, 20, {}, "managed-bot-uid=", ); managedVoiceClients.register(voiceServerScope, 22, {}, "fallback-bot-uid="); const handleVoiceActivity = vi.fn(); const ctx = { tsClient, connected: true, managedVoiceClients, voiceServerScope, voiceDucking: { handleVoiceActivity, removeSpeaker: vi.fn(), reset: vi.fn(), }, } as Record; (BotInstance.prototype as any).setupTsEvents.call(ctx); tsClient.emit("voiceActivity", { clientId: 20, codec: 5, clientUid: "managed-bot-uid=", }); // If a UID is momentarily unavailable, the scoped client-id registry is // retained as a fallback for the common same-endpoint case. tsClient.emit("voiceActivity", { clientId: 22, codec: 5 }); tsClient.emit("voiceActivity", { clientId: 21, codec: 5, clientUid: "human-uid=", }); expect(handleVoiceActivity).toHaveBeenCalledOnce(); expect(handleVoiceActivity).toHaveBeenCalledWith(21); }); it("keeps a disconnecting bot registered during the in-flight packet grace", () => { vi.useFakeTimers(); try { const managedVoiceClients = new ManagedVoiceClientRegistry(); const voiceServerScope = { host: "voice.example.com", voicePort: 9987 }; const owner = {}; managedVoiceClients.register( voiceServerScope, 20, owner, "managed-bot-uid=", ); const ctx = { managedVoiceClients, voiceServerScope, registeredVoiceClientId: 20, registeredVoiceClientOwner: owner, registeredVoiceClientScope: voiceServerScope, registeredVoiceClientUid: "managed-bot-uid=", }; (BotInstance.prototype as any).unregisterManagedVoiceClient.call(ctx, 1_000); // A reconnect may resolve to a new endpoint before the grace expires; // cleanup must still target the scope that owned the old client id. ctx.voiceServerScope = { host: "other.example.com", voicePort: 9987 }; expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(true); expect(managedVoiceClients.hasClientUid("managed-bot-uid=")).toBe(true); vi.advanceTimersByTime(999); expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(true); vi.advanceTimersByTime(1); expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(false); expect(managedVoiceClients.hasClientUid("managed-bot-uid=")).toBe(false); } finally { vi.useRealTimers(); } }); }); /** Minimal `this` carrying only what handleTextMessage's gate path touches. * The gate methods live on the prototype and are attached here so calls like * `this.isCommandAllowed(...)` resolve against this same object. */ function makeGateCtx(opts: { adminGroups?: number[]; lookupGroups?: string[]; lookupThrows?: boolean; }) { const ctx: any = { config: { commandPrefix: "!", commandAliases: {}, adminGroups: opts.adminGroups ?? [] }, logger: { info: vi.fn(), error: vi.fn() }, tsClient: { sendTextMessage: vi.fn(async () => {}), getClientServerGroups: vi.fn(async () => { if (opts.lookupThrows) throw new Error("query failed"); return opts.lookupGroups ?? []; }), }, executeCommand: vi.fn(async () => null), isCommandAllowed: (BotInstance.prototype as any).isCommandAllowed, lookupInvokerGroups: (BotInstance.prototype as any).lookupInvokerGroups, }; return ctx; } function makeMsg(message: string, invokerGroups: string[] = [], invokerId = "5"): TS3TextMessage { return { invokerName: "Tester", invokerId, invokerUid: "uid", message, targetMode: 2, invokerGroups }; } const handleTextMessage = (BotInstance.prototype as any).handleTextMessage as ( this: unknown, msg: TS3TextMessage, ) => Promise; describe("BotInstance.handleTextMessage — command permission gate", () => { it("runs a public command with no group lookup, even under enforcement", async () => { const ctx = makeGateCtx({ adminGroups: [6] }); await handleTextMessage.call(ctx, makeMsg("!play 晴天", ["6"])); expect(ctx.executeCommand).toHaveBeenCalledTimes(1); expect(ctx.tsClient.getClientServerGroups).not.toHaveBeenCalled(); expect(ctx.tsClient.sendTextMessage).not.toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE); }); it("runs an admin command with no lookup when enforcement is off", async () => { const ctx = makeGateCtx({ adminGroups: [] }); await handleTextMessage.call(ctx, makeMsg("!stop")); expect(ctx.executeCommand).toHaveBeenCalledTimes(1); expect(ctx.tsClient.getClientServerGroups).not.toHaveBeenCalled(); }); it("allows an enforced admin command when the live lookup returns a matching group", async () => { const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["6"] }); await handleTextMessage.call(ctx, makeMsg("!stop")); expect(ctx.tsClient.getClientServerGroups).toHaveBeenCalledTimes(1); expect(ctx.executeCommand).toHaveBeenCalledTimes(1); }); it("denies an enforced admin command when the live lookup has no matching group", async () => { const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["8"] }); await handleTextMessage.call(ctx, makeMsg("!stop")); expect(ctx.executeCommand).not.toHaveBeenCalled(); expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE); }); it("fails closed when the live lookup returns no groups", async () => { const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: [] }); await handleTextMessage.call(ctx, makeMsg("!stop")); expect(ctx.executeCommand).not.toHaveBeenCalled(); expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE); }); it("fails closed when the live lookup throws", async () => { const ctx = makeGateCtx({ adminGroups: [6], lookupThrows: true }); await handleTextMessage.call(ctx, makeMsg("!stop")); expect(ctx.executeCommand).not.toHaveBeenCalled(); expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE); }); it("ignores stale event groups: a demoted sender (cached match) is denied by the live lookup", async () => { const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["8"] }); await handleTextMessage.call(ctx, makeMsg("!stop", ["6"])); expect(ctx.executeCommand).not.toHaveBeenCalled(); expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE); }); it("uses live groups, not stale event groups: a freshly-promoted sender is allowed", async () => { const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["6"] }); await handleTextMessage.call(ctx, makeMsg("!stop", ["8"])); expect(ctx.executeCommand).toHaveBeenCalledTimes(1); }); it("resolves out-of-channel senders server-wide: empty event groups but a matching live group → allowed", async () => { const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["6"] }); await handleTextMessage.call(ctx, makeMsg("!stop", [], "5")); expect(ctx.tsClient.getClientServerGroups).toHaveBeenCalledTimes(1); expect(ctx.executeCommand).toHaveBeenCalledTimes(1); }); }); describe("BotInstance.getProviderFor — spotify routing", () => { it("getProviderFor routes 'spotify' to the injected spotify provider", () => { const spotify = { platform: "spotify" } as any; const ctx = { spotifyProvider: spotify, neteaseProvider: { platform: "netease" } } as any; expect(BotInstance.prototype.getProviderFor.call(ctx, "spotify" as any)).toBe(spotify); }); }); // --- Spotify orchestration (Task 7 + Correction C4) ------------------------ // These drive the REAL prototype methods on a hand-built ctx (the file's // established `.call(ctx)` style) and assert the routing DECISIONS. Live audio // is not testable here. C4 supersedes the brief where they conflict: switching // a URL track -> spotify does NOT call player.stop() (playPcmStream fences the // prior ffmpeg internally), and a spotify -> spotify handoff does NOT re-attach // the persistent PCM stream (playPcmStream is called ONCE across both tracks). const resolveAndPlay = BotInstance.prototype.resolveAndPlay as ( this: unknown, song: any, ) => Promise; const setupPlayerEvents = (BotInstance.prototype as any).setupPlayerEvents as ( this: unknown, ) => void; const cmdPause = (BotInstance.prototype as any).cmdPause as (this: unknown) => string; const cmdResume = (BotInstance.prototype as any).cmdResume as (this: unknown) => string; const cmdStop = (BotInstance.prototype as any).cmdStop as (this: unknown) => string; const handleOccupancy = (BotInstance.prototype as any).handleOccupancy as ( this: unknown, userCount: number, ) => void; const seek = (BotInstance.prototype as any).seek as (this: unknown, seconds: number) => void; const playNext = (BotInstance.prototype as any).playNext as ( this: unknown, maxRetries?: number, ) => Promise; const cmdRemove = (BotInstance.prototype as any).cmdRemove as ( this: unknown, cmd: any, ) => Promise | string; function makeController() { return { ensureStarted: vi.fn(async () => true), playTrack: vi.fn(async () => true), getPcmStream: vi.fn(() => ({ kind: "pcm" } as any)), pause: vi.fn(async () => {}), resume: vi.fn(async () => {}), seek: vi.fn(async () => {}), stop: vi.fn(() => {}), on: vi.fn(), }; } function makePlayer() { // `externalActive` mirrors the real AudioPlayer: playPcmStream attaches the // external stream (true), and both stop() and play() detach it (false). The // re-attach guard reads isExternalActive(), so this must track that state. // `state` mirrors the real player's PlayerState transitions so tests can // observe paused→playing (R3-3): playPcmStream/play → "playing", stop → // "idle", pause → "paused" (only from playing), resume → "playing" (only // from paused). Existing tests never read state, so tracking it is inert // for them. let externalActive = false; let state: "idle" | "playing" | "paused" = "idle"; return { play: vi.fn((..._args: any[]) => { externalActive = false; state = "playing"; }), stop: vi.fn(() => { externalActive = false; state = "idle"; }), playPcmStream: vi.fn((..._args: any[]) => { externalActive = true; state = "playing"; }), pause: vi.fn(() => { if (state === "playing") state = "paused"; }), resume: vi.fn(() => { if (state === "paused") state = "playing"; }), seek: vi.fn(), isExternalActive: vi.fn(() => externalActive), getState: vi.fn(() => state), }; } function makeResolveCtx(opts: { controller: ReturnType; player: ReturnType; url: string; currentSourceIsSpotify?: boolean; }) { return { connected: true, config: {}, id: "bot1", voteSkipUsers: new Set(), autoPaused: false, currentSourceIsSpotify: opts.currentSourceIsSpotify ?? false, effectiveDuration: undefined, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, tsClient: { sendTextMessage: vi.fn(async () => {}) }, database: { addPlayHistory: vi.fn() }, spotifyController: opts.controller, player: opts.player, getProviderFor: vi.fn(() => ({ getSongUrl: async () => ({ url: opts.url }) })), syncProfileToSong: vi.fn(async () => {}), emit: vi.fn(), } as any; } function spotifySong() { return { id: "abc", name: "Song", artist: "Artist", album: "Album", platform: "spotify", coverUrl: "c", duration: 200, url: "", }; } describe("BotInstance.resolveAndPlay — Spotify routing (C4)", () => { it("routes a spotify song to controller.playTrack + player.playPcmStream, not player.play", async () => { const controller = makeController(); const player = makePlayer(); const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc" }); const ok = await resolveAndPlay.call(ctx, spotifySong()); expect(ok).toBe(true); expect(controller.ensureStarted).toHaveBeenCalledTimes(1); expect(controller.playTrack).toHaveBeenCalledWith("spotify:track:abc"); expect(player.playPcmStream).toHaveBeenCalledTimes(1); expect(player.playPcmStream.mock.calls[0][0]).toEqual({ kind: "pcm" }); expect(player.play).not.toHaveBeenCalled(); // C4: playPcmStream fences the prior url-ffmpeg internally — no player.stop(). expect(player.stop).not.toHaveBeenCalled(); expect(ctx.currentSourceIsSpotify).toBe(true); expect(ctx.database.addPlayHistory).toHaveBeenCalledTimes(1); expect(ctx.emit).toHaveBeenCalledWith("stateChange"); }); it("returns false + sends the Stage-1 fallback when the backend is unavailable", async () => { const controller = makeController(); controller.ensureStarted = vi.fn(async () => false); const player = makePlayer(); const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc" }); const ok = await resolveAndPlay.call(ctx, spotifySong()); expect(ok).toBe(false); expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledTimes(1); expect(controller.playTrack).not.toHaveBeenCalled(); expect(player.playPcmStream).not.toHaveBeenCalled(); expect(player.play).not.toHaveBeenCalled(); }); it("attaches the PCM stream once (no player.stop) when switching URL -> spotify", async () => { const controller = makeController(); const player = makePlayer(); const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc", currentSourceIsSpotify: false, }); await resolveAndPlay.call(ctx, spotifySong()); // C4: NO player.stop() on the URL -> spotify transition. expect(player.stop).not.toHaveBeenCalled(); expect(player.playPcmStream).toHaveBeenCalledTimes(1); }); it("does NOT re-attach the stream on a spotify -> spotify handoff (playPcmStream called once across two tracks)", async () => { const controller = makeController(); const player = makePlayer(); const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc", currentSourceIsSpotify: false, }); // First spotify track: coming from a URL/idle source -> attach. await resolveAndPlay.call(ctx, spotifySong()); expect(ctx.currentSourceIsSpotify).toBe(true); // Second spotify track: go-librespot changes tracks into the SAME FIFO. await resolveAndPlay.call(ctx, spotifySong()); expect(player.playPcmStream).toHaveBeenCalledTimes(1); // NOT re-attached expect(controller.playTrack).toHaveBeenCalledTimes(2); // both tracks played expect(player.stop).not.toHaveBeenCalled(); }); it("RE-attaches on a spotify -> (command player.stop) -> spotify sequence (does not stay silent)", async () => { // Regression: command paths (cmdPlay/cmdPlaylist/cmdAlbum/cmdFm) call // player.stop() — which DETACHES the external stream — WITHOUT clearing the // currentSourceIsSpotify flag. Gating re-attach on the stale flag skipped // playPcmStream, silencing the next spotify track. We now gate on the // player's actual external state, so the re-attach happens. const controller = makeController(); const player = makePlayer(); const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc", currentSourceIsSpotify: false, }); // First spotify track attaches the persistent PCM stream. await resolveAndPlay.call(ctx, spotifySong()); expect(player.playPcmStream).toHaveBeenCalledTimes(1); expect(player.isExternalActive()).toBe(true); // A command path stops the player (detaches the stream) but leaves the // spotify flag stale-true — exactly the state that used to cause silence. player.stop(); expect(player.isExternalActive()).toBe(false); expect(ctx.currentSourceIsSpotify).toBe(true); // flag NOT cleared by stop() // Next spotify track MUST re-attach (gate on player external state, not flag). await resolveAndPlay.call(ctx, spotifySong()); expect(player.playPcmStream).toHaveBeenCalledTimes(2); expect(player.isExternalActive()).toBe(true); }); it("returns false + sends the fallback when playTrack resolves false (dead/failed sidecar)", async () => { const controller = makeController(); controller.playTrack = vi.fn(async () => false); const player = makePlayer(); const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc" }); const ok = await resolveAndPlay.call(ctx, spotifySong()); expect(ok).toBe(false); expect(controller.playTrack).toHaveBeenCalledTimes(1); // Same Stage-1 fallback message as the backend-unavailable path. expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledTimes(1); // Never attach the player to a dead stream. expect(player.playPcmStream).not.toHaveBeenCalled(); expect(ctx.currentSourceIsSpotify).toBe(false); }); it("recovers on mid-session sidecar death: onExternalEnd stops controller+player and clears the flag", async () => { const controller = makeController(); const player = makePlayer(); const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc" }); await resolveAndPlay.call(ctx, spotifySong()); expect(ctx.currentSourceIsSpotify).toBe(true); expect(player.playPcmStream).toHaveBeenCalledTimes(1); // The sidecar PCM stream EOFs mid-session → fire the wired onExternalEnd. const opts = player.playPcmStream.mock.calls[0][1] as { onExternalEnd?: () => void }; expect(typeof opts.onExternalEnd).toBe("function"); opts.onExternalEnd!(); // Recovery: controller torn down (next track rebuilds), player stopped // (drops external mode so the next track re-attaches), flag cleared. expect(controller.stop).toHaveBeenCalledTimes(1); expect(player.stop).toHaveBeenCalledTimes(1); expect(ctx.currentSourceIsSpotify).toBe(false); }); it("pauses the sidecar and clears the flag when switching to a non-spotify track", async () => { const controller = makeController(); const player = makePlayer(); const song = { ...spotifySong(), platform: "netease" }; const ctx = makeResolveCtx({ controller, player, url: "http://cdn/x.mp3", currentSourceIsSpotify: true, }); const ok = await resolveAndPlay.call(ctx, song); expect(ok).toBe(true); expect(controller.pause).toHaveBeenCalledTimes(1); expect(ctx.currentSourceIsSpotify).toBe(false); expect(player.play).toHaveBeenCalledWith("http://cdn/x.mp3", 0, 200); expect(player.playPcmStream).not.toHaveBeenCalled(); }); // R3-3: spotify A playing → pause → skip to spotify B. The persistent PCM // stream stays attached through the pause, so the re-attach gate skips // playPcmStream (which is what sets state='playing'). Without an explicit // resume the player would sit 'paused' and emit silence while the sidecar // decodes B. The reuse path must force the player back to PLAYING. it("resumes the player when skipping from a PAUSED spotify track to another spotify track (R3-3)", async () => { const controller = makeController(); const player = makePlayer(); const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc", currentSourceIsSpotify: false, }); // Spotify A starts → PCM stream attached, player playing. await resolveAndPlay.call(ctx, spotifySong()); expect(player.getState()).toBe("playing"); expect(player.isExternalActive()).toBe(true); // User pauses: player paused; the external stream stays attached. player.pause(); expect(player.getState()).toBe("paused"); expect(player.isExternalActive()).toBe(true); // Skip to spotify B via the external-stream-reuse path (playPcmStream skipped). await resolveAndPlay.call(ctx, spotifySong()); // Player must be PLAYING again (frames would emit), not stuck paused. expect(player.getState()).toBe("playing"); // Reuse path — the persistent stream was NOT re-attached. expect(player.playPcmStream).toHaveBeenCalledTimes(1); expect(player.resume).toHaveBeenCalled(); }); // The normal (non-paused) spotify→spotify handoff must be unaffected: resume // on an already-playing player is a no-op, so state stays 'playing'. it("keeps a non-paused spotify→spotify handoff playing (R3-3 no-regression)", async () => { const controller = makeController(); const player = makePlayer(); const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc", currentSourceIsSpotify: false, }); await resolveAndPlay.call(ctx, spotifySong()); await resolveAndPlay.call(ctx, spotifySong()); expect(player.getState()).toBe("playing"); expect(player.playPcmStream).toHaveBeenCalledTimes(1); }); }); describe("BotInstance.setupPlayerEvents — controller trackEnded wiring", () => { function makeEventCtx(currentPlatform: string) { return { spotifyController: { on: vi.fn() }, player: { on: vi.fn() }, queue: { current: vi.fn(() => ({ platform: currentPlatform })) }, logger: { debug: vi.fn(), error: vi.fn() }, playNext: vi.fn(async () => true), } as any; } function trackEndedHandler(ctx: any) { const call = ctx.spotifyController.on.mock.calls.find( (c: any[]) => c[0] === "trackEnded", ); expect(call).toBeDefined(); return call[1] as (e: any) => void; } it("advances via playNext when the current song is spotify", () => { const ctx = makeEventCtx("spotify"); setupPlayerEvents.call(ctx); trackEndedHandler(ctx)({ uri: "spotify:track:x", reason: "ended" }); expect(ctx.playNext).toHaveBeenCalledTimes(1); }); it("ignores controller trackEnded when the current song is not spotify", () => { const ctx = makeEventCtx("netease"); setupPlayerEvents.call(ctx); trackEndedHandler(ctx)({ uri: "spotify:track:x", reason: "ended" }); expect(ctx.playNext).not.toHaveBeenCalled(); }); }); describe("BotInstance transport delegation — spotify current song", () => { function makeCmdCtx(currentPlatform: string) { return { player: { pause: vi.fn(), resume: vi.fn(), stop: vi.fn() }, spotifyController: { pause: vi.fn(async () => {}), resume: vi.fn(async () => {}), stop: vi.fn(() => {}), }, queue: { current: vi.fn(() => ({ platform: currentPlatform })), clear: vi.fn() }, logger: { warn: vi.fn() }, emit: vi.fn(), autoPaused: true, currentSourceIsSpotify: true, sweepLocalAudio: vi.fn(), disableFmMode: vi.fn(), profileManager: { onSongChange: vi.fn(async () => {}) }, } as any; } it("cmdPause delegates to controller.pause when current is spotify", () => { const ctx = makeCmdCtx("spotify"); cmdPause.call(ctx); expect(ctx.player.pause).toHaveBeenCalled(); expect(ctx.spotifyController.pause).toHaveBeenCalledTimes(1); }); it("cmdResume delegates to controller.resume when current is spotify", () => { const ctx = makeCmdCtx("spotify"); cmdResume.call(ctx); expect(ctx.player.resume).toHaveBeenCalled(); expect(ctx.spotifyController.resume).toHaveBeenCalledTimes(1); }); it("cmdStop stops the sidecar + player and clears the spotify flag", () => { const ctx = makeCmdCtx("spotify"); cmdStop.call(ctx); expect(ctx.spotifyController.stop).toHaveBeenCalledTimes(1); expect(ctx.player.stop).toHaveBeenCalledTimes(1); expect(ctx.queue.clear).toHaveBeenCalledTimes(1); expect(ctx.currentSourceIsSpotify).toBe(false); }); it("does NOT touch the controller when current is not spotify", () => { const ctx = makeCmdCtx("netease"); cmdPause.call(ctx); cmdResume.call(ctx); expect(ctx.spotifyController.pause).not.toHaveBeenCalled(); expect(ctx.spotifyController.resume).not.toHaveBeenCalled(); }); }); // --- R3-2: removing the currently-playing Spotify track --------------------- // queue.remove() of the current index only decrements currentIndex; it never // stops the player or sidecar. A spotify track has NO player self-EOF advance // path, so leaving the sidecar running while queue.current() is no longer that // track wedges the bot in silence. cmdRemove must reconcile: stop the sidecar // and advance (or stop cleanly) — but ONLY for a current spotify track. describe("BotInstance.cmdRemove — spotify current-track reconciliation (R3-2)", () => { function makeRemoveCtx(opts: { currentIndex: number; currentSourceIsSpotify: boolean; removed: any; }) { return { currentSourceIsSpotify: opts.currentSourceIsSpotify, queue: { getCurrentIndex: vi.fn(() => opts.currentIndex), remove: vi.fn(() => opts.removed), }, spotifyController: makeController(), player: makePlayer(), playNext: vi.fn(async () => true), sweepLocalAudio: vi.fn(), emit: vi.fn(), logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, } as any; } it("stops the sidecar and advances when removing the CURRENT spotify track", async () => { const ctx = makeRemoveCtx({ currentIndex: 0, currentSourceIsSpotify: true, removed: { name: "A", platform: "spotify" }, }); const reply = await cmdRemove.call(ctx, { args: "1" }); // index 0 == current expect(reply).toContain("Removed: A"); expect(ctx.spotifyController.stop).toHaveBeenCalledTimes(1); expect(ctx.currentSourceIsSpotify).toBe(false); expect(ctx.player.stop).toHaveBeenCalledTimes(1); // Advance to whatever is now current (or stop cleanly if empty). expect(ctx.playNext).toHaveBeenCalledTimes(1); }); it("does NOT stop the sidecar when removing a NON-current track", async () => { const ctx = makeRemoveCtx({ currentIndex: 0, currentSourceIsSpotify: true, removed: { name: "B", platform: "spotify" }, }); await cmdRemove.call(ctx, { args: "2" }); // index 1 != current (0) expect(ctx.spotifyController.stop).not.toHaveBeenCalled(); expect(ctx.player.stop).not.toHaveBeenCalled(); expect(ctx.playNext).not.toHaveBeenCalled(); expect(ctx.currentSourceIsSpotify).toBe(true); }); it("does NOT stop the sidecar when the current track is NOT spotify (URL self-heals)", async () => { const ctx = makeRemoveCtx({ currentIndex: 0, currentSourceIsSpotify: false, // current is a URL track removed: { name: "A", platform: "netease" }, }); await cmdRemove.call(ctx, { args: "1" }); // index 0 == current expect(ctx.spotifyController.stop).not.toHaveBeenCalled(); expect(ctx.player.stop).not.toHaveBeenCalled(); expect(ctx.playNext).not.toHaveBeenCalled(); }); it("returns 'Invalid position' without touching the sidecar on a bad index", async () => { const ctx = makeRemoveCtx({ currentIndex: 0, currentSourceIsSpotify: true, removed: null, // queue.remove() rejects the index }); const reply = await cmdRemove.call(ctx, { args: "9" }); expect(reply).toBe("Invalid position"); expect(ctx.spotifyController.stop).not.toHaveBeenCalled(); expect(ctx.playNext).not.toHaveBeenCalled(); }); }); // --- R3-6: queue exhausts on a spotify track -------------------------------- // playNext's exhausted (non-FM) branch only called player.stop(); it left the // sidecar decoding and currentSourceIsSpotify stale — diverging from cmdStop. describe("BotInstance.playNext — spotify teardown on queue exhaust (R3-6)", () => { function makeExhaustCtx(opts: { currentSourceIsSpotify: boolean }) { return { connected: true, isAdvancing: false, isFmMode: false, currentSourceIsSpotify: opts.currentSourceIsSpotify, voteSkipUsers: new Set(), queue: { next: vi.fn(() => null), unplayedCount: vi.fn(() => 0) }, player: makePlayer(), spotifyController: makeController(), profileManager: { onSongChange: vi.fn(async () => {}) }, resolveAndPlay: vi.fn(async () => true), sweepLocalAudio: vi.fn(), emit: vi.fn(), logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, } as any; } it("stops the sidecar and clears the flag when a spotify queue exhausts", async () => { const ctx = makeExhaustCtx({ currentSourceIsSpotify: true }); const started = await playNext.call(ctx); expect(started).toBe(false); expect(ctx.spotifyController.stop).toHaveBeenCalledTimes(1); expect(ctx.currentSourceIsSpotify).toBe(false); expect(ctx.player.stop).toHaveBeenCalledTimes(1); }); it("does NOT stop the sidecar when a NON-spotify queue exhausts", async () => { const ctx = makeExhaustCtx({ currentSourceIsSpotify: false }); const started = await playNext.call(ctx); expect(started).toBe(false); expect(ctx.spotifyController.stop).not.toHaveBeenCalled(); expect(ctx.player.stop).toHaveBeenCalledTimes(1); }); }); describe("BotInstance.handleOccupancy — spotify auto-pause delegation (C4)", () => { function makeOccupancyCtx(currentPlatform: string, state: string) { return { player: { getState: () => state, pause: vi.fn(), resume: vi.fn() }, spotifyController: { pause: vi.fn(async () => {}), resume: vi.fn(async () => {}) }, queue: { current: vi.fn(() => ({ platform: currentPlatform })) }, config: { autoPauseOnEmpty: true }, autoPaused: false, logger: { warn: vi.fn() }, emit: vi.fn(), _scheduleIdleCheck: vi.fn(), _cancelIdleTimer: vi.fn(), } as any; } it("delegates pause to the controller when auto-pausing a spotify track (empty channel)", () => { const ctx = makeOccupancyCtx("spotify", "playing"); handleOccupancy.call(ctx, 0); expect(ctx.player.pause).toHaveBeenCalledTimes(1); expect(ctx.spotifyController.pause).toHaveBeenCalledTimes(1); expect(ctx.autoPaused).toBe(true); }); it("delegates resume to the controller when a listener returns to a spotify track", () => { const ctx = makeOccupancyCtx("spotify", "paused"); ctx.autoPaused = true; handleOccupancy.call(ctx, 1); expect(ctx.player.resume).toHaveBeenCalledTimes(1); expect(ctx.spotifyController.resume).toHaveBeenCalledTimes(1); expect(ctx.autoPaused).toBe(false); }); it("does NOT touch the controller when auto-pausing a non-spotify track", () => { const ctx = makeOccupancyCtx("netease", "playing"); handleOccupancy.call(ctx, 0); expect(ctx.player.pause).toHaveBeenCalledTimes(1); expect(ctx.spotifyController.pause).not.toHaveBeenCalled(); }); }); describe("BotInstance.seek — spotify routing (C4)", () => { function makeSeekCtx(currentPlatform: string) { return { queue: { current: vi.fn(() => ({ platform: currentPlatform })) }, spotifyController: { seek: vi.fn(async () => {}) }, player: { seek: vi.fn() }, logger: { warn: vi.fn() }, } as any; } it("routes seek to the controller for a spotify track, converting seconds -> ms", () => { const ctx = makeSeekCtx("spotify"); seek.call(ctx, 30); // 30 seconds // SpotifyController.seek is millisecond-based: 30s -> 30000ms (not 30). expect(ctx.spotifyController.seek).toHaveBeenCalledWith(30000); expect(ctx.player.seek).not.toHaveBeenCalled(); }); it("routes seek to the player (seconds-based) for a non-spotify track", () => { const ctx = makeSeekCtx("netease"); seek.call(ctx, 30); expect(ctx.player.seek).toHaveBeenCalledWith(30); expect(ctx.spotifyController.seek).not.toHaveBeenCalled(); }); }); // --- Spotify OAuth threading (Task 6, C3.1) -------------------------------- // The process-wide shared SpotifyOAuth must reach the SpotifyController via the // controller factory. We drive the REAL BotInstance constructor with a fake // controller factory that captures its param object, so the thread is observed // end-to-end (options.spotifyOAuth -> buildController({ oauth })). describe("BotInstance — spotifyOAuth threading to the controller factory (C3.1)", () => { function makeInstanceOptions(over: Partial = {}): { options: BotInstanceOptions; captured: { param?: { oauth?: SpotifyOAuth; instanceId?: string } }; } { const captured: { param?: { oauth?: SpotifyOAuth; instanceId?: string } } = {}; const provider = { platform: "netease" } as unknown as MusicProvider; const logger: any = { info() {}, warn() {}, error() {}, debug() {}, child() { return logger; }, }; const database = { getProfileConfig: () => ({}), getCustomAvatarPath: () => null, getPlayerSettings: () => ({ volume: 75, playMode: "seq" }), saveVolume: () => {}, savePlayMode: () => {}, } as unknown as BotDatabase; const options: BotInstanceOptions = { id: "bot-oauth-test", name: "OAuthBot", tsOptions: { host: "localhost", port: 9987, queryPort: 10011, nickname: "OAuthBot" } as any, neteaseProvider: provider, qqProvider: provider, bilibiliProvider: provider, youtubeProvider: provider, database, config: { spotify: {} } as unknown as BotConfig, logger, avatarStore: { read: () => null } as unknown as AvatarStore, spotifyControllerFactory: (o) => { captured.param = o; // Only `on` is touched during construction (setupPlayerEvents wires // the "trackEnded" listener); return a minimal fake controller. return { on: () => {} } as unknown as SpotifyController; }, ...over, }; return { options, captured }; } it("forwards the injected spotifyOAuth to the controller factory as `oauth`", () => { const sentinel = {} as unknown as SpotifyOAuth; const { options, captured } = makeInstanceOptions({ spotifyOAuth: sentinel }); // eslint-disable-next-line no-new new BotInstance(options); expect(captured.param).toBeDefined(); expect(captured.param?.oauth).toBe(sentinel); }); it("leaves the factory `oauth` undefined when no spotifyOAuth is supplied (behavior-unchanged)", () => { const { options, captured } = makeInstanceOptions(); // eslint-disable-next-line no-new new BotInstance(options); expect(captured.param).toBeDefined(); expect(captured.param?.oauth).toBeUndefined(); }); // R2-5: the bot id must reach the controller as `instanceId` so the backend // derives a UNIQUE Spotify Connect device name (-). Without it two // bots share the process-global deviceName and Connect commands misroute. it("forwards the bot id to the controller factory as `instanceId` (corner-case R2-5)", () => { const { options, captured } = makeInstanceOptions({ id: "bot-xyz" }); // eslint-disable-next-line no-new new BotInstance(options); expect(captured.param).toBeDefined(); expect(captured.param?.instanceId).toBe("bot-xyz"); }); }); describe("spotifyPortsForBotId — per-bot go-librespot ports (Fix 3)", () => { it("yields the SAME ports for the same bot id (stable across restarts)", () => { const a = spotifyPortsForBotId("bot-alpha"); const b = spotifyPortsForBotId("bot-alpha"); expect(a).toEqual(b); }); it("yields DIFFERENT ports for different bot ids", () => { const a = spotifyPortsForBotId("bot-alpha"); const b = spotifyPortsForBotId("bot-beta"); expect(a.apiPort).not.toBe(b.apiPort); expect(a.callbackPort).not.toBe(b.callbackPort); }); it("keeps apiPort and callbackPort in disjoint ranges", () => { for (const id of ["bot-alpha", "bot-beta", "x", "a-very-long-bot-identifier-123"]) { const { apiPort, callbackPort } = spotifyPortsForBotId(id); expect(apiPort).toBeGreaterThanOrEqual(3700); expect(apiPort).toBeLessThan(4700); expect(callbackPort).toBeGreaterThanOrEqual(8700); expect(callbackPort).toBeLessThan(9700); // Same offset within each range → the two never collide with each other. expect(callbackPort - apiPort).toBe(5000); } }); }); // --- Persisting volume + play mode across restarts (#125) ------------------ const cmdVol = (BotInstance.prototype as any).cmdVol as (this: unknown, cmd: any) => string; const cmdMode = (BotInstance.prototype as any).cmdMode as (this: unknown, cmd: any) => string; describe("BotInstance.cmdVol — persistence (#125)", () => { function makeVolCtx() { let stored = 75; return { id: "bot1", player: { setVolume: vi.fn((v: number) => { stored = v; }), getVolume: vi.fn(() => stored), }, database: { saveVolume: vi.fn() }, logger: { warn: vi.fn() }, emit: vi.fn(), // The real private persist helper lives on the prototype; wire it so the // test exercises the shipped persistence path end-to-end. persistVolume: (BotInstance.prototype as any).persistVolume, } as any; } it("saves the new volume via database.saveVolume (covers chat !vol AND the REST endpoint)", () => { const ctx = makeVolCtx(); const res = cmdVol.call(ctx, { args: "40" }); expect(res).toBe("Volume set to 40%"); expect(ctx.player.setVolume).toHaveBeenCalledWith(40); expect(ctx.database.saveVolume).toHaveBeenCalledWith("bot1", 40); expect(ctx.emit).toHaveBeenCalledWith("stateChange"); }); it("does not persist an out-of-range volume", () => { const ctx = makeVolCtx(); const res = cmdVol.call(ctx, { args: "999" }); expect(res).toBe("Usage: !vol <0-100>"); expect(ctx.player.setVolume).not.toHaveBeenCalled(); expect(ctx.database.saveVolume).not.toHaveBeenCalled(); }); it("swallows a database error so the volume change still succeeds", () => { const ctx = makeVolCtx(); ctx.database.saveVolume = vi.fn(() => { throw new Error("disk full"); }); const res = cmdVol.call(ctx, { args: "50" }); expect(res).toBe("Volume set to 50%"); expect(ctx.player.setVolume).toHaveBeenCalledWith(50); expect(ctx.logger.warn).toHaveBeenCalled(); }); }); describe("BotInstance.cmdMode — persistence (#125)", () => { function makeModeCtx() { let mode = "seq"; return { id: "bot1", queue: { setMode: vi.fn((m: string) => { mode = m; }), getMode: vi.fn(() => mode), }, database: { savePlayMode: vi.fn() }, logger: { warn: vi.fn() }, emit: vi.fn(), persistPlayMode: (BotInstance.prototype as any).persistPlayMode, } as any; } it("saves the new play mode via database.savePlayMode", () => { const ctx = makeModeCtx(); const res = cmdMode.call(ctx, { args: "rloop" }); expect(res).toBe("Play mode set to: rloop"); expect(ctx.queue.setMode).toHaveBeenCalledWith("rloop"); expect(ctx.database.savePlayMode).toHaveBeenCalledWith("bot1", "rloop"); expect(ctx.emit).toHaveBeenCalledWith("stateChange"); }); it("does not persist an unknown mode", () => { const ctx = makeModeCtx(); const res = cmdMode.call(ctx, { args: "bogus" }); expect(res).toBe("Usage: !mode "); expect(ctx.queue.setMode).not.toHaveBeenCalled(); expect(ctx.database.savePlayMode).not.toHaveBeenCalled(); }); }); describe("BotInstance — restores persisted player settings on construction (#125)", () => { const provider = { platform: "netease" } as unknown as MusicProvider; function makeOptions(id: string, database: BotDatabase): BotInstanceOptions { const logger: any = { info() {}, warn() {}, error() {}, debug() {}, child() { return logger; } }; return { id, name: "RestoreBot", tsOptions: { host: "localhost", port: 9987, queryPort: 10011, nickname: "RestoreBot" } as any, neteaseProvider: provider, qqProvider: provider, bilibiliProvider: provider, youtubeProvider: provider, database, config: { spotify: {} } as unknown as BotConfig, logger, avatarStore: { read: () => null } as unknown as AvatarStore, spotifyControllerFactory: () => ({ on: () => {} } as unknown as SpotifyController), }; } it("applies the saved volume + play mode from the database", () => { const db = createDatabase(":memory:"); db.saveBotInstance({ id: "bot-restore", name: "B", serverAddress: "x", serverPort: 9987, nickname: "n", defaultChannel: "", channelId: "", channelPassword: "", autoStart: false, serverProtocol: "", ts6ApiKey: "", serverPassword: "", }); db.saveVolume("bot-restore", 33); db.savePlayMode("bot-restore", "loop"); const bot = new BotInstance(makeOptions("bot-restore", db)); const status = bot.getStatus(); expect(status.volume).toBe(33); expect(status.playMode).toBe("loop"); db.close(); }); it("falls back to defaults for a bot with no saved settings", () => { const db = createDatabase(":memory:"); const bot = new BotInstance(makeOptions("brand-new", db)); const status = bot.getStatus(); expect(status.volume).toBe(75); expect(status.playMode).toBe("seq"); db.close(); }); }); describe("BotInstance.handleTextMessage — response chunking (#116)", () => { it("splits a long command response into multiple sends, each under the byte cap", async () => { const ctx = makeGateCtx({ adminGroups: [] }); const longResponse = Array.from( { length: 200 }, (_, i) => `歌词 line number ${i} with some content`, ).join("\n"); ctx.executeCommand = vi.fn(async () => longResponse); await handleTextMessage.call(ctx, makeMsg("!lyrics")); const calls = ctx.tsClient.sendTextMessage.mock.calls; expect(calls.length).toBeGreaterThan(1); for (const [chunk] of calls) { expect(Buffer.byteLength(chunk as string, "utf8")).toBeLessThanOrEqual(900); } }); it("sends a short command response as a single message", async () => { const ctx = makeGateCtx({ adminGroups: [] }); ctx.executeCommand = vi.fn(async () => "short reply"); await handleTextMessage.call(ctx, makeMsg("!lyrics")); expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledTimes(1); expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith("short reply"); }); }); const cmdLyrics = (BotInstance.prototype as any).cmdLyrics as ( this: unknown, ) => Promise; describe("BotInstance.cmdLyrics — full lyrics (#116)", () => { it("returns ALL lyric lines, not just the first 10", async () => { const lyricLines = Array.from({ length: 30 }, (_, i) => ({ time: i, text: `lyric line ${i}`, })); const ctx: any = { queue: { current: () => ({ id: "s1", name: "Song", platform: "netease" }) }, getProviderFor: () => ({ getLyrics: vi.fn(async () => lyricLines) }), }; const out = await cmdLyrics.call(ctx); for (const l of lyricLines) { expect(out).toContain(l.text); } expect(out.startsWith("Lyrics for Song:")).toBe(true); }); it("returns 'No lyrics available' when the provider has none", async () => { const ctx: any = { queue: { current: () => ({ id: "s1", name: "Song", platform: "netease" }) }, getProviderFor: () => ({ getLyrics: vi.fn(async () => []) }), }; expect(await cmdLyrics.call(ctx)).toBe("No lyrics available"); }); }); // ─── Saved queues + live-queue persistence + playKeepsQueue (#119) ───────── // All exercise the ACTUAL shipped methods via their prototype, bound to a // minimal ctx — the same lightweight pattern as the cmd* tests above. const playSingleSong = BotInstance.prototype.playSingleSong as ( this: unknown, song: unknown, requesterName?: string, ) => Promise; const loadSavedQueue = BotInstance.prototype.loadSavedQueue as ( this: unknown, songs: unknown[], mode: "replace" | "append", requesterName?: string, ) => Promise; const cmdSaveQueue = (BotInstance.prototype as any).cmdSaveQueue as (this: unknown, cmd: any) => string; const cmdLoadQueue = (BotInstance.prototype as any).cmdLoadQueue as (this: unknown, cmd: any) => Promise; const cmdListQueues = (BotInstance.prototype as any).cmdListQueues as (this: unknown) => string; const persistQueueSnapshot = (BotInstance.prototype as any).persistQueueSnapshot as (this: unknown) => void; const scheduleQueueSnapshot = (BotInstance.prototype as any).scheduleQueueSnapshot as (this: unknown) => void; const restoreQueueFromSnapshot = (BotInstance.prototype as any).restoreQueueFromSnapshot as (this: unknown) => Promise; const withRequester = (BotInstance.prototype as any).withRequester; const isSameSong = (BotInstance.prototype as any).isSameSong; const savedQueuesGuard = (BotInstance.prototype as any).savedQueuesGuard; function song119(id: string) { return { id, name: id, artist: "", album: "", platform: "netease" as const, coverUrl: "", duration: 1 }; } function makePlayer119() { let state: "idle" | "playing" | "paused" = "idle"; return { stop: vi.fn(() => { state = "idle"; }), resetFailures: vi.fn(), getState: vi.fn(() => state), _play: () => { state = "playing"; }, }; } describe("BotInstance.playSingleSong / playKeepsQueue (#119)", () => { function makeCtx(playKeepsQueue: boolean) { const queue = new PlayQueue(); return { config: { playKeepsQueue }, queue, player: makePlayer119(), withRequester, isSameSong, disableFmMode: vi.fn(), sweepLocalAudio: vi.fn(), resolveAndPlay: vi.fn(async () => true), } as any; } it("clears the queue when playKeepsQueue is false (default)", async () => { const ctx = makeCtx(false); ctx.queue.add(song119("a")); ctx.queue.play(); const ok = await playSingleSong.call(ctx, song119("b"), "alice"); expect(ok).toBe(true); expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["b"]); expect(ctx.queue.current()?.id).toBe("b"); expect(ctx.sweepLocalAudio).toHaveBeenCalled(); }); it("inserts-after-current and keeps the queue when playKeepsQueue is true", async () => { const ctx = makeCtx(true); ctx.queue.add(song119("a")); ctx.queue.add(song119("c")); ctx.queue.play(); // current = a (index 0) await playSingleSong.call(ctx, song119("b"), "alice"); expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["a", "b", "c"]); expect(ctx.queue.current()?.id).toBe("b"); expect(ctx.queue.current()?.requestedBy).toBe("alice"); // Keep-queue mode must not sweep local uploads (nothing was released). expect(ctx.sweepLocalAudio).not.toHaveBeenCalled(); }); it("falls back to clear-and-play when playKeepsQueue is true but the queue is empty", async () => { const ctx = makeCtx(true); await playSingleSong.call(ctx, song119("b"), "alice"); expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["b"]); expect(ctx.queue.current()?.id).toBe("b"); }); }); describe("BotInstance.loadSavedQueue (#119)", () => { function makeCtx() { const player = makePlayer119(); return { queue: new PlayQueue(), player, withRequester, disableFmMode: vi.fn(), sweepLocalAudio: vi.fn(), resolveAndPlay: vi.fn(async () => { player._play(); return true; }), emit: vi.fn(), } as any; } it("replace clears + plays from the first track", async () => { const ctx = makeCtx(); ctx.queue.add(song119("old")); ctx.queue.play(); await loadSavedQueue.call(ctx, [song119("a"), song119("b")], "replace", "bob"); expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["a", "b"]); expect(ctx.queue.current()?.id).toBe("a"); expect(ctx.queue.current()?.requestedBy).toBe("bob"); expect(ctx.disableFmMode).toHaveBeenCalled(); expect(ctx.resolveAndPlay).toHaveBeenCalled(); expect(ctx.emit).toHaveBeenCalledWith("stateChange"); }); it("append adds to the end and starts playing only when idle", async () => { const ctx = makeCtx(); // Idle bot with an existing (not playing) queue entry. ctx.queue.add(song119("x")); await loadSavedQueue.call(ctx, [song119("a"), song119("b")], "append"); expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["x", "a", "b"]); // wasIdle → start the first appended song (index 1). expect(ctx.queue.current()?.id).toBe("a"); expect(ctx.resolveAndPlay).toHaveBeenCalledTimes(1); }); it("append does not interrupt a playing track", async () => { const ctx = makeCtx(); ctx.player._play(); // player is 'playing' ctx.queue.add(song119("x")); ctx.queue.play(); // current = x await loadSavedQueue.call(ctx, [song119("a")], "append"); expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["x", "a"]); expect(ctx.queue.current()?.id).toBe("x"); expect(ctx.resolveAndPlay).not.toHaveBeenCalled(); }); }); describe("BotInstance chat save/load/queues (#119)", () => { function makeCtx(enabled: boolean, db = createDatabase(":memory:")) { const queue = new PlayQueue(); return { config: { savedQueuesEnabled: enabled, commandPrefix: "!" }, queue, database: db, savedQueuesGuard, loadSavedQueue: vi.fn(async () => {}), } as any; } it("replies 此功能未启用 when the feature is disabled", () => { const ctx = makeCtx(false); expect(cmdSaveQueue.call(ctx, parseCommand("!save night", "!")!)).toBe("此功能未启用"); expect(cmdListQueues.call(ctx)).toBe("此功能未启用"); }); it("refuses saving an empty queue", () => { const ctx = makeCtx(true); expect(cmdSaveQueue.call(ctx, parseCommand("!save night", "!")!)).toBe("队列为空,无法保存"); }); it("saves the current queue to the shared bucket and lists it", () => { const ctx = makeCtx(true); ctx.queue.add(song119("a")); ctx.queue.add(song119("b")); const reply = cmdSaveQueue.call(ctx, parseCommand("!save night", "!")!); expect(reply).toContain("已保存队列"); expect(ctx.database.listSavedQueues(SHARED_QUEUE_OWNER, false).map((x: any) => x.name)).toContain("night"); expect(cmdListQueues.call(ctx)).toContain("night"); }); it("loads a saved queue by name (replace by default, -a appends)", async () => { const db = createDatabase(":memory:"); const ctx = makeCtx(true, db); db.saveQueue(SHARED_QUEUE_OWNER, "night", [song119("a")]); const rep = await cmdLoadQueue.call(ctx, parseCommand("!load night", "!")!); expect(rep).toContain("已加载"); expect(ctx.loadSavedQueue).toHaveBeenCalledWith(expect.any(Array), "replace"); const repA = await cmdLoadQueue.call(ctx, parseCommand("!load -a night", "!")!); expect(repA).toContain("已追加"); expect(ctx.loadSavedQueue).toHaveBeenLastCalledWith(expect.any(Array), "append"); }); it("reports a missing saved queue", async () => { const ctx = makeCtx(true); expect(await cmdLoadQueue.call(ctx, parseCommand("!load nope", "!")!)).toContain("找不到"); }); }); describe("BotInstance live-queue persistence (#119)", () => { function makeCtx(enabled: boolean, db = createDatabase(":memory:")) { return { id: "bot1", config: { savedQueuesEnabled: enabled }, queue: new PlayQueue(), database: db, isFmMode: false, fmProvider: null, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, player: makePlayer119(), resolveAndPlay: vi.fn(async () => true), getProviderFor: vi.fn(() => ({ platform: "netease" })), } as any; } it("persists a snapshot when enabled", () => { const ctx = makeCtx(true); ctx.queue.add(song119("a")); ctx.queue.play(); persistQueueSnapshot.call(ctx); const st = ctx.database.getQueueState("bot1")!; expect(st.songs.map((s: any) => s.id)).toEqual(["a"]); expect(st.currentIndex).toBe(0); }); it("does NOT persist when the feature is disabled", () => { const ctx = makeCtx(false); ctx.queue.add(song119("a")); ctx.queue.play(); persistQueueSnapshot.call(ctx); expect(ctx.database.getQueueState("bot1")).toBeNull(); }); it("clears the persisted row when the queue is empty", () => { const db = createDatabase(":memory:"); db.saveQueueState({ botId: "bot1", songs: [song119("a")], currentIndex: 0, mode: "seq", isFmMode: false, fmPlatform: "" }); const ctx = makeCtx(true, db); persistQueueSnapshot.call(ctx); // queue is empty expect(db.getQueueState("bot1")).toBeNull(); }); it("restores and resumes the current track on restore", async () => { const db = createDatabase(":memory:"); db.saveQueueState({ botId: "bot1", songs: [song119("a"), song119("b")], currentIndex: 1, mode: "loop", isFmMode: false, fmPlatform: "" }); const ctx = makeCtx(true, db); await restoreQueueFromSnapshot.call(ctx); expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["a", "b"]); expect(ctx.queue.getCurrentIndex()).toBe(1); expect(ctx.queue.getMode()).toBe(PlayMode.Loop); expect(ctx.resolveAndPlay).toHaveBeenCalledTimes(1); }); it("restores FM mode + provider from the snapshot", async () => { const db = createDatabase(":memory:"); db.saveQueueState({ botId: "bot1", songs: [song119("a")], currentIndex: 0, mode: "random", isFmMode: true, fmPlatform: "qq" }); const ctx = makeCtx(true, db); await restoreQueueFromSnapshot.call(ctx); expect(ctx.isFmMode).toBe(true); expect(ctx.getProviderFor).toHaveBeenCalledWith("qq"); }); it("does nothing when the feature is disabled", async () => { const db = createDatabase(":memory:"); db.saveQueueState({ botId: "bot1", songs: [song119("a")], currentIndex: 0, mode: "seq", isFmMode: false, fmPlatform: "" }); const ctx = makeCtx(false, db); await restoreQueueFromSnapshot.call(ctx); expect(ctx.queue.list()).toEqual([]); expect(ctx.resolveAndPlay).not.toHaveBeenCalled(); }); it("a cancelled snapshot timer does not wipe persisted state (disconnect race)", () => { vi.useFakeTimers(); try { const db = createDatabase(":memory:"); db.saveQueueState({ botId: "bot1", songs: [song119("a")], currentIndex: 0, mode: "seq", isFmMode: false, fmPlatform: "" }); const ctx = makeCtx(true, db); ctx.queue.add(song119("a")); ctx.queue.play(); // Debounced snapshot scheduled, then a disconnect clears the queue and // cancels the pending timer — the persisted row must survive for restore. scheduleQueueSnapshot.call(ctx); ctx.queue.clear(); if (ctx.snapshotTimer) clearTimeout(ctx.snapshotTimer); vi.advanceTimersByTime(3000); expect(db.getQueueState("bot1")).not.toBeNull(); } finally { vi.useRealTimers(); } }); }); describe("BotInstance Bilibili multi-P resolution", () => { it("resolves multi-P search result to P1 with accurate duration and name", async () => { const multiPSongDetail = { id: "BV1multiP?p=1", name: "测试视频 - P1 分P1", artist: "UP主", album: "", duration: 100, // P1 duration coverUrl: "", platform: "bilibili" as const, }; const mockBili = { platform: "bilibili" as const, search: vi.fn().mockResolvedValue({ songs: [{ id: "BV1multiP", name: "测试视频", artist: "UP主", album: "", duration: 300, // total duration in search coverUrl: "", platform: "bilibili", }], albums: [], playlists: [], }), getSongDetail: vi.fn().mockResolvedValue(multiPSongDetail), getSongUrl: vi.fn().mockResolvedValue({ url: "http://audio.test" }), }; const ctx = { config: { commandPrefix: "!" }, lastSearchResults: [] as any[], getProvider: () => mockBili, getProviderFor: () => mockBili, }; const res = await (BotInstance.prototype as any).resolvePlayQuery.call(ctx, { name: "play", args: "测试视频", rawArgs: ["测试视频"], flags: new Set(), }); expect(res.song).toBeDefined(); expect(res.song.id).toBe("BV1multiP?p=1"); expect(res.song.name).toBe("测试视频 - P1 分P1"); expect(res.song.duration).toBe(100); }); }); describe("cmdPlaylist with a playlist link (#160)", () => { const cmdPlaylist = (BotInstance.prototype as any).cmdPlaylist as ( this: unknown, cmd: { name: string; args: string; rawArgs: string[]; flags: Set }, ) => Promise; function makeCtx() { const song = { id: "s1", name: "Song", artist: "A", album: "B", duration: 1, coverUrl: "" }; const makeProvider = (platform: string) => ({ platform, search: vi.fn().mockResolvedValue({ songs: [], playlists: [] }), getPlaylistSongs: vi.fn().mockResolvedValue([song]), }); const providers: Record = { netease: makeProvider("netease"), qq: makeProvider("qq"), youtube: makeProvider("youtube"), }; const queued: any[] = []; return { providers, queued, getProvider: vi.fn(() => providers.netease), getProviderFor: vi.fn((p: string) => providers[p]), assertProviderEnabled: vi.fn(), extractId: (BotInstance.prototype as any).extractId, looksLikeCollectionId: (BotInstance.prototype as any).looksLikeCollectionId, player: { stop: vi.fn() }, queue: { clear: vi.fn(), add: (s: any) => queued.push(s), play: () => queued[0] }, disableFmMode: vi.fn(), withRequester: (s: any) => s, resolveAndPlay: vi.fn(async () => true), sweepLocalAudio: vi.fn(), emit: vi.fn(), }; } const cmd = (args: string, flags: string[] = []) => ({ name: "playlist", args, rawArgs: args.split(" "), flags: new Set(flags) }); it("routes a QQ playlist link to QQ even without -q (default is NetEase)", async () => { const ctx = makeCtx(); const reply = await cmdPlaylist.call(ctx, cmd("[URL]https://y.qq.com/n/ryqq/playlist/8052190267[/URL]")); expect(ctx.providers.qq.getPlaylistSongs).toHaveBeenCalledWith("8052190267"); expect(ctx.providers.netease.getPlaylistSongs).not.toHaveBeenCalled(); expect(ctx.queued[0].platform).toBe("qq"); expect(reply).toMatch(/^Loaded 1 songs/); }); it("loads a YouTube playlist link by its list id instead of name-searching the URL", async () => { const ctx = makeCtx(); await cmdPlaylist.call(ctx, cmd("https://www.youtube.com/playlist?list=PLabc123")); expect(ctx.providers.youtube.getPlaylistSongs).toHaveBeenCalledWith("PLabc123"); expect(ctx.providers.netease.search).not.toHaveBeenCalled(); }); it("checks the link's platform is enabled", async () => { const ctx = makeCtx(); ctx.assertProviderEnabled.mockImplementation(() => { throw new Error("音源未启用:qq"); }); await expect(cmdPlaylist.call(ctx, cmd("https://y.qq.com/n/ryqq/playlist/1"))).rejects.toThrow("音源未启用"); }); it("keeps the old behavior for a bare id", async () => { const ctx = makeCtx(); await cmdPlaylist.call(ctx, cmd("2829883282")); expect(ctx.providers.netease.getPlaylistSongs).toHaveBeenCalledWith("2829883282"); }); });