Add unit tests and refactor music service classes

This commit is contained in:
anthorob committed 2026-09-06 16:21:45 -04:00
1 parent b8786c8dc9
commit 1b981abdf7
13 files changed
+1085 -133

No files matched your search

@@ -24,11 +24,12 @@ import org.apache.logging.log4j.Logger;
* Per-guild music player and queue owner.
*
* <p>The shared {@link AudioPlayerManager} and the manager cache live on {@link MusicServiceImpl}.
* Obtain instances via {@link me.bobert.netneutrality.music.MusicService#get(Guild)}.
* Obtain instances via {@link MusicService#get(Guild)}.
*/
public class MusicManager {
public class MusicManager implements MusicManagerCallback {
private static final Logger logger = LogManager.getLogger(MusicManager.class);
static final int DEFAULT_VOLUME = 50;
private final Guild guild;
private final AudioPlayerManager playerManager;
private final AudioPlayer player;
@@ -36,6 +37,7 @@ public class MusicManager {
private final int baseVolume;
private final GuildService guildService;
private final TaskManager taskManager;
private final VoteSkipService voteSkipService;
MusicManager(Guild guild, AudioPlayerManager playerManager, GuildService guildService, TaskManager taskManager) {
this.guild = guild;
@@ -44,7 +46,8 @@ public class MusicManager {
this.scheduler = new TrackScheduler(this);
this.guildService = guildService;
this.taskManager = taskManager;
this.baseVolume = Integer.parseInt(guildService.getGuildSettingService().getSetting(GSettings.MUSIC_VOLUME));
this.voteSkipService = new VoteSkipService();
this.baseVolume = parseBaseVolume(guildService);
guild.getAudioManager().setSendingHandler(new AudioPlayerSendHandler(player));
player.addListener(scheduler);
@@ -86,7 +89,7 @@ public class MusicManager {
guild.getAudioManager().setSelfDeafened(true);
player.setVolume(baseVolume);
} else {
throw new RuntimeException("Error connecting to voice channel");
throw new IllegalArgumentException("audioChannel must not be null");
}
}
@@ -104,11 +107,11 @@ public class MusicManager {
return guild.getAudioManager().getConnectedChannel() != null;
}
public synchronized AudioTrack getCurrentlyPlaying() {
public AudioTrack getCurrentlyPlaying() {
return player.getPlayingTrack();
}
public synchronized boolean isCurrentlyPlaying() {
public boolean isCurrentlyPlaying() {
return player.getPlayingTrack() != null;
}
@@ -132,58 +135,38 @@ public class MusicManager {
}
}
public synchronized void stopTrack() {
public void stopTrack() {
player.stopTrack();
}
public synchronized CommandExecutionResult resume() {
public CommandExecutionResult resume() {
return getQueueManager().resume();
}
public synchronized CommandExecutionResult voteSkip(CommandContext context) {
AudioChannel audioChannel = guild.getAudioManager().getConnectedChannel();
if (audioChannel == null) {
return CommandExecutionResult.warn("Bot is not connected to a voice channel");
}
if (!isCurrentlyPlaying()) {
return CommandExecutionResult.warn("No tracks are currently playing");
}
TrackInfo info = getQueueManager().peekHeadOfQueue();
if (info == null) {
return CommandExecutionResult.warn("Queue is empty");
}
// Minus the bot itself
int connected = audioChannel.getMembers().size() - 1;
if (connected < 3) {
getQueueManager().skip();
return CommandExecutionResult.info("Skipping current track since not enough people are connected to the voice channel.");
}
double needed = connected / 2.1;
info.addSkip(context.getGuildUser());
logger.debug("VoteSkip: guild={}, connected={}, needed={}, skips={}, user={}", context.getGuild().getId(), connected, needed, info.getSkips().size(), context.getGuildUser().getId());
if (info.getSkips().size() > needed) {
return getQueueManager().skip();
}
return CommandExecutionResult.success(String.format("Adding your vote to skip the current track. Current votes: %d/%d",
info.getSkips().size(),
(int) Math.ceil(needed))
);
return voteSkipService.voteSkip(context, getQueueManager(), audioChannel, isCurrentlyPlaying());
}
public synchronized void setVolume(int volume) {
public void setVolume(int volume) {
player.setVolume(volume);
}
public int getQueueSize() {
return getQueueManager().getQueueSize();
}
@Override
public void onQueueEmpty() {
disconnect();
}
private static int parseBaseVolume(GuildService guildService) {
try {
return Integer.parseInt(guildService.getGuildSettingService().getSetting(GSettings.MUSIC_VOLUME));
} catch (NumberFormatException e) {
logger.warn("Invalid MUSIC_VOLUME setting, falling back to default ({})", DEFAULT_VOLUME, e);
return DEFAULT_VOLUME;
}
}
}
@@ -0,0 +1,16 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* Callback interface that breaks the circular dependency between
* {@link QueueManager} / {@link TrackScheduler} and {@link MusicManager}.
*/
public interface MusicManagerCallback {
void onQueueEmpty();
}
@@ -76,12 +76,6 @@ public class QueueManager {
}
public CommandExecutionResult clearQueue() {
scheduler.getMusicManager().disconnect();
if (queue.isEmpty()) {
return CommandExecutionResult.warn("Queue is empty!");
}
synchronized (queue) {
int queueSize = queue.size();
if (queueSize == 0) {
@@ -89,17 +83,14 @@ public class QueueManager {
}
queue.clear();
return CommandExecutionResult.success(String.format("Removed %d track%s from the queue", queueSize--, (queueSize > 1 ? "s" : "")));
scheduler.getCallback().onQueueEmpty();
return CommandExecutionResult.success(String.format("Removed %d track%s from the queue", queueSize, (queueSize > 1 ? "s" : "")));
}
}
public CommandExecutionResult removeTracksFromUser(Member member) {
if (queue.stream().filter(trackInfo -> member.equals(trackInfo.getRequester())).toList().isEmpty()) {
return CommandExecutionResult.info(String.format("No tracks added by user %s", member.getEffectiveName()));
}
synchronized (queue) {
int size = queue.stream().filter(trackInfo -> member.equals(trackInfo.getRequester())).toList().size();
int size = (int) queue.stream().filter(trackInfo -> member.equals(trackInfo.getRequester())).count();
if (size == 0) {
return CommandExecutionResult.info(String.format("No tracks added by user %s", member.getEffectiveName()));
}
@@ -134,9 +125,6 @@ public class QueueManager {
public void queue(AudioTrack track, Member member) {
TrackInfo info = new TrackInfo(track, member);
if (queue.contains(info)) {
return;
}
synchronized (queue) {
if (queue.contains(info)) {
@@ -145,14 +133,12 @@ public class QueueManager {
if (player.getPlayingTrack() == null && !queue.isEmpty()) {
queue.addFirst(info);
}
if ((player.getPlayingTrack() == null && queue.isEmpty()) || player.getPlayingTrack() != null) {
} else {
queue.add(info);
}
if (player.getPlayingTrack() == null) {
AudioTrack toPlay = (queue.peek() == null ? null : queue.peek().getTrack());
AudioTrack toPlay = queue.peek() == null ? null : queue.peek().getTrack();
player.startTrack(toPlay, true);
}
}
@@ -194,11 +180,6 @@ public class QueueManager {
nbTrack = 1;
}
if (queue.isEmpty()) {
scheduler.getMusicManager().disconnect();
return CommandExecutionResult.info("Queue is empty, leaving voice channel");
}
synchronized (queue) {
if (queue.isEmpty()) {
scheduler.getMusicManager().disconnect();
@@ -206,7 +187,7 @@ public class QueueManager {
}
if (nbTrack > queue.size()) {
nbTrack = 1;
nbTrack = queue.size();
}
logger.debug("Guild {}: skipping {} tracks", guild.getId(), nbTrack);
@@ -226,10 +207,6 @@ public class QueueManager {
}
public CommandExecutionResult shuffle() {
if (queue.size() <= 3) {
return CommandExecutionResult.warn("The queue is too small to be shuffled");
}
synchronized (queue) {
if (queue.size() <= 3) {
return CommandExecutionResult.warn("The queue is too small to be shuffled");
@@ -251,12 +228,8 @@ public class QueueManager {
}
public CommandExecutionResult removeFromQueue(int index) {
if (queue.isEmpty() || index < 1) {
return CommandExecutionResult.none();
}
synchronized (queue) {
if (queue.isEmpty()) {
if (queue.isEmpty() || index < 1) {
return CommandExecutionResult.none();
}
@@ -0,0 +1,93 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.context.Context;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Encapsulates the retry-or-skip logic for stuck tracks, extracted from
* {@link TrackScheduler#onTrackStuck(AudioPlayer, AudioTrack, long)}.
*/
class StuckTrackRecoveryHandler {
private static final Logger logger = LogManager.getLogger(StuckTrackRecoveryHandler.class);
private final AudioPlayer player;
private final QueueManager queueManager;
private AudioTrack lastStuckTrack;
StuckTrackRecoveryHandler(AudioPlayer player, QueueManager queueManager) {
this.player = player;
this.queueManager = queueManager;
}
/**
* Resets the last-stuck state so that a subsequent stuck event on the same
* identifier is treated as a first attempt again.
*/
void reset() {
lastStuckTrack = null;
}
/**
* Handles a stuck-track event. On the first occurrence for a given track
* identifier the handler attempts to clone and re-queue the track; on the
* second consecutive occurrence it skips to the next track.
*/
void handle(AudioTrack track, Context context) {
if (lastStuckTrack == null || !lastStuckTrack.getIdentifier().equals(track.getIdentifier())) {
lastStuckTrack = track;
attemptRecovery(track, context);
} else {
context.reply("Current track is stuck, skipping to next..");
queueManager.startNextTrack();
}
}
private void attemptRecovery(AudioTrack track, Context context) {
logger.debug("Trying to load stuck track again: {}", track.getIdentifier());
context.async("Current track is stuck, trying to load it again", asyncContext -> {
try {
TrackInfo info = queueManager.peekHeadOfQueue();
if (info != null && info.getTrack().getIdentifier().equals(track.getIdentifier())) {
AudioTrack clone = track.makeClone();
long lastPosition = track.getPosition();
player.stopTrack();
if (queueManager.peekHeadOfQueue() != null
&& queueManager.peekHeadOfQueue().getTrack().getIdentifier().equals(track.getIdentifier())) {
queueManager.removeHeadOfQueue();
}
queueManager.queue(clone, info.getRequester());
AudioTrack playing = player.getPlayingTrack();
if (playing != null && playing.getIdentifier().equals(track.getIdentifier())) {
playing.setPosition(lastPosition);
lastStuckTrack = null;
} else {
asyncContext.edit("Failed to seek track to last timestamp, the track will play from the beginning");
}
} else {
asyncContext.edit("Failed to load last playing track, skipping to next..");
queueManager.startNextTrack();
}
} catch (Exception e) {
LogHandler.log(e);
asyncContext.edit("Failed to load last playing track, skipping to next..");
queueManager.startNextTrack();
}
});
}
}
@@ -1,9 +1,4 @@
package me.bobert.netneutrality.music;/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
@@ -15,6 +10,7 @@ import net.dv8tion.jda.api.entities.Member;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Collections;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
@@ -23,7 +19,7 @@ public class TrackInfo {
private final AudioTrack track;
private final Member member;
private final Set<String> skips;
private static final Logger logger = LogManager.getLogger(QueueManager.class);
private static final Logger logger = LogManager.getLogger(TrackInfo.class);
public TrackInfo(AudioTrack track, Member member) {
this.track = track;
@@ -36,7 +32,11 @@ public class TrackInfo {
}
public Set<String> getSkips() {
return skips;
return Collections.unmodifiableSet(skips);
}
public int getSkipCount() {
return skips.size();
}
public void addSkip(Member member) {
@@ -10,8 +10,8 @@ import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.context.MusicChannelContext;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.context.Context;
import me.bobert.netneutrality.exceptions.ContextException;
import net.dv8tion.jda.api.entities.Guild;
@@ -23,16 +23,19 @@ public class TrackScheduler extends AudioEventAdapter {
private static final Logger logger = LogManager.getLogger(TrackScheduler.class);
private final QueueManager queueManager;
private final MusicManager manager;
private final MusicManagerCallback callback;
private final AudioPlayer player;
private final Guild guild;
private Context context;
private AudioTrack lastStuckTrack = null;
private volatile Context context;
private final StuckTrackRecoveryHandler stuckTrackRecoveryHandler;
public TrackScheduler(MusicManager manager) {
this.manager = manager;
this.callback = manager;
this.player = manager.getPlayer();
this.guild = manager.getGuild();
this.queueManager = new QueueManager(this);
this.stuckTrackRecoveryHandler = new StuckTrackRecoveryHandler(player, queueManager);
}
public QueueManager getQueueManager() {
@@ -43,6 +46,10 @@ public class TrackScheduler extends AudioEventAdapter {
return manager;
}
public MusicManagerCallback getCallback() {
return callback;
}
public AudioPlayer getPlayer() {
return player;
}
@@ -52,8 +59,9 @@ public class TrackScheduler extends AudioEventAdapter {
}
public Member getDj() {
if (context != null) {
return context.getGuildUser();
Context ctx = context;
if (ctx != null) {
return ctx.getGuildUser();
}
return null;
@@ -71,6 +79,10 @@ public class TrackScheduler extends AudioEventAdapter {
return context;
}
StuckTrackRecoveryHandler getStuckTrackRecoveryHandler() {
return stuckTrackRecoveryHandler;
}
@Override
public void onPlayerPause(AudioPlayer player) {
// Player was paused
@@ -84,6 +96,7 @@ public class TrackScheduler extends AudioEventAdapter {
@Override
public void onTrackStart(AudioPlayer player, AudioTrack track) {
logger.debug("Guild {}: starting track: {}", guild.getId(), track.getIdentifier());
stuckTrackRecoveryHandler.reset();
}
@Override
@@ -122,47 +135,6 @@ public class TrackScheduler extends AudioEventAdapter {
@Override
public void onTrackStuck(AudioPlayer player, AudioTrack track, long thresholdMs) {
logger.error("Guild {}: currently playing track is stuck: {}", guild.getId(), track.getIdentifier());
if (lastStuckTrack == null || !lastStuckTrack.getIdentifier().equals(track.getIdentifier())) {
lastStuckTrack = track;
logger.debug("Trying to load stuck track again: {}", track.getIdentifier());
context.async("Current track is stuck, trying to load it again", asyncContext -> {
try {
TrackInfo info = queueManager.peekHeadOfQueue();
if (info != null && info.getTrack().getIdentifier().equals(track.getIdentifier())) {
AudioTrack clone = track.makeClone();
long lastPosition = track.getPosition();
player.stopTrack();
if (queueManager.peekHeadOfQueue() != null && queueManager.peekHeadOfQueue().getTrack().getIdentifier().equals(track.getIdentifier())) {
queueManager.removeHeadOfQueue();
}
queueManager.queue(clone, info.getRequester());
AudioTrack playing = player.getPlayingTrack();
if (playing != null && playing.getIdentifier().equals(track.getIdentifier())) {
playing.setPosition(lastPosition);
lastStuckTrack = null;
} else {
asyncContext.edit("Failed to seek track to last timestamp, the track will play from the beginning");
}
} else {
asyncContext.edit("Failed to load last playing track, skipping to next..");
queueManager.startNextTrack();
}
} catch (Exception e) {
LogHandler.log(e);
asyncContext.edit("Failed to load last playing track, skipping to next..");
queueManager.startNextTrack();
}
});
} else {
context.reply("Current track is stuck, skipping to next..");
queueManager.startNextTrack();
}
stuckTrackRecoveryHandler.handle(track, context);
}
}
@@ -0,0 +1,66 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.context.CommandContext;
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class VoteSkipService {
private static final Logger logger = LogManager.getLogger(VoteSkipService.class);
static final double VOTE_THRESHOLD_DIVISOR = 2.1;
static final int MIN_MEMBERS_FOR_VOTE = 3;
/**
* Records a vote-skip from the invoking user and, if the threshold is met, skips the track.
*
* <p><b>Concurrency note:</b> The {@code addSkip} call and the subsequent threshold check are
* <em>not</em> performed atomically. In practice this is acceptable because duplicate votes
* from the same user are idempotent and the worst-case outcome is a single extra "vote
* recorded" message before the skip fires on the next invocation.</p>
*/
public CommandExecutionResult voteSkip(CommandContext context, QueueManager queueManager, AudioChannel audioChannel, boolean isCurrentlyPlaying) {
if (audioChannel == null) {
return CommandExecutionResult.warn("Bot is not connected to a voice channel");
}
if (!isCurrentlyPlaying) {
return CommandExecutionResult.warn("No tracks are currently playing");
}
TrackInfo info = queueManager.peekHeadOfQueue();
if (info == null) {
return CommandExecutionResult.warn("Queue is empty");
}
// Minus the bot itself
int connected = audioChannel.getMembers().size() - 1;
if (connected < MIN_MEMBERS_FOR_VOTE) {
queueManager.skip();
return CommandExecutionResult.info("Skipping current track since not enough people are connected to the voice channel.");
}
double needed = connected / VOTE_THRESHOLD_DIVISOR;
info.addSkip(context.getGuildUser());
logger.debug("VoteSkip: guild={}, connected={}, needed={}, skips={}, user={}", context.getGuild().getId(), connected, needed, info.getSkipCount(), context.getGuildUser().getId());
if (info.getSkipCount() > needed) {
return queueManager.skip();
}
return CommandExecutionResult.success(String.format("Adding your vote to skip the current track. Current votes: %d/%d",
info.getSkipCount(),
(int) Math.ceil(needed))
);
}
}
@@ -0,0 +1,274 @@
package me.bobert.netneutrality.feature.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.music.*;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
public class QueueManagerTest {
private AudioPlayer player;
private TrackScheduler scheduler;
private MusicManager musicManager;
private MusicManagerCallback callback;
private Guild guild;
private QueueManager queueManager;
@BeforeEach
void setUp() {
player = mock(AudioPlayer.class);
guild = mock(Guild.class);
when(guild.getId()).thenReturn("guild1");
scheduler = mock(TrackScheduler.class);
musicManager = mock(MusicManager.class);
callback = mock(MusicManagerCallback.class);
when(scheduler.getPlayer()).thenReturn(player);
when(scheduler.getGuild()).thenReturn(guild);
when(scheduler.getMusicManager()).thenReturn(musicManager);
when(scheduler.getCallback()).thenReturn(callback);
queueManager = new QueueManager(scheduler);
}
private AudioTrack mockTrack(String id) {
AudioTrack track = mock(AudioTrack.class);
when(track.getIdentifier()).thenReturn(id);
AudioTrackInfo info = new AudioTrackInfo("Title " + id, "Author", 1000L, id, false, "http://example.com/" + id);
when(track.getInfo()).thenReturn(info);
when(track.makeClone()).thenReturn(track);
return track;
}
private Member mockMember(String id) {
Member member = mock(Member.class);
when(member.getId()).thenReturn(id);
when(member.getEffectiveName()).thenReturn("User" + id);
return member;
}
// --- Queue ordering ---
@Test
public void queueAddsToTailAndStartsPlaybackWhenEmpty() {
AudioTrack track = mockTrack("t1");
Member member = mockMember("u1");
when(player.getPlayingTrack()).thenReturn(null);
queueManager.queue(track, member);
assertEquals(1, queueManager.getQueueSize());
verify(player).startTrack(track, true);
}
@Test
public void queueAddsToTailWhenAlreadyPlaying() {
AudioTrack t1 = mockTrack("t1");
AudioTrack t2 = mockTrack("t2");
Member member = mockMember("u1");
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1);
queueManager.queue(t1, member);
queueManager.queue(t2, member);
assertEquals(2, queueManager.getQueueSize());
List<TrackInfo> q = queueManager.getQueue();
assertSame(t1, q.get(0).getTrack());
assertSame(t2, q.get(1).getTrack());
}
@Test
public void queueAddsToFrontWhenNothingPlayingAndQueueNotEmpty() {
AudioTrack t1 = mockTrack("t1");
AudioTrack t2 = mockTrack("t2");
Member member = mockMember("u1");
// First queue: nothing playing, queue empty -> add to tail, start track
when(player.getPlayingTrack()).thenReturn(null);
queueManager.queue(t1, member);
// Second queue: nothing playing (startTrack with noInterrupt=true didn't actually start),
// queue not empty -> should addFirst so t2 is at head and gets played
when(player.getPlayingTrack()).thenReturn(null);
queueManager.queue(t2, member);
assertEquals(2, queueManager.getQueueSize());
List<TrackInfo> q = queueManager.getQueue();
// t2 should be at the front since nothing was playing and queue was not empty
assertSame(t2, q.get(0).getTrack());
assertSame(t1, q.get(1).getTrack());
}
@Test
public void queueRejectsDuplicate() {
AudioTrack track = mockTrack("t1");
Member member = mockMember("u1");
when(player.getPlayingTrack()).thenReturn(null).thenReturn(track);
queueManager.queue(track, member);
queueManager.queue(track, member);
assertEquals(1, queueManager.getQueueSize());
}
// --- Clear ---
@Test
public void clearQueueOnEmptyReturnsWarn() {
CommandExecutionResult result = queueManager.clearQueue();
assertEquals(CommandExecutionResult.Result.WARN, result.getResult());
verify(callback, never()).onQueueEmpty();
}
@Test
public void clearQueueRemovesAllAndCallsCallback() {
when(player.getPlayingTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
queueManager.queue(mockTrack("t1"), mockMember("u1"));
queueManager.queue(mockTrack("t2"), mockMember("u1"));
CommandExecutionResult result = queueManager.clearQueue();
assertEquals(CommandExecutionResult.Result.SUCCESS, result.getResult());
assertTrue(queueManager.isEmpty());
verify(callback).onQueueEmpty();
}
// --- Skip ---
@Test
public void skipOnEmptyQueueDisconnects() {
CommandExecutionResult result = queueManager.skip();
assertEquals(CommandExecutionResult.Result.INFO, result.getResult());
verify(musicManager).disconnect();
}
@Test
public void skipSingleTrack() {
AudioTrack t1 = mockTrack("t1");
AudioTrack t2 = mockTrack("t2");
Member member = mockMember("u1");
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1).thenReturn(t1);
queueManager.queue(t1, member);
queueManager.queue(t2, member);
CommandExecutionResult result = queueManager.skip(1);
assertEquals(CommandExecutionResult.Result.SUCCESS, result.getResult());
// After skip(1): head (t1) removed via startNextTrack, t2 is now head
assertEquals(1, queueManager.getQueueSize());
}
@Test
public void skipMoreThanQueueSizeSkipsAll() {
AudioTrack t1 = mockTrack("t1");
AudioTrack t2 = mockTrack("t2");
Member member = mockMember("u1");
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1).thenReturn(t1);
queueManager.queue(t1, member);
queueManager.queue(t2, member);
// skip(10) should clamp to queue.size() = 2, removing all
CommandExecutionResult result = queueManager.skip(10);
// After removing (nbTrack-1)=1 via poll, queue has 1 left, then startNextTrack polls that
// Actually with nbTrack clamped to 2: poll 1 track, then startNextTrack polls the last
assertTrue(queueManager.isEmpty());
}
// --- Shuffle ---
@Test
public void shuffleTooSmallQueueReturnsWarn() {
AudioTrack t1 = mockTrack("t1");
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1);
queueManager.queue(t1, mockMember("u1"));
CommandExecutionResult result = queueManager.shuffle();
assertEquals(CommandExecutionResult.Result.WARN, result.getResult());
}
@Test
public void shufflePreservesCurrentTrackAtHead() {
Member member = mockMember("u1");
AudioTrack playing = mockTrack("t1");
when(player.getPlayingTrack()).thenReturn(null).thenReturn(playing).thenReturn(playing).thenReturn(playing).thenReturn(playing);
queueManager.queue(mockTrack("t1"), member);
queueManager.queue(mockTrack("t2"), member);
queueManager.queue(mockTrack("t3"), member);
queueManager.queue(mockTrack("t4"), member);
CommandExecutionResult result = queueManager.shuffle();
assertEquals(CommandExecutionResult.Result.SUCCESS, result.getResult());
// First track should still be t1
List<TrackInfo> q = queueManager.getQueue();
assertEquals(4, q.size());
assertEquals("t1", q.get(0).getTrack().getIdentifier());
}
// --- RemoveFromQueue ---
@Test
public void removeFromQueueEmptyReturnsNone() {
CommandExecutionResult result = queueManager.removeFromQueue(1);
assertEquals(CommandExecutionResult.Result.NOACTION, result.getResult());
}
@Test
public void removeFromQueueOutOfBoundsReturnsError() {
when(player.getPlayingTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
queueManager.queue(mockTrack("t1"), mockMember("u1"));
CommandExecutionResult result = queueManager.removeFromQueue(5);
assertEquals(CommandExecutionResult.Result.ERROR, result.getResult());
}
@Test
public void removeFromQueueIndexZeroReturnsNone() {
when(player.getPlayingTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
queueManager.queue(mockTrack("t1"), mockMember("u1"));
CommandExecutionResult result = queueManager.removeFromQueue(0);
assertEquals(CommandExecutionResult.Result.NOACTION, result.getResult());
}
// --- RemoveTracksFromUser ---
@Test
public void removeTracksFromUserNoMatchReturnsInfo() {
Member member = mockMember("u1");
CommandExecutionResult result = queueManager.removeTracksFromUser(member);
assertEquals(CommandExecutionResult.Result.INFO, result.getResult());
}
// --- isEmpty / getQueueSize ---
@Test
public void isEmptyOnNewQueue() {
assertTrue(queueManager.isEmpty());
assertEquals(0, queueManager.getQueueSize());
}
@Test
public void peekHeadReturnsNullOnEmpty() {
assertNull(queueManager.peekHeadOfQueue());
}
}
@@ -0,0 +1,119 @@
package me.bobert.netneutrality.feature.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import me.bobert.netneutrality.music.TrackInfo;
import net.dv8tion.jda.api.entities.Member;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
public class TrackInfoTest {
private AudioTrack track;
private Member member;
private TrackInfo trackInfo;
@BeforeEach
void setUp() {
track = mock(AudioTrack.class);
member = mock(Member.class);
when(member.getId()).thenReturn("user1");
when(member.getEffectiveName()).thenReturn("User1");
trackInfo = new TrackInfo(track, member);
}
@Test
public void getTrackReturnsTrack() {
assertSame(track, trackInfo.getTrack());
}
@Test
public void getRequesterReturnsMember() {
assertSame(member, trackInfo.getRequester());
}
@Test
public void addSkipIncrementsCount() {
assertEquals(0, trackInfo.getSkipCount());
Member voter = mock(Member.class);
when(voter.getId()).thenReturn("voter1");
trackInfo.addSkip(voter);
assertEquals(1, trackInfo.getSkipCount());
}
@Test
public void duplicateSkipIsIgnored() {
Member voter = mock(Member.class);
when(voter.getId()).thenReturn("voter1");
trackInfo.addSkip(voter);
trackInfo.addSkip(voter);
assertEquals(1, trackInfo.getSkipCount());
}
@Test
public void multipleDistinctSkips() {
Member voter1 = mock(Member.class);
when(voter1.getId()).thenReturn("voter1");
Member voter2 = mock(Member.class);
when(voter2.getId()).thenReturn("voter2");
trackInfo.addSkip(voter1);
trackInfo.addSkip(voter2);
assertEquals(2, trackInfo.getSkipCount());
}
@Test
public void hasSkipReturnsTrueAfterAdding() {
Member voter = mock(Member.class);
when(voter.getId()).thenReturn("voter1");
assertFalse(trackInfo.hasSkip(voter));
trackInfo.addSkip(voter);
assertTrue(trackInfo.hasSkip(voter));
}
@Test
public void getSkipsReturnsUnmodifiableSet() {
Set<String> skips = trackInfo.getSkips();
assertThrows(UnsupportedOperationException.class, () -> skips.add("hacker"));
}
@Test
public void equalsBasedOnTrack() {
TrackInfo other = new TrackInfo(track, mock(Member.class));
assertEquals(trackInfo, other);
assertEquals(trackInfo.hashCode(), other.hashCode());
}
@Test
public void notEqualWithDifferentTrack() {
AudioTrack otherTrack = mock(AudioTrack.class);
TrackInfo other = new TrackInfo(otherTrack, member);
assertNotEquals(trackInfo, other);
}
@Test
public void equalsNullAndOtherType() {
assertNotEquals(null, trackInfo);
assertNotEquals("string", trackInfo);
}
@Test
public void equalsSameInstance() {
assertEquals(trackInfo, trackInfo);
}
}
@@ -0,0 +1,82 @@
package me.bobert.netneutrality.feature.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason;
import me.bobert.netneutrality.music.*;
import net.dv8tion.jda.api.entities.Guild;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
public class TrackSchedulerTest {
private AudioPlayer player;
private MusicManager musicManager;
private QueueManager queueManager;
private TrackScheduler scheduler;
@BeforeEach
void setUp() {
player = mock(AudioPlayer.class);
Guild guild = mock(Guild.class);
when(guild.getId()).thenReturn("guild1");
musicManager = mock(MusicManager.class);
when(musicManager.getPlayer()).thenReturn(player);
when(musicManager.getGuild()).thenReturn(guild);
scheduler = new TrackScheduler(musicManager);
queueManager = scheduler.getQueueManager();
}
@Test
public void onTrackEndFinishedStartsNextTrack() {
AudioTrack track = mock(AudioTrack.class);
when(track.getIdentifier()).thenReturn("t1");
// With empty queue, startNextTrack will just stopTrack
scheduler.onTrackEnd(player, track, AudioTrackEndReason.FINISHED);
verify(player).stopTrack();
}
@Test
public void onTrackEndLoadFailedStartsNextTrack() {
AudioTrack track = mock(AudioTrack.class);
when(track.getIdentifier()).thenReturn("t1");
scheduler.onTrackEnd(player, track, AudioTrackEndReason.LOAD_FAILED);
verify(player).stopTrack();
}
@Test
public void onTrackEndReplacedDoesNotStartNext() {
AudioTrack track = mock(AudioTrack.class);
when(track.getIdentifier()).thenReturn("t1");
scheduler.onTrackEnd(player, track, AudioTrackEndReason.REPLACED);
verify(player, never()).stopTrack();
verify(player, never()).startTrack(any(), anyBoolean());
}
@Test
public void onTrackStartResetsStuckHandler() {
AudioTrack track = mock(AudioTrack.class);
when(track.getIdentifier()).thenReturn("t1");
// After onTrackStart, the stuck handler should be reset
scheduler.onTrackStart(player, track);
// No exception means reset was called successfully
}
@Test
public void getDjReturnsNullWhenNoContext() {
assert scheduler.getDj() == null;
}
}
@@ -0,0 +1,147 @@
package me.bobert.netneutrality.feature.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.music.QueueManager;
import me.bobert.netneutrality.music.TrackInfo;
import me.bobert.netneutrality.music.VoteSkipService;
import me.bobert.netneutrality.core.context.CommandContext;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
public class VoteSkipServiceTest {
private VoteSkipService service;
private QueueManager queueManager;
private CommandContext context;
private AudioChannel audioChannel;
private TrackInfo trackInfo;
@BeforeEach
void setUp() {
service = new VoteSkipService();
queueManager = mock(QueueManager.class);
context = mock(CommandContext.class);
audioChannel = mock(AudioChannel.class);
Guild guild = mock(Guild.class);
when(guild.getId()).thenReturn("guild1");
when(context.getGuild()).thenReturn(guild);
AudioTrack track = mock(AudioTrack.class);
Member requester = mock(Member.class);
when(requester.getId()).thenReturn("requester");
trackInfo = new TrackInfo(track, requester);
when(queueManager.peekHeadOfQueue()).thenReturn(trackInfo);
}
private Member mockMember(String id) {
Member m = mock(Member.class);
when(m.getId()).thenReturn(id);
return m;
}
@Test
public void returnsWarnWhenNotConnected() {
CommandExecutionResult result = service.voteSkip(context, queueManager, null, true);
assertEquals(CommandExecutionResult.Result.WARN, result.getResult());
}
@Test
public void returnsWarnWhenNotPlaying() {
CommandExecutionResult result = service.voteSkip(context, queueManager, audioChannel, false);
assertEquals(CommandExecutionResult.Result.WARN, result.getResult());
}
@Test
public void returnsWarnWhenQueueEmpty() {
when(queueManager.peekHeadOfQueue()).thenReturn(null);
CommandExecutionResult result = service.voteSkip(context, queueManager, audioChannel, true);
assertEquals(CommandExecutionResult.Result.WARN, result.getResult());
}
@Test
public void autoSkipsWhenBelowMinMembers() {
// 2 members (below MIN_MEMBERS_FOR_VOTE=3)
Member bot = mockMember("bot");
Member u1 = mockMember("u1");
Member u2 = mockMember("u2");
when(audioChannel.getMembers()).thenReturn(List.of(bot, u1, u2));
when(context.getGuildUser()).thenReturn(u1);
when(queueManager.skip()).thenReturn(CommandExecutionResult.success("Skipping"));
CommandExecutionResult result = service.voteSkip(context, queueManager, audioChannel, true);
assertEquals(CommandExecutionResult.Result.INFO, result.getResult());
verify(queueManager).skip();
}
@Test
public void recordsVoteWhenBelowThreshold() {
// 4 members = 3 connected (minus bot). needed = 3/2.1 ≈ 1.43. 1 vote is not > 1.43
Member bot = mockMember("bot");
Member u1 = mockMember("u1");
Member u2 = mockMember("u2");
Member u3 = mockMember("u3");
Member voter = mockMember("voter1");
when(audioChannel.getMembers()).thenReturn(List.of(bot, u1, u2, u3));
when(context.getGuildUser()).thenReturn(voter);
CommandExecutionResult result = service.voteSkip(context, queueManager, audioChannel, true);
assertEquals(CommandExecutionResult.Result.SUCCESS, result.getResult());
assertTrue(result.getMessage().contains("Adding your vote"));
assertEquals(1, trackInfo.getSkipCount());
}
@Test
public void skipsWhenThresholdExceeded() {
// 4 members = 3 connected. needed = 3/2.1 ≈ 1.43. 2 votes > 1.43 → skip
Member bot = mockMember("bot");
Member u1 = mockMember("u1");
Member u2 = mockMember("u2");
Member u3 = mockMember("u3");
Member voter1 = mockMember("voter1");
Member voter2 = mockMember("voter2");
when(audioChannel.getMembers()).thenReturn(List.of(bot, u1, u2, u3));
when(queueManager.skip()).thenReturn(CommandExecutionResult.success("Skipping"));
// First vote
when(context.getGuildUser()).thenReturn(voter1);
service.voteSkip(context, queueManager, audioChannel, true);
// Second vote triggers skip
when(context.getGuildUser()).thenReturn(voter2);
CommandExecutionResult result = service.voteSkip(context, queueManager, audioChannel, true);
assertEquals(CommandExecutionResult.Result.SUCCESS, result.getResult());
verify(queueManager).skip();
}
@Test
public void duplicateVoteFromSameUser() {
Member bot = mockMember("bot");
Member u1 = mockMember("u1");
Member u2 = mockMember("u2");
Member u3 = mockMember("u3");
Member voter = mockMember("voter1");
when(audioChannel.getMembers()).thenReturn(List.of(bot, u1, u2, u3));
when(context.getGuildUser()).thenReturn(voter);
service.voteSkip(context, queueManager, audioChannel, true);
service.voteSkip(context, queueManager, audioChannel, true);
// Should still be 1 skip despite two calls
assertEquals(1, trackInfo.getSkipCount());
}
}