queue, int position)
+ {
+ return position < 1 || position > queue.size();
+ }
+
+ /**
+ * Validates a queue position and sends an error message if invalid.
+ *
+ * @param handler The audio handler
+ * @param position The 1-based position to validate
+ * @param output The output adapter for error messages
+ * @return true if the position is valid, false otherwise
+ */
+ private boolean validateQueuePosition(AudioHandler handler, int position, OutputAdapter output)
+ {
+ int size = handler.getQueue().size();
+ if (position < 1 || position > size)
+ {
+ output.replyError("Position must be a valid integer between 1 and " + size + "!");
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Checks if the queue is non-empty and sends an error message if empty.
+ *
+ * @param handler The audio handler
+ * @param output The output adapter for error messages
+ * @return true if the queue is non-empty, false otherwise
+ */
+ private boolean requireNonEmptyQueue(AudioHandler handler, OutputAdapter output)
+ {
+ if (handler.getQueue().isEmpty())
+ {
+ output.replyError("There is nothing in the queue!");
+ return false;
+ }
+ return true;
+ }
+
+ // ========== Inner Classes ==========
+
+ /**
+ * Result of adding a track to the queue.
+ */
+ public static class TrackAddResult
+ {
+ public final int position;
+ public final String formattedMessage;
+ public final String trackTitle;
+
+ public TrackAddResult(int position, String formattedMessage, String trackTitle)
+ {
+ this.position = position;
+ this.formattedMessage = formattedMessage;
+ this.trackTitle = trackTitle;
+ }
+ }
+
+ /**
+ * Data class containing queue information for display.
+ */
+ public static class QueueInfo
+ {
+ public final String[] tracks;
+ public final long totalDuration;
+ public final String nowPlayingTitle;
+ public final String statusEmoji;
+ public final RepeatMode repeatMode;
+ public final QueueType queueType;
+ public final Object nowPlayingMessage;
+ public final Object noMusicMessage;
+
+ public QueueInfo(String[] tracks, long totalDuration, String nowPlayingTitle, String statusEmoji,
+ RepeatMode repeatMode, QueueType queueType, Object nowPlayingMessage, Object noMusicMessage)
+ {
+ this.tracks = tracks;
+ this.totalDuration = totalDuration;
+ this.nowPlayingTitle = nowPlayingTitle;
+ this.statusEmoji = statusEmoji;
+ this.repeatMode = repeatMode;
+ this.queueType = queueType;
+ this.nowPlayingMessage = nowPlayingMessage;
+ this.noMusicMessage = noMusicMessage;
+ }
+
+ public boolean isEmpty()
+ {
+ return tracks.length == 0;
+ }
+ }
+
+ /**
+ * Adapter interface for abstracting output operations.
+ *
+ * This interface allows services to be command-type agnostic - the same service
+ * methods work for text commands, slash commands, and button interactions.
+ * Each command type provides its own implementation.
+ *
+ * @see com.jagrosh.jmusicbot.commands.BaseOutputAdapter
+ * @see com.jagrosh.jmusicbot.commands.v1.TextOutputAdapters
+ * @see com.jagrosh.jmusicbot.commands.v2.SlashOutputAdapters
+ */
+ public interface OutputAdapter
+ {
+ void replySuccess(String content);
+ void replyError(String content);
+ void replyWarning(String content);
+ void editMessage(String content);
+ void editMessage(String content, Consumer onSuccess);
+ void editNowPlaying(AudioHandler handler);
+ void editNoMusic(AudioHandler handler);
+ void onShowHelp();
+ }
+}
diff --git a/src/main/java/com/jagrosh/jmusicbot/service/SearchService.java b/src/main/java/com/jagrosh/jmusicbot/service/SearchService.java
new file mode 100644
index 000000000..e0d04d721
--- /dev/null
+++ b/src/main/java/com/jagrosh/jmusicbot/service/SearchService.java
@@ -0,0 +1,205 @@
+/*
+ * Copyright 2026 Arif Banai (arif-banai)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.jagrosh.jmusicbot.service;
+
+import com.jagrosh.jmusicbot.Bot;
+import com.jagrosh.jmusicbot.utils.TimeUtil;
+import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
+import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
+import com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity;
+import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
+import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
+import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+/**
+ * Service for search-related operations.
+ * Uses PlayerService for shared track operations (validation, queue addition).
+ */
+public class SearchService
+{
+ private static final Logger LOG = LoggerFactory.getLogger(SearchService.class);
+
+ private final Bot bot;
+
+ public SearchService(Bot bot)
+ {
+ this.bot = bot;
+ LOG.info("SearchService initialized");
+ }
+
+ /**
+ * Performs a search and handles the results.
+ *
+ * @param guild The guild
+ * @param member The member requesting search
+ * @param query The search query
+ * @param searchPrefix The search prefix (e.g., "ytsearch:" or "scsearch:")
+ * @param channel The text channel for request metadata
+ * @param callback The callback for handling results
+ */
+ public void search(Guild guild, Member member, String query, String searchPrefix,
+ TextChannel channel, SearchCallback callback)
+ {
+ LOG.debug("Search requested: guild={}, user={}, query=\"{}\", prefix={}",
+ guild.getId(), member.getUser().getName(), query, searchPrefix);
+
+ if (query == null || query.isEmpty())
+ {
+ LOG.debug("Search rejected: empty query");
+ callback.onError("Please include a query.");
+ return;
+ }
+
+ LOG.info("Executing search: guild={}, user={}, query=\"{}\"",
+ guild.getId(), member.getUser().getName(), query);
+
+ bot.getPlayerManager().loadItemOrdered(guild, searchPrefix + query,
+ new SearchResultHandler(guild, member, query, channel, callback));
+ }
+
+ /**
+ * Formats search result choices for display.
+ *
+ * @param tracks The tracks from search results
+ * @param limit Maximum number of choices to return
+ * @return Array of formatted choice strings
+ */
+ public String[] formatSearchChoices(List tracks, int limit)
+ {
+ int count = Math.min(limit, tracks.size());
+ String[] choices = new String[count];
+ for (int i = 0; i < count; i++)
+ {
+ AudioTrack track = tracks.get(i);
+ choices[i] = "`[" + TimeUtil.formatTime(track.getDuration()) + "]` [**"
+ + track.getInfo().title + "**](" + track.getInfo().uri + ")";
+ }
+ return choices;
+ }
+
+ /**
+ * Callback interface for search results.
+ */
+ public interface SearchCallback
+ {
+ /**
+ * Called when a single track is loaded.
+ */
+ void onTrackLoaded(AudioTrack track, int queuePosition, String formattedMessage);
+
+ /**
+ * Called when search results (playlist) are loaded.
+ */
+ void onSearchResults(AudioPlaylist playlist, String[] formattedChoices);
+
+ /**
+ * Called when no matches are found.
+ */
+ void onNoMatches(String query);
+
+ /**
+ * Called when loading fails.
+ */
+ void onLoadFailed(String errorMessage);
+
+ /**
+ * Called for general errors.
+ */
+ void onError(String message);
+ }
+
+ private class SearchResultHandler implements AudioLoadResultHandler
+ {
+ private final Guild guild;
+ private final Member member;
+ private final String query;
+ private final TextChannel channel;
+ private final SearchCallback callback;
+
+ private SearchResultHandler(Guild guild, Member member, String query,
+ TextChannel channel, SearchCallback callback)
+ {
+ this.guild = guild;
+ this.member = member;
+ this.query = query;
+ this.channel = channel;
+ this.callback = callback;
+ }
+
+ @Override
+ public void trackLoaded(AudioTrack track)
+ {
+ LOG.debug("Search result - single track: guild={}, track=\"{}\"",
+ guild.getId(), track.getInfo().title);
+
+ MusicService musicService = bot.getMusicService();
+
+ // Use shared track operations from MusicService
+ MusicService.TrackAddResult result = musicService.addTrackToQueue(guild, member, track, query, channel);
+ if (result == null)
+ {
+ LOG.warn("Search track rejected (too long): guild={}, track=\"{}\"",
+ guild.getId(), track.getInfo().title);
+ callback.onLoadFailed(musicService.formatTooLongError(track));
+ return;
+ }
+
+ LOG.info("Search result added to queue: guild={}, track=\"{}\", position={}",
+ guild.getId(), track.getInfo().title, result.position);
+ callback.onTrackLoaded(track, result.position, result.formattedMessage);
+ }
+
+ @Override
+ public void playlistLoaded(AudioPlaylist playlist)
+ {
+ LOG.debug("Search results loaded: guild={}, query=\"{}\", results={}",
+ guild.getId(), query, playlist.getTracks().size());
+
+ String[] choices = formatSearchChoices(playlist.getTracks(), 4);
+ callback.onSearchResults(playlist, choices);
+ }
+
+ @Override
+ public void noMatches()
+ {
+ LOG.debug("Search - no matches: guild={}, query=\"{}\"", guild.getId(), query);
+ callback.onNoMatches(query);
+ }
+
+ @Override
+ public void loadFailed(FriendlyException throwable)
+ {
+ if (throwable.severity == Severity.COMMON)
+ {
+ LOG.warn("Search load failed (common): guild={}, query=\"{}\", error={}",
+ guild.getId(), query, throwable.getMessage());
+ callback.onLoadFailed("Error loading: " + throwable.getMessage());
+ }
+ else
+ {
+ LOG.error("Search load failed (severe): guild={}, query=\"{}\"",
+ guild.getId(), query, throwable);
+ callback.onLoadFailed("Error loading track.");
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/FormatUtil.java b/src/main/java/com/jagrosh/jmusicbot/utils/FormatUtil.java
index 5e4f18e92..4d2652e38 100644
--- a/src/main/java/com/jagrosh/jmusicbot/utils/FormatUtil.java
+++ b/src/main/java/com/jagrosh/jmusicbot/utils/FormatUtil.java
@@ -16,6 +16,8 @@
package com.jagrosh.jmusicbot.utils;
import com.jagrosh.jmusicbot.audio.RequestMetadata.UserInfo;
+import com.sedmelluq.discord.lavaplayer.source.local.LocalAudioTrack;
+import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
@@ -50,17 +52,6 @@ public static String formatUsername(User user)
{
return formatUsername(user.getName(), user.getDiscriminator());
}
-
- public static String progressBar(double percent)
- {
- String str = "";
- for(int i=0; i<12; i++)
- if(i == (int)(percent*12))
- str+="\uD83D\uDD18"; // 🔘
- else
- str+="▬";
- return str;
- }
public static String volumeIcon(int volume)
{
@@ -110,4 +101,20 @@ public static String filter(String input)
.replace("@here", "@h\u0435re") // cyrillic letter e
.trim();
}
+
+ public static String getTrackTitle(AudioTrack track) {
+ String title = track.getInfo().title;
+ if (track instanceof LocalAudioTrack && (title == null || title.equals("Unknown title"))) {
+ String identifier = track.getIdentifier();
+ int lastSeparator = Math.max(identifier.lastIndexOf('/'), identifier.lastIndexOf('\\'));
+ return (lastSeparator != -1) ? identifier.substring(lastSeparator + 1) : identifier;
+ }
+
+ // Truncate if the title is too long for Discord displays
+ if (title != null && title.length() > 100) {
+ title = title.substring(0, 97) + "...";
+ }
+
+ return title;
+ }
}
diff --git a/src/main/java/com/jagrosh/jmusicbot/utils/MessageFormatter.java b/src/main/java/com/jagrosh/jmusicbot/utils/MessageFormatter.java
index 00c5530f4..405734da5 100644
--- a/src/main/java/com/jagrosh/jmusicbot/utils/MessageFormatter.java
+++ b/src/main/java/com/jagrosh/jmusicbot/utils/MessageFormatter.java
@@ -4,9 +4,13 @@
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.audio.NowPlayingInfo;
import com.jagrosh.jmusicbot.audio.RequestMetadata;
-import com.sedmelluq.discord.lavaplayer.source.youtube.YoutubeAudioTrack;
+import com.jagrosh.jmusicbot.settings.RepeatMode;
+import com.sedmelluq.discord.lavaplayer.source.local.LocalAudioTrack;
import net.dv8tion.jda.api.EmbedBuilder;
+import net.dv8tion.jda.api.components.actionrow.ActionRow;
+import net.dv8tion.jda.api.components.buttons.Button;
import net.dv8tion.jda.api.entities.User;
+import net.dv8tion.jda.api.entities.emoji.Emoji;
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
@@ -17,42 +21,81 @@ public static MessageCreateData buildNowPlayingMessage(Bot bot, NowPlayingInfo i
return buildNoMusicPlayingMessage(bot, info);
MessageCreateBuilder mb = new MessageCreateBuilder();
- mb.addContent(FormatUtil.filter(bot.getConfig().getSuccess() + " **Now Playing in " + info.guild.getSelfMember().getVoiceState().getChannel().getAsMention() + "...**"));
EmbedBuilder eb = new EmbedBuilder();
eb.setColor(info.guild.getSelfMember().getColors().getPrimary());
+ eb.setAuthor(info.guild.getName(), null, info.guild.getIconUrl());
+
+ // Handle local file names using the util method
+ String title = FormatUtil.getTrackTitle(info.track);
+
+ try {
+ eb.setTitle(title, info.track.getInfo().uri);
+ } catch (Exception e) {
+ eb.setTitle(title);
+ }
+
+ if (info.track.getInfo().author != null && (!info.track.getInfo().author.isEmpty() && !info.track.getInfo().author.equalsIgnoreCase( "unknown artist") )) {
+ eb.addField("Author", info.track.getInfo().author, false);
+ }
+
+ StringBuilder description = new StringBuilder();
+ description.append("**Playing from:** ").append(info.track.getSourceManager().getSourceName());
+ eb.setDescription(description.toString());
+
+ eb.addField("Duration", TimeUtil.formatTime(info.duration), true);
+ eb.addField("Queue", String.valueOf(info.queueSize), true);
+ eb.addField("Volume", info.volume + "%", true);
+
+ RepeatMode repeatMode = bot.getSettingsManager().getSettings(info.guild).getRepeatMode();
+ if (repeatMode != RepeatMode.OFF) {
+ eb.addField("Repeat", repeatMode.getEmoji() + " " + repeatMode.getUserFriendlyName(), true);
+ }
RequestMetadata rm = info.track.getUserData(RequestMetadata.class);
if (rm != null && rm.getOwner() != 0L) {
User u = info.guild.getJDA().getUserById(rm.user.id);
- if (u == null)
- eb.setAuthor(FormatUtil.formatUsername(rm.user), null, rm.user.avatar);
- else
- eb.setAuthor(FormatUtil.formatUsername(u), null, u.getEffectiveAvatarUrl());
+ String requester = (u == null) ? FormatUtil.formatUsername(rm.user) : u.getAsMention();
+ eb.addField("Requester", requester, false);
}
- try {
- eb.setTitle(info.track.getInfo().title, info.track.getInfo().uri);
- } catch (Exception e) {
- eb.setTitle(info.track.getInfo().title);
+ if (!(info.track instanceof LocalAudioTrack) && bot.getConfig().useNPImages()) {
+ var thumbnailUrl = info.track.getInfo().artworkUrl;
+ if (thumbnailUrl == null || thumbnailUrl.isEmpty())
+ thumbnailUrl = "https://img.youtube.com/vi/" + info.track.getIdentifier() + "/mqdefault.jpg";
+ eb.setThumbnail(thumbnailUrl);
}
- if (info.track instanceof YoutubeAudioTrack && bot.getConfig().useNPImages()) {
- eb.setThumbnail("https://img.youtube.com/vi/" + info.track.getIdentifier() + "/mqdefault.jpg");
- }
+ if (info.footerInfo != null && !info.footerInfo.isEmpty())
+ eb.setFooter(info.footerInfo);
- if (info.track.getInfo().author != null && !info.track.getInfo().author.isEmpty())
- eb.setFooter("Source: " + info.track.getInfo().author, null);
+ mb.setEmbeds(eb.build());
- double progress = (double) info.position / info.duration;
- String statusEmoji = info.isPaused ? AudioHandler.PAUSE_EMOJI : AudioHandler.PLAY_EMOJI;
+ // Add interactive buttons using ActionRow.of for better compatibility
+ Button repeatButton = switch (repeatMode) {
+ case ALL -> Button.primary("repeat", Emoji.fromUnicode("\uD83D\uDD01")); // 🔁
+ case SINGLE -> Button.primary("repeat", Emoji.fromUnicode("\uD83D\uDD02")); // 🔂
+ default -> Button.secondary("repeat", Emoji.fromUnicode("\uD83D\uDD01")); // 🔁
+ };
- eb.setDescription(statusEmoji
- + " " + FormatUtil.progressBar(progress)
- + " `[" + TimeUtil.formatTime(info.position) + "/" + TimeUtil.formatTime(info.duration) + "]` "
- + FormatUtil.volumeIcon(info.volume));
+ mb.setComponents(
+ ActionRow.of(
+ Button.secondary("previous", Emoji.fromUnicode("\u23EE")), // Previous ⏮
+ info.isPaused
+ ? Button.primary("pause", Emoji.fromUnicode("\u25B6")) // Pause ⏸
+ : Button.secondary("pause", Emoji.fromUnicode("\u23F8")), // or Resume ▶
+ Button.secondary("skip", Emoji.fromUnicode("\u23ED")), // Skip ⏭
+ Button.secondary("stop", Emoji.fromUnicode("\u23F9")) // Stop ⏹
+ ),
+ ActionRow.of(
+ Button.secondary("shuffle", Emoji.fromUnicode("\uD83D\uDD00")), // Shuffle 🔀
+ repeatButton, // Repeat cycle
+ Button.secondary("voldown", Emoji.fromUnicode("\uD83D\uDD09")), // Vol Down 🔉
+ Button.secondary("volup", Emoji.fromUnicode("\uD83D\uDD0A")) // Vol Up 🔊
+ )
+ );
- return mb.setEmbeds(eb.build()).build();
+ return mb.build();
}
public static MessageCreateData buildNoMusicPlayingMessage(Bot bot, NowPlayingInfo info) {
@@ -60,7 +103,7 @@ public static MessageCreateData buildNoMusicPlayingMessage(Bot bot, NowPlayingIn
.setContent(FormatUtil.filter(bot.getConfig().getSuccess() + " **Now Playing...**"))
.setEmbeds(new EmbedBuilder()
.setTitle("No music playing")
- .setDescription(AudioHandler.STOP_EMOJI + " " + FormatUtil.progressBar(-1) + " " + FormatUtil.volumeIcon(info.volume))
+ .setDescription(AudioHandler.STOP_EMOJI + " " + FormatUtil.volumeIcon(info.volume))
.setColor(info.guild.getSelfMember().getColors().getPrimary())
.build()).build();
}
diff --git a/src/main/resources/reference.conf b/src/main/resources/reference.conf
index c99e1e667..1c50e7423 100644
--- a/src/main/resources/reference.conf
+++ b/src/main/resources/reference.conf
@@ -35,6 +35,10 @@ playback {
# Example: 10 pages = up to 1000 tracks.
maxYouTubePlaylistPages = 10
+ # Maximum number of tracks to keep in playback history.
+ # Used for the previous/rewind functionality.
+ maxHistorySize = 10
+
# Ratio of users that must vote to skip the currently playing song.
# Some guilds may override this, this is the default.
skipRatio = 0.55
diff --git a/src/test/java/com/jagrosh/jmusicbot/TestBase.java b/src/test/java/com/jagrosh/jmusicbot/TestBase.java
new file mode 100644
index 000000000..8d717bcf4
--- /dev/null
+++ b/src/test/java/com/jagrosh/jmusicbot/TestBase.java
@@ -0,0 +1,94 @@
+package com.jagrosh.jmusicbot;
+
+import com.jagrosh.jmusicbot.audio.AudioHandler;
+import com.jagrosh.jmusicbot.audio.PlayerManager;
+import com.jagrosh.jmusicbot.settings.Settings;
+import com.jagrosh.jmusicbot.settings.SettingsManager;
+import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
+import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
+import net.dv8tion.jda.api.JDA;
+import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.User;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
+import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion;
+import net.dv8tion.jda.api.managers.AudioManager;
+import org.junit.jupiter.api.BeforeEach;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.concurrent.ScheduledExecutorService;
+
+import static org.mockito.Mockito.when;
+
+public abstract class TestBase {
+
+ @Mock
+ protected Bot bot;
+ @Mock
+ protected BotConfig config;
+ @Mock
+ protected PlayerManager playerManager;
+ @Mock
+ protected SettingsManager settingsManager;
+ @Mock
+ protected Settings settings;
+ @Mock
+ protected Guild guild;
+ @Mock
+ protected Member member;
+ @Mock
+ protected User user;
+ @Mock
+ protected TextChannel textChannel;
+ @Mock
+ protected AudioManager audioManager;
+ @Mock
+ protected AudioHandler audioHandler;
+ @Mock
+ protected AudioPlayer audioPlayer;
+ @Mock
+ protected AudioTrack audioTrack;
+ @Mock
+ protected JDA jda;
+ @Mock
+ protected AudioChannelUnion audioChannel;
+ @Mock
+ protected ScheduledExecutorService threadpool;
+
+ protected final long GUILD_ID = 123456789L;
+ protected final long OWNER_ID = 123L;
+
+ @BeforeEach
+ public void setUp() {
+ MockitoAnnotations.openMocks(this);
+
+ // Basic Bot relationships
+ when(bot.getConfig()).thenReturn(config);
+ when(bot.getPlayerManager()).thenReturn(playerManager);
+ when(bot.getSettingsManager()).thenReturn(settingsManager);
+ when(bot.getThreadpool()).thenReturn(threadpool);
+ when(bot.getJDA()).thenReturn(jda);
+
+ // PlayerManager relationships
+ when(playerManager.getBot()).thenReturn(bot);
+
+ // Guild and Audio relationships
+ when(guild.getIdLong()).thenReturn(GUILD_ID);
+ when(guild.getAudioManager()).thenReturn(audioManager);
+ when(audioManager.getSendingHandler()).thenReturn(audioHandler);
+ when(audioHandler.getPlayer()).thenReturn(audioPlayer);
+
+ // Member and User relationships
+ when(member.getUser()).thenReturn(user);
+ when(member.getGuild()).thenReturn(guild);
+ when(user.getId()).thenReturn(String.valueOf(OWNER_ID));
+ when(user.getIdLong()).thenReturn(OWNER_ID);
+
+ // Config defaults
+ when(config.getOwnerId()).thenReturn(OWNER_ID);
+
+ // Settings defaults
+ when(settingsManager.getSettings(GUILD_ID)).thenReturn(settings);
+ }
+}
diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceOAuthTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceOAuthTest.java
index 2902be3f1..4dcc1bc2f 100644
--- a/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceOAuthTest.java
+++ b/src/test/java/com/jagrosh/jmusicbot/unit/AudioSourceOAuthTest.java
@@ -16,6 +16,7 @@
package com.jagrosh.jmusicbot.unit;
import dev.lavalink.youtube.YoutubeAudioSourceManager;
+import dev.lavalink.youtube.clients.skeleton.Client;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@@ -217,21 +218,27 @@ void youtubeClientsConfiguredForOAuthMode() throws Exception {
// With OAuth enabled
Object[] oauthClients = (Object[]) buildClients.invoke(null, true);
assertNotNull(oauthClients, "Should return clients for OAuth mode");
- assertEquals(5, oauthClients.length, "OAuth mode should use 5 clients (3 metadata-only: AndroidVr, MWeb, Web; 2 OAuth: TvHtml5Embedded, Tv)");
+ assertEquals(5, oauthClients.length, "OAuth mode should use 5 clients (AndroidVr, MWeb, Web, TvHtml5Embedded, Tv)");
- // Verify client types - first 3 are metadata-only (playback disabled)
+ // Verify client types
assertEquals("AndroidVr", oauthClients[0].getClass().getSimpleName(),
- "First OAuth client should be AndroidVr (metadata-only)");
+ "First OAuth client should be AndroidVr");
assertEquals("MWeb", oauthClients[1].getClass().getSimpleName(),
- "Second OAuth client should be MWeb (metadata-only)");
+ "Second OAuth client should be MWeb");
assertEquals("Web", oauthClients[2].getClass().getSimpleName(),
- "Third OAuth client should be Web (metadata-only)");
-
- // Last 2 are OAuth clients for streaming
+ "Third OAuth client should be Web");
assertEquals("TvHtml5Embedded", oauthClients[3].getClass().getSimpleName(),
- "Fourth OAuth client should be TvHtml5Embedded (OAuth streaming)");
+ "Fourth OAuth client should be TvHtml5Embedded");
assertEquals("Tv", oauthClients[4].getClass().getSimpleName(),
- "Fifth OAuth client should be Tv (OAuth streaming)");
+ "Fifth OAuth client should be Tv");
+
+ // Verify first 3 clients have playback disabled (metadataOnly)
+ for (int i = 0; i < 3; i++) {
+ Client client = (Client) oauthClients[i];
+ assertFalse(client.getOptions().getPlayback(),
+ String.format("OAuth client %d (%s) should have playback disabled",
+ i, client.getClass().getSimpleName()));
+ }
}
@Test
diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/audio/AloneInVoiceHandlerTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/audio/AloneInVoiceHandlerTest.java
new file mode 100644
index 000000000..ab32758ed
--- /dev/null
+++ b/src/test/java/com/jagrosh/jmusicbot/unit/audio/AloneInVoiceHandlerTest.java
@@ -0,0 +1,91 @@
+package com.jagrosh.jmusicbot.unit.audio;
+
+import com.jagrosh.jmusicbot.TestBase;
+import com.jagrosh.jmusicbot.audio.AloneInVoiceHandler;
+import net.dv8tion.jda.api.entities.GuildVoiceState;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.User;
+import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+
+import java.util.Collections;
+
+import static org.mockito.Mockito.*;
+
+public class AloneInVoiceHandlerTest extends TestBase {
+
+ @Mock
+ private GuildVoiceUpdateEvent voiceUpdateEvent;
+ @Mock
+ private GuildVoiceState voiceState;
+
+ private AloneInVoiceHandler aloneInVoiceHandler;
+
+ @BeforeEach
+ @Override
+ public void setUp() {
+ super.setUp();
+ when(voiceUpdateEvent.getEntity()).thenReturn(member);
+
+ aloneInVoiceHandler = new AloneInVoiceHandler(bot);
+ }
+
+ @Test
+ public void testOnVoiceUpdateWhenDisabled() {
+ when(config.getAloneTimeUntilStop()).thenReturn(0L);
+ aloneInVoiceHandler.init();
+
+ aloneInVoiceHandler.onVoiceUpdate(voiceUpdateEvent);
+
+ verify(playerManager, never()).hasHandler(any());
+ }
+
+ @Test
+ public void testOnVoiceUpdateWhenAlone() {
+ when(config.getAloneTimeUntilStop()).thenReturn(300L);
+ aloneInVoiceHandler.init();
+ when(playerManager.hasHandler(guild)).thenReturn(true);
+ when(audioManager.getConnectedChannel()).thenReturn(audioChannel);
+ when(audioChannel.getMembers()).thenReturn(Collections.singletonList(member));
+ when(member.getUser()).thenReturn(user);
+ when(member.getVoiceState()).thenReturn(voiceState);
+ when(user.isBot()).thenReturn(true); // Only bot is in channel
+
+ aloneInVoiceHandler.onVoiceUpdate(voiceUpdateEvent);
+ }
+
+ @Test
+ public void testIsAlone() throws Exception {
+ // Since isAlone is private, we test it through onVoiceUpdate or use reflection if needed.
+ // But we can test the behavior of onVoiceUpdate which uses isAlone.
+
+ when(config.getAloneTimeUntilStop()).thenReturn(300L);
+ aloneInVoiceHandler.init();
+ when(playerManager.hasHandler(guild)).thenReturn(true);
+
+ // Not alone (human in channel)
+ when(audioManager.getConnectedChannel()).thenReturn(audioChannel);
+ Member human = mock(Member.class);
+ User humanUser = mock(User.class);
+ GuildVoiceState humanVoiceState = mock(GuildVoiceState.class);
+ when(human.getUser()).thenReturn(humanUser);
+ when(human.getVoiceState()).thenReturn(humanVoiceState);
+ when(humanUser.isBot()).thenReturn(false);
+ when(humanVoiceState.isDeafened()).thenReturn(false);
+ when(audioChannel.getMembers()).thenReturn(Collections.singletonList(human));
+
+ aloneInVoiceHandler.onVoiceUpdate(voiceUpdateEvent);
+ // Should not be in aloneSince
+
+ // Alone (only bot)
+ when(user.isBot()).thenReturn(true);
+ when(member.getVoiceState()).thenReturn(voiceState);
+ when(audioChannel.getMembers()).thenReturn(Collections.singletonList(member));
+ when(member.getUser()).thenReturn(user);
+
+ aloneInVoiceHandler.onVoiceUpdate(voiceUpdateEvent);
+ // Should be in aloneSince
+ }
+}
diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/audio/AudioHandlerTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/audio/AudioHandlerTest.java
new file mode 100644
index 000000000..d2d166461
--- /dev/null
+++ b/src/test/java/com/jagrosh/jmusicbot/unit/audio/AudioHandlerTest.java
@@ -0,0 +1,100 @@
+package com.jagrosh.jmusicbot.unit.audio;
+
+import com.jagrosh.jmusicbot.TestBase;
+import com.jagrosh.jmusicbot.audio.AudioHandler;
+import com.jagrosh.jmusicbot.audio.QueuedTrack;
+import com.jagrosh.jmusicbot.settings.QueueType;
+import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
+import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
+import net.dv8tion.jda.api.entities.SelfMember;
+import net.dv8tion.jda.api.entities.GuildVoiceState;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+public class AudioHandlerTest extends TestBase {
+
+ @Mock
+ private SelfMember selfMember;
+ @Mock
+ private GuildVoiceState voiceState;
+
+ private AudioHandler audioHandler;
+
+ @BeforeEach
+ @Override
+ public void setUp() {
+ super.setUp();
+ when(settings.getQueueType()).thenReturn(QueueType.FAIR);
+
+ // AudioHandler's constructor is not visible, so use reflection to instantiate it for testing
+ try {
+ var constructor = AudioHandler.class.getDeclaredConstructor(
+ playerManager.getClass().getInterfaces().length > 0 ? playerManager.getClass().getInterfaces()[0] : playerManager.getClass(),
+ guild.getClass().getInterfaces().length > 0 ? guild.getClass().getInterfaces()[0] : guild.getClass(),
+ audioPlayer.getClass().getInterfaces().length > 0 ? audioPlayer.getClass().getInterfaces()[0] : audioPlayer.getClass()
+ );
+ constructor.setAccessible(true);
+ audioHandler = (AudioHandler) constructor.newInstance(playerManager, guild, audioPlayer);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to instantiate AudioHandler via reflection", e);
+ }
+ }
+
+ @Test
+ public void testAddTrackWhenNothingPlaying() {
+ QueuedTrack qtrack = mock(QueuedTrack.class);
+ AudioTrack track = mock(AudioTrack.class);
+ when(qtrack.getTrack()).thenReturn(track);
+ when(audioPlayer.getPlayingTrack()).thenReturn(null);
+
+ int result = audioHandler.addTrack(qtrack);
+
+ assertEquals(-1, result);
+ verify(audioPlayer).playTrack(track);
+ }
+
+ @Test
+ public void testAddTrackWhenSomethingPlaying() {
+ QueuedTrack qtrack = mock(QueuedTrack.class);
+ AudioTrack track = mock(AudioTrack.class);
+ AudioTrackInfo info = new AudioTrackInfo("Title", "Author", 1000, "identifier", true, "uri");
+ when(track.getInfo()).thenReturn(info);
+ when(qtrack.getTrack()).thenReturn(track);
+ when(audioPlayer.getPlayingTrack()).thenReturn(mock(AudioTrack.class));
+
+ int result = audioHandler.addTrack(qtrack);
+
+ assertTrue(result >= 0);
+ assertEquals(1, audioHandler.getQueue().size());
+ }
+
+ @Test
+ public void testStopAndClear() {
+ audioHandler.stopAndClear();
+
+ verify(audioPlayer).stopTrack();
+ assertTrue(audioHandler.getQueue().isEmpty());
+ }
+
+ @Test
+ public void testIsMusicPlaying() {
+ when(jda.getGuildById(anyLong())).thenReturn(guild);
+ when(guild.getSelfMember()).thenReturn(selfMember);
+ when(selfMember.getVoiceState()).thenReturn(voiceState);
+ when(voiceState.getChannel()).thenReturn(audioChannel);
+ when(audioPlayer.getPlayingTrack()).thenReturn(audioTrack);
+
+ assertTrue(audioHandler.isMusicPlaying(jda));
+
+ when(voiceState.getChannel()).thenReturn(null);
+ assertFalse(audioHandler.isMusicPlaying(jda));
+
+ when(voiceState.getChannel()).thenReturn(audioChannel);
+ when(audioPlayer.getPlayingTrack()).thenReturn(null);
+ assertFalse(audioHandler.isMusicPlaying(jda));
+ }
+}
diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/commands/SlashCommandRegistryTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/commands/SlashCommandRegistryTest.java
new file mode 100644
index 000000000..4394b150f
--- /dev/null
+++ b/src/test/java/com/jagrosh/jmusicbot/unit/commands/SlashCommandRegistryTest.java
@@ -0,0 +1,326 @@
+package com.jagrosh.jmusicbot.unit.commands;
+
+import com.jagrosh.jdautilities.command.CommandClient;
+import com.jagrosh.jdautilities.command.SlashCommand;
+import com.jagrosh.jmusicbot.commands.SlashCommandRegistry;
+import com.jagrosh.jmusicbot.utils.OtherUtil;
+import net.dv8tion.jda.api.JDA;
+import net.dv8tion.jda.api.interactions.commands.Command;
+import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData;
+import net.dv8tion.jda.api.requests.restaction.CommandListUpdateAction;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.MockitoAnnotations;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Consumer;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * Tests for {@link SlashCommandRegistry} to verify:
+ * 1. First run (no hash file) => registers commands and creates hash file
+ * 2. Hash unchanged => skips registration
+ * 3. Hash changed => re-registers and updates hash file
+ */
+@SuppressWarnings("unchecked") // ArgumentCaptor with generics requires unchecked casts
+public class SlashCommandRegistryTest {
+
+ private static final String HASH_FILE_NAME = ".slashcommands.hash";
+
+ @TempDir
+ Path tempDir;
+
+ @Mock
+ private JDA jda;
+
+ @Mock
+ private CommandClient commandClient;
+
+ @Mock
+ private CommandListUpdateAction commandListUpdateAction;
+
+ @Mock
+ private SlashCommand slashCommand;
+
+ @Mock
+ private SlashCommandData slashCommandData;
+
+ private MockedStatic otherUtilMock;
+ private AutoCloseable mocks;
+
+ @BeforeEach
+ void setUp() {
+ mocks = MockitoAnnotations.openMocks(this);
+
+ // Mock OtherUtil.getPath to redirect to temp directory
+ otherUtilMock = mockStatic(OtherUtil.class);
+ otherUtilMock.when(() -> OtherUtil.getPath(HASH_FILE_NAME))
+ .thenReturn(tempDir.resolve(HASH_FILE_NAME));
+
+ // Setup JDA mock chain
+ when(jda.updateCommands()).thenReturn(commandListUpdateAction);
+ when(commandListUpdateAction.addCommands(anyCollection())).thenReturn(commandListUpdateAction);
+
+ // Setup SlashCommand mock
+ when(slashCommand.buildCommandData()).thenReturn(slashCommandData);
+ when(slashCommandData.getName()).thenReturn("testcommand");
+ when(slashCommandData.getDescription()).thenReturn("A test command");
+ when(slashCommandData.getOptions()).thenReturn(Collections.emptyList());
+ when(slashCommandData.getSubcommands()).thenReturn(Collections.emptyList());
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (otherUtilMock != null) {
+ otherUtilMock.close();
+ }
+ if (mocks != null) {
+ mocks.close();
+ }
+ }
+
+ @Test
+ void testFirstRun_NoHashFile_RegistersCommandsAndCreatesHashFile() {
+ // Given: No hash file exists and we have commands to register
+ when(commandClient.getSlashCommands()).thenReturn(List.of(slashCommand));
+
+ // Capture the success callback
+ ArgumentCaptor>> successCaptor = ArgumentCaptor.forClass(Consumer.class);
+ ArgumentCaptor> errorCaptor = ArgumentCaptor.forClass(Consumer.class);
+ doNothing().when(commandListUpdateAction).queue(successCaptor.capture(), errorCaptor.capture());
+
+ // When: Register commands
+ SlashCommandRegistry.registerIfChanged(jda, commandClient);
+
+ // Then: JDA.updateCommands() should be called
+ verify(jda).updateCommands();
+ verify(commandListUpdateAction).addCommands(anyCollection());
+ verify(commandListUpdateAction).queue(any(), any());
+
+ // Simulate successful registration callback
+ List registeredCommands = Collections.emptyList(); // Mock result
+ successCaptor.getValue().accept(registeredCommands);
+
+ // Verify hash file was created
+ Path hashFile = tempDir.resolve(HASH_FILE_NAME);
+ assertTrue(Files.exists(hashFile), "Hash file should be created after registration");
+ }
+
+ @Test
+ void testHashUnchanged_SkipsRegistration() throws IOException {
+ // Given: Hash file exists with current command hash
+ when(commandClient.getSlashCommands()).thenReturn(List.of(slashCommand));
+
+ // First, register to create the hash file
+ ArgumentCaptor>> successCaptor = ArgumentCaptor.forClass(Consumer.class);
+ doNothing().when(commandListUpdateAction).queue(successCaptor.capture(), any());
+
+ SlashCommandRegistry.registerIfChanged(jda, commandClient);
+ successCaptor.getValue().accept(Collections.emptyList()); // Trigger hash save
+
+ // Verify hash file exists
+ Path hashFile = tempDir.resolve(HASH_FILE_NAME);
+ assertTrue(Files.exists(hashFile), "Hash file should exist");
+ String savedHash = Files.readString(hashFile, StandardCharsets.UTF_8).trim();
+ assertFalse(savedHash.isEmpty(), "Hash should not be empty");
+
+ // Reset mocks for second call
+ reset(jda, commandListUpdateAction);
+ when(jda.updateCommands()).thenReturn(commandListUpdateAction);
+ when(commandListUpdateAction.addCommands(anyCollection())).thenReturn(commandListUpdateAction);
+
+ // When: Register again with same commands
+ SlashCommandRegistry.registerIfChanged(jda, commandClient);
+
+ // Then: JDA.updateCommands() should NOT be called (commands unchanged)
+ verify(jda, never()).updateCommands();
+ verify(commandListUpdateAction, never()).queue(any(), any());
+ }
+
+ @Test
+ void testHashChanged_ReregistersAndUpdatesHashFile() throws IOException {
+ // Given: Hash file exists with old command hash
+ when(commandClient.getSlashCommands()).thenReturn(List.of(slashCommand));
+
+ // First, register to create the hash file
+ ArgumentCaptor>> successCaptor = ArgumentCaptor.forClass(Consumer.class);
+ doNothing().when(commandListUpdateAction).queue(successCaptor.capture(), any());
+
+ SlashCommandRegistry.registerIfChanged(jda, commandClient);
+ successCaptor.getValue().accept(Collections.emptyList()); // Trigger hash save
+
+ // Verify hash file exists and capture the old hash
+ Path hashFile = tempDir.resolve(HASH_FILE_NAME);
+ String oldHash = Files.readString(hashFile, StandardCharsets.UTF_8).trim();
+
+ // Now change the command (different name = different hash)
+ SlashCommand modifiedCommand = mock(SlashCommand.class);
+ SlashCommandData modifiedData = mock(SlashCommandData.class);
+ when(modifiedCommand.buildCommandData()).thenReturn(modifiedData);
+ when(modifiedData.getName()).thenReturn("modifiedcommand"); // Different name
+ when(modifiedData.getDescription()).thenReturn("A modified command");
+ when(modifiedData.getOptions()).thenReturn(Collections.emptyList());
+ when(modifiedData.getSubcommands()).thenReturn(Collections.emptyList());
+ when(commandClient.getSlashCommands()).thenReturn(List.of(modifiedCommand));
+
+ // Reset mocks for second call
+ reset(jda, commandListUpdateAction);
+ when(jda.updateCommands()).thenReturn(commandListUpdateAction);
+ when(commandListUpdateAction.addCommands(anyCollection())).thenReturn(commandListUpdateAction);
+
+ ArgumentCaptor>> secondSuccessCaptor = ArgumentCaptor.forClass(Consumer.class);
+ doNothing().when(commandListUpdateAction).queue(secondSuccessCaptor.capture(), any());
+
+ // When: Register again with modified commands
+ SlashCommandRegistry.registerIfChanged(jda, commandClient);
+
+ // Then: JDA.updateCommands() SHOULD be called (commands changed)
+ verify(jda).updateCommands();
+ verify(commandListUpdateAction).addCommands(anyCollection());
+ verify(commandListUpdateAction).queue(any(), any());
+
+ // Simulate successful registration
+ secondSuccessCaptor.getValue().accept(Collections.emptyList());
+
+ // Verify hash file was updated with new hash
+ String newHash = Files.readString(hashFile, StandardCharsets.UTF_8).trim();
+ assertNotEquals(oldHash, newHash, "Hash should be different after command change");
+ }
+
+ @Test
+ void testEmptyCommands_DoesNotRegister() {
+ // Given: No commands to register
+ when(commandClient.getSlashCommands()).thenReturn(Collections.emptyList());
+
+ // When: Register
+ SlashCommandRegistry.registerIfChanged(jda, commandClient);
+
+ // Then: Nothing should happen
+ verify(jda, never()).updateCommands();
+ }
+
+ @Test
+ void testNullCommands_DoesNotRegister() {
+ // Given: Null command list
+ when(commandClient.getSlashCommands()).thenReturn(null);
+
+ // When: Register
+ SlashCommandRegistry.registerIfChanged(jda, commandClient);
+
+ // Then: Nothing should happen
+ verify(jda, never()).updateCommands();
+ }
+
+ @Test
+ void testClearHash_RemovesHashFile() throws IOException {
+ // Given: Hash file exists
+ Path hashFile = tempDir.resolve(HASH_FILE_NAME);
+ Files.writeString(hashFile, "somehash", StandardCharsets.UTF_8);
+ assertTrue(Files.exists(hashFile), "Hash file should exist before clearing");
+
+ // When: Clear hash
+ SlashCommandRegistry.clearHash();
+
+ // Then: Hash file should be deleted
+ assertFalse(Files.exists(hashFile), "Hash file should be deleted after clearing");
+ }
+
+ @Test
+ void testForceRegister_AlwaysRegisters() throws IOException {
+ // Given: Hash file exists with current command hash
+ when(commandClient.getSlashCommands()).thenReturn(List.of(slashCommand));
+
+ // First, do a normal registration
+ ArgumentCaptor>> successCaptor = ArgumentCaptor.forClass(Consumer.class);
+ doNothing().when(commandListUpdateAction).queue(successCaptor.capture(), any());
+
+ SlashCommandRegistry.registerIfChanged(jda, commandClient);
+ successCaptor.getValue().accept(Collections.emptyList());
+
+ // Reset mocks
+ reset(jda, commandListUpdateAction);
+ when(jda.updateCommands()).thenReturn(commandListUpdateAction);
+ when(commandListUpdateAction.addCommands(anyCollection())).thenReturn(commandListUpdateAction);
+
+ ArgumentCaptor>> forceSuccessCaptor = ArgumentCaptor.forClass(Consumer.class);
+ doNothing().when(commandListUpdateAction).queue(forceSuccessCaptor.capture(), any());
+
+ // When: Force register (even though hash hasn't changed)
+ SlashCommandRegistry.forceRegister(jda, commandClient);
+
+ // Then: JDA.updateCommands() SHOULD be called (forced)
+ verify(jda).updateCommands();
+ verify(commandListUpdateAction).addCommands(anyCollection());
+ verify(commandListUpdateAction).queue(any(), any());
+ }
+
+ @Test
+ void testRegistrationFailure_DoesNotSaveHash() throws IOException {
+ // Given: No hash file exists
+ when(commandClient.getSlashCommands()).thenReturn(List.of(slashCommand));
+
+ // Capture the error callback
+ ArgumentCaptor>> successCaptor = ArgumentCaptor.forClass(Consumer.class);
+ ArgumentCaptor> errorCaptor = ArgumentCaptor.forClass(Consumer.class);
+ doNothing().when(commandListUpdateAction).queue(successCaptor.capture(), errorCaptor.capture());
+
+ // When: Register commands
+ SlashCommandRegistry.registerIfChanged(jda, commandClient);
+
+ // Simulate failed registration callback
+ errorCaptor.getValue().accept(new RuntimeException("Discord API error"));
+
+ // Then: Hash file should NOT be created
+ Path hashFile = tempDir.resolve(HASH_FILE_NAME);
+ assertFalse(Files.exists(hashFile), "Hash file should not be created after failed registration");
+ }
+
+ @Test
+ void testHashCalculation_IncludesAllCommandDetails() throws IOException {
+ // Given: A command with specific details
+ when(commandClient.getSlashCommands()).thenReturn(List.of(slashCommand));
+
+ ArgumentCaptor>> successCaptor = ArgumentCaptor.forClass(Consumer.class);
+ doNothing().when(commandListUpdateAction).queue(successCaptor.capture(), any());
+
+ SlashCommandRegistry.registerIfChanged(jda, commandClient);
+ successCaptor.getValue().accept(Collections.emptyList());
+
+ Path hashFile = tempDir.resolve(HASH_FILE_NAME);
+ String hash1 = Files.readString(hashFile, StandardCharsets.UTF_8).trim();
+
+ // Now change the description (different description = different hash)
+ reset(jda, commandListUpdateAction, slashCommandData);
+ when(jda.updateCommands()).thenReturn(commandListUpdateAction);
+ when(commandListUpdateAction.addCommands(anyCollection())).thenReturn(commandListUpdateAction);
+ when(slashCommand.buildCommandData()).thenReturn(slashCommandData);
+ when(slashCommandData.getName()).thenReturn("testcommand"); // Same name
+ when(slashCommandData.getDescription()).thenReturn("Different description"); // Different description
+ when(slashCommandData.getOptions()).thenReturn(Collections.emptyList());
+ when(slashCommandData.getSubcommands()).thenReturn(Collections.emptyList());
+
+ ArgumentCaptor>> successCaptor2 = ArgumentCaptor.forClass(Consumer.class);
+ doNothing().when(commandListUpdateAction).queue(successCaptor2.capture(), any());
+
+ SlashCommandRegistry.registerIfChanged(jda, commandClient);
+ successCaptor2.getValue().accept(Collections.emptyList());
+
+ String hash2 = Files.readString(hashFile, StandardCharsets.UTF_8).trim();
+
+ // Hashes should be different because description changed
+ assertNotEquals(hash1, hash2, "Hash should change when command description changes");
+ }
+}
diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/queue/HistoryQueueTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/queue/HistoryQueueTest.java
new file mode 100644
index 000000000..67e1f85ff
--- /dev/null
+++ b/src/test/java/com/jagrosh/jmusicbot/unit/queue/HistoryQueueTest.java
@@ -0,0 +1,97 @@
+package com.jagrosh.jmusicbot.unit.queue;
+
+import com.jagrosh.jmusicbot.queue.HistoryQueue;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+import java.util.List;
+
+public class HistoryQueueTest {
+
+ @Test
+ public void testAddAndSize() {
+ HistoryQueue queue = new HistoryQueue<>();
+ queue.setMaxSize(3);
+
+ queue.add("one");
+ assertEquals(1, queue.size());
+ assertEquals("one", queue.get(0));
+
+ queue.add("two");
+ assertEquals(2, queue.size());
+ assertEquals("two", queue.get(0));
+ assertEquals("one", queue.get(1));
+ }
+
+ @Test
+ public void testMaxSize() {
+ HistoryQueue queue = new HistoryQueue<>();
+ queue.setMaxSize(2);
+
+ queue.add("one");
+ queue.add("two");
+ queue.add("three");
+
+ assertEquals(2, queue.size());
+ assertEquals("three", queue.get(0));
+ assertEquals("two", queue.get(1));
+
+ // Ensure "one" was removed (it was the oldest)
+ List list = queue.getList();
+ assertFalse(list.contains("one"));
+ }
+
+ @Test
+ public void testRemoveFirst() {
+ HistoryQueue queue = new HistoryQueue<>();
+ queue.setMaxSize(10);
+ queue.add("one");
+ queue.add("two");
+
+ assertEquals("two", queue.removeFirst());
+ assertEquals(1, queue.size());
+ assertEquals("one", queue.get(0));
+
+ assertEquals("one", queue.removeFirst());
+ assertTrue(queue.isEmpty());
+ assertNull(queue.removeFirst());
+ }
+
+ @Test
+ public void testSetMaxSizeShrink() {
+ HistoryQueue queue = new HistoryQueue<>();
+ queue.setMaxSize(5);
+ queue.add("1");
+ queue.add("2");
+ queue.add("3");
+ queue.add("4");
+ queue.add("5");
+
+ queue.setMaxSize(2);
+ assertEquals(2, queue.size());
+ assertEquals("5", queue.get(0));
+ assertEquals("4", queue.get(1));
+ }
+
+ @Test
+ public void testSetNegativeMaxSize() {
+ HistoryQueue queue = new HistoryQueue<>();
+ assertThrows(IllegalArgumentException.class, () -> queue.setMaxSize(-1));
+ }
+
+ @Test
+ public void testClear() {
+ HistoryQueue queue = new HistoryQueue<>();
+ queue.setMaxSize(10);
+ queue.add("one");
+ queue.clear();
+ assertTrue(queue.isEmpty());
+ assertEquals(0, queue.size());
+ }
+
+ @Test
+ public void testAddNull() {
+ HistoryQueue queue = new HistoryQueue<>();
+ queue.add(null);
+ assertEquals(0, queue.size());
+ }
+}
diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/queue/LinearQueueTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/queue/LinearQueueTest.java
new file mode 100644
index 000000000..16af6ca90
--- /dev/null
+++ b/src/test/java/com/jagrosh/jmusicbot/unit/queue/LinearQueueTest.java
@@ -0,0 +1,78 @@
+package com.jagrosh.jmusicbot.unit.queue;
+
+import com.jagrosh.jmusicbot.queue.LinearQueue;
+import com.jagrosh.jmusicbot.queue.Queueable;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+public class LinearQueueTest {
+
+ @Test
+ public void testAddAndPull() {
+ LinearQueue queue = new LinearQueue<>(null);
+ Q q1 = new Q(1);
+ Q q2 = new Q(2);
+
+ assertEquals(0, queue.add(q1));
+ assertEquals(1, queue.add(q2));
+ assertEquals(2, queue.size());
+
+ assertEquals(q1, queue.pull());
+ assertEquals(1, queue.size());
+ assertEquals(q2, queue.pull());
+ assertTrue(queue.isEmpty());
+ }
+
+ @Test
+ public void testRewind() {
+ LinearQueue queue = new LinearQueue<>(null);
+ queue.setMaxHistorySize(10);
+ Q q1 = new Q(1);
+ Q q2 = new Q(2);
+
+ queue.addToHistory(q1);
+
+ Q rewinded = queue.rewind(q2);
+ assertEquals(q1, rewinded);
+ assertEquals(1, queue.size());
+ assertEquals(q2, queue.get(0));
+ }
+
+ @Test
+ public void testMoveItem() {
+ LinearQueue queue = new LinearQueue<>(null);
+ Q q1 = new Q(1);
+ Q q2 = new Q(2);
+ Q q3 = new Q(3);
+
+ queue.add(q1);
+ queue.add(q2);
+ queue.add(q3);
+
+ queue.moveItem(0, 2); // Move q1 to the end
+ assertEquals(q2, queue.get(0));
+ assertEquals(q3, queue.get(1));
+ assertEquals(q1, queue.get(2));
+ }
+
+ @Test
+ public void testRemoveAll() {
+ LinearQueue queue = new LinearQueue<>(null);
+ queue.add(new Q(1));
+ queue.add(new Q(2));
+ queue.add(new Q(1));
+ queue.add(new Q(3));
+
+ int removed = queue.removeAll(1);
+ assertEquals(2, removed);
+ assertEquals(2, queue.size());
+ assertEquals(2L, queue.get(0).getIdentifier());
+ assertEquals(3L, queue.get(1).getIdentifier());
+ }
+
+ private static class Q implements Queueable {
+ private final long id;
+ Q(long id) { this.id = id; }
+ @Override public long getIdentifier() { return id; }
+ }
+}
diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/service/PlayerServiceTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/service/PlayerServiceTest.java
new file mode 100644
index 000000000..01e2c754b
--- /dev/null
+++ b/src/test/java/com/jagrosh/jmusicbot/unit/service/PlayerServiceTest.java
@@ -0,0 +1,55 @@
+package com.jagrosh.jmusicbot.unit.service;
+
+import com.jagrosh.jmusicbot.TestBase;
+import com.jagrosh.jmusicbot.service.MusicService;
+import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+public class PlayerServiceTest extends TestBase {
+
+ private MusicService.OutputAdapter output = mock(MusicService.OutputAdapter.class);
+
+ private MusicService musicService;
+
+ @BeforeEach
+ @Override
+ public void setUp() {
+ super.setUp();
+ musicService = new MusicService(bot);
+ }
+
+ @Test
+ public void testPlayResumeWhenPaused() {
+ when(audioPlayer.getPlayingTrack()).thenReturn(audioTrack);
+ when(audioPlayer.isPaused()).thenReturn(true);
+ AudioTrackInfo info = new AudioTrackInfo("Title", "Author", 1000, "identifier", true, "uri");
+ when(audioTrack.getInfo()).thenReturn(info);
+
+ musicService.play(guild, member, null, textChannel, output);
+
+ verify(audioPlayer).setPaused(false);
+ verify(output).replySuccess(anyString());
+ }
+
+ @Test
+ public void testPlayWithArgsCallsLoadItem() {
+ String args = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; // this is the best song ever
+
+ musicService.play(guild, member, args, textChannel, output);
+
+ verify(playerManager).loadItemOrdered(eq(guild), eq(args), any());
+ }
+
+ @Test
+ public void testPlayEmptyArgsShowsHelpWhenNotPaused() {
+ when(audioPlayer.getPlayingTrack()).thenReturn(null);
+
+ musicService.play(guild, member, null, textChannel, output);
+
+ verify(output).onShowHelp();
+ }
+}
diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/settings/SettingsTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/settings/SettingsTest.java
new file mode 100644
index 000000000..7b8784b96
--- /dev/null
+++ b/src/test/java/com/jagrosh/jmusicbot/unit/settings/SettingsTest.java
@@ -0,0 +1,130 @@
+package com.jagrosh.jmusicbot.unit.settings;
+
+import com.jagrosh.jmusicbot.settings.QueueType;
+import com.jagrosh.jmusicbot.settings.RepeatMode;
+import com.jagrosh.jmusicbot.settings.Settings;
+import com.jagrosh.jmusicbot.settings.SettingsManager;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+public class SettingsTest {
+
+ private SettingsManager manager;
+ private Settings settings;
+
+ @BeforeEach
+ public void setUp() {
+ // Mock manager to prevent NPE when setters call writeSettings()
+ manager = mock(SettingsManager.class);
+ settings = new Settings(manager, 0, 0, 0, 100, null, RepeatMode.OFF, null, -1, QueueType.FAIR);
+ }
+
+ @Test
+ public void testDefaultValues() {
+ assertEquals(100, settings.getVolume());
+ assertEquals(RepeatMode.OFF, settings.getRepeatMode());
+ assertEquals(QueueType.FAIR, settings.getQueueType());
+ assertNull(settings.getPrefix());
+ assertNull(settings.getDefaultPlaylist());
+ assertEquals(-1, settings.getSkipRatio());
+ assertTrue(settings.getPrefixes().isEmpty());
+ }
+
+ @Test
+ public void testSetVolume() {
+ settings.setVolume(50);
+ assertEquals(50, settings.getVolume());
+
+ settings.setVolume(0);
+ assertEquals(0, settings.getVolume());
+
+ settings.setVolume(150);
+ assertEquals(150, settings.getVolume());
+ }
+
+ @Test
+ public void testSetRepeatMode() {
+ settings.setRepeatMode(RepeatMode.ALL);
+ assertEquals(RepeatMode.ALL, settings.getRepeatMode());
+
+ settings.setRepeatMode(RepeatMode.SINGLE);
+ assertEquals(RepeatMode.SINGLE, settings.getRepeatMode());
+
+ settings.setRepeatMode(RepeatMode.OFF);
+ assertEquals(RepeatMode.OFF, settings.getRepeatMode());
+ }
+
+ @Test
+ public void testSetQueueType() {
+ settings.setQueueType(QueueType.LINEAR);
+ assertEquals(QueueType.LINEAR, settings.getQueueType());
+
+ settings.setQueueType(QueueType.FAIR);
+ assertEquals(QueueType.FAIR, settings.getQueueType());
+ }
+
+ @Test
+ public void testSetPrefix() {
+ settings.setPrefix("!");
+ assertEquals("!", settings.getPrefix());
+ assertTrue(settings.getPrefixes().contains("!"));
+ assertEquals(1, settings.getPrefixes().size());
+
+ settings.setPrefix("!!");
+ assertEquals("!!", settings.getPrefix());
+ assertTrue(settings.getPrefixes().contains("!!"));
+ }
+
+ @Test
+ public void testSetPrefixNull() {
+ settings.setPrefix("!");
+ settings.setPrefix(null);
+ assertNull(settings.getPrefix());
+ assertTrue(settings.getPrefixes().isEmpty());
+ }
+
+ @Test
+ public void testSetDefaultPlaylist() {
+ settings.setDefaultPlaylist("my_playlist");
+ assertEquals("my_playlist", settings.getDefaultPlaylist());
+
+ settings.setDefaultPlaylist(null);
+ assertNull(settings.getDefaultPlaylist());
+ }
+
+ @Test
+ public void testSetSkipRatio() {
+ settings.setSkipRatio(0.5);
+ assertEquals(0.5, settings.getSkipRatio());
+
+ settings.setSkipRatio(0.0);
+ assertEquals(0.0, settings.getSkipRatio());
+
+ settings.setSkipRatio(1.0);
+ assertEquals(1.0, settings.getSkipRatio());
+ }
+
+ @Test
+ public void testConstructorWithStringIds() {
+ Settings s = new Settings(manager, "123", "456", "789", 75, "playlist", RepeatMode.ALL, "?", 0.6, QueueType.LINEAR);
+
+ assertEquals(75, s.getVolume());
+ assertEquals("playlist", s.getDefaultPlaylist());
+ assertEquals(RepeatMode.ALL, s.getRepeatMode());
+ assertEquals("?", s.getPrefix());
+ assertEquals(0.6, s.getSkipRatio());
+ assertEquals(QueueType.LINEAR, s.getQueueType());
+ }
+
+ @Test
+ public void testConstructorWithInvalidStringIds() {
+ // Invalid IDs should default to 0 without throwing
+ Settings s = new Settings(manager, "invalid", "also_invalid", "nope", 100, null, RepeatMode.OFF, null, -1, QueueType.FAIR);
+
+ assertEquals(100, s.getVolume());
+ assertEquals(RepeatMode.OFF, s.getRepeatMode());
+ }
+}
diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/utils/FormatUtilTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/utils/FormatUtilTest.java
new file mode 100644
index 000000000..a45494766
--- /dev/null
+++ b/src/test/java/com/jagrosh/jmusicbot/unit/utils/FormatUtilTest.java
@@ -0,0 +1,30 @@
+package com.jagrosh.jmusicbot.unit.utils;
+
+import com.jagrosh.jmusicbot.utils.FormatUtil;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+public class FormatUtilTest {
+
+ @Test
+ public void testFormatUsername() {
+ assertEquals("User#1234", FormatUtil.formatUsername("User", "1234"));
+ assertEquals("User", FormatUtil.formatUsername("User", "0000"));
+ assertEquals("User", FormatUtil.formatUsername("User", null));
+ }
+
+ @Test
+ public void testVolumeIcon() {
+ assertEquals("\uD83D\uDD07", FormatUtil.volumeIcon(0));
+ assertEquals("\uD83D\uDD08", FormatUtil.volumeIcon(20));
+ assertEquals("\uD83D\uDD09", FormatUtil.volumeIcon(50));
+ assertEquals("\uD83D\uDD0A", FormatUtil.volumeIcon(80));
+ }
+
+ @Test
+ public void testFilter() {
+ assertEquals("safe", FormatUtil.filter("safe"));
+ assertEquals("@\u0435veryone", FormatUtil.filter("@everyone"));
+ assertEquals("@h\u0435re", FormatUtil.filter("@here"));
+ }
+}
diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/utils/OtherUtilTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/utils/OtherUtilTest.java
index 1fbec2bc9..194ef506c 100644
--- a/src/test/java/com/jagrosh/jmusicbot/unit/utils/OtherUtilTest.java
+++ b/src/test/java/com/jagrosh/jmusicbot/unit/utils/OtherUtilTest.java
@@ -79,6 +79,7 @@ private static Stream testData()
}
@Test
+<<<<<<< HEAD
@DisplayName("getLatestVersion returns latest non-prerelease version when latest is not a pre-release")
void testGetLatestVersion_NonPrerelease() throws IOException
{
@@ -312,4 +313,13 @@ void testGetLatestVersion_MultiplePrereleases() throws IOException
assertEquals("0.6.2", result);
}
+
+ @Test
+ @DisplayName("makeNonEmpty returns the string if not empty, otherwise returns zero-width space")
+ public void testMakeNonEmpty()
+ {
+ assertEquals("test", OtherUtil.makeNonEmpty("test"));
+ assertEquals("\u200B", OtherUtil.makeNonEmpty(null));
+ assertEquals("\u200B", OtherUtil.makeNonEmpty(""));
+ }
}
diff --git a/src/test/java/com/jagrosh/jmusicbot/unit/utils/TimeUtilTest.java b/src/test/java/com/jagrosh/jmusicbot/unit/utils/TimeUtilTest.java
index 5a138ad6e..1558421d5 100644
--- a/src/test/java/com/jagrosh/jmusicbot/unit/utils/TimeUtilTest.java
+++ b/src/test/java/com/jagrosh/jmusicbot/unit/utils/TimeUtilTest.java
@@ -115,4 +115,15 @@ public void timestampNumberFormat()
assertNotNull(seek);
assertEquals(3000, seek.milliseconds);
}
+
+ @Test
+ public void testFormatTime()
+ {
+ assertEquals("LIVE", TimeUtil.formatTime(Long.MAX_VALUE));
+ assertEquals("00:00", TimeUtil.formatTime(0));
+ assertEquals("00:05", TimeUtil.formatTime(5000));
+ assertEquals("00:59", TimeUtil.formatTime(59000));
+ assertEquals("01:00", TimeUtil.formatTime(60000));
+ assertEquals("1:01:01", TimeUtil.formatTime(3661000));
+ }
}