From 87ba09b0012f8674d8022be3cf2c14d306a7ea7f Mon Sep 17 00:00:00 2001 From: anthorob Date: Wed, 28 Oct 2026 03:58:19 -0400 Subject: [PATCH] **Improve event handling with new music and queue updates** Added new `MusicEvent` types, including `TRACK_ERROR`, `TRACK_STUCK`, and `PLAYER_POSITION`, providing enhanced diagnostics and feedback. Improved queue mutation notifications by introducing granular events like `added`, `removed`, `moved`, `cleared`, and `shuffled`. Updated tests and documentation to reflect the changes, ensuring consistency and clarity across the event system. --- README.md | 2 +- docs/events.md | 15 ++ .../netneutrality/events/MusicEvent.java | 6 +- .../netneutrality/hooks/EventsHandler.java | 2 + .../netneutrality/music/MusicServiceImpl.java | 6 + .../netneutrality/music/QueueManager.java | 58 +++++-- .../netneutrality/music/TrackScheduler.java | 50 +++++- .../music/QueueManagerEventsTest.java | 147 ++++++++++++++++++ .../music/TrackSchedulerTest.java | 70 +++++++++ 9 files changed, 336 insertions(+), 20 deletions(-) create mode 100644 src/test/java/me/bobert/netneutrality/music/QueueManagerEventsTest.java diff --git a/README.md b/README.md index 4dddce7..2672083 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ When `NETNEUTRALITY_WEBHOOK_URL` is set, the bot POSTs JSON music-state events ( `TRACK_START` / `TRACK_END` payloads are `{ identifier, title, author, length, uri, artworkUrl }` (plus `reason` on `TRACK_END`). `uri` and `artworkUrl` may be `null` when the source doesn't provide them. -`VOICE_CONNECTED` / `VOICE_DISCONNECTED` are sent for the bot itself and for any member joining or leaving the voice channel the bot is currently in. Their payload is `{ channelId, userId, bot, self }`, where `bot` marks bot accounts and `self` marks this bot's own voice changes. +`VOICE_CONNECTED` / `VOICE_DISCONNECTED` are sent for the bot itself and for any member joining or leaving the voice channel the bot is currently in. Their payload is `{ channelId, userId, username, nickname, bot, self }`, where `nickname` is the member's effective display name in the guild, `bot` marks bot accounts and `self` marks this bot's own voice changes. ## Running with Docker Compose diff --git a/docs/events.md b/docs/events.md index 21ef943..12491fa 100644 --- a/docs/events.md +++ b/docs/events.md @@ -91,6 +91,21 @@ Key points: `TaskManager.getInstance().handle(context)`. - **`null` contexts** (bot messages, consumer-only events) are simply not routed anywhere. +## Webhook music events + +`MusicEvent` instances are delivered by `WebhookDispatcher`. Every event carries `type`, `guildId`, +`timestamp` and `seq`; the payloads of the queue and player events are: + +| Type | Payload | +|---|---| +| `QUEUE_UPDATED` | `size` (int), `change` (`added`/`removed`/`moved`/`cleared`/`shuffled`), `items` (list). Each item has `index` (0-based position, before the mutation for removals), `identifier`, `title`, `uri`, `requesterId`. `moved` items also carry `fromIndex` and `toIndex`. `cleared` and `shuffled` have an empty `items` (reload the queue on `shuffled`). | +| `QUEUE_SHUFFLED` | `size` | +| `TRACK_ERROR` | `track` (`identifier`, `title`, `author`, `length`, `uri`, `artworkUrl`), `message`, `severity` | +| `TRACK_STUCK` | `track` (same fields as above), `thresholdMs` | +| `PLAYER_POSITION` | `position` (ms), `timestamp` (node time, epoch ms), `connected`, `ping` (ms). Sent from Lavalink `PlayerUpdateEvent`, at most once every 5 seconds per guild and only while a track is playing. | + +`size` in `QUEUE_UPDATED` is unchanged, so existing consumers keep working. + ## Extending the event system To handle a new event type: diff --git a/src/main/java/me/bobert/netneutrality/events/MusicEvent.java b/src/main/java/me/bobert/netneutrality/events/MusicEvent.java index f71b46b..213f13b 100644 --- a/src/main/java/me/bobert/netneutrality/events/MusicEvent.java +++ b/src/main/java/me/bobert/netneutrality/events/MusicEvent.java @@ -27,7 +27,11 @@ public record MusicEvent(Type type, String guildId, long timestamp, long seq, Ob PLAYER_SEEKED, VOTE_SKIP_UPDATED, VOICE_CONNECTED, - VOICE_DISCONNECTED + VOICE_DISCONNECTED, + QUEUE_SHUFFLED, + TRACK_ERROR, + TRACK_STUCK, + PLAYER_POSITION } /** diff --git a/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java b/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java index c03907e..8f76e5c 100644 --- a/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java +++ b/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java @@ -171,6 +171,8 @@ public class EventsHandler implements EventListener { return Map.of( "channelId", channelId, "userId", event.getEntity().getId(), + "username", event.getEntity().getUser().getName(), + "nickname", event.getEntity().getEffectiveName(), "bot", event.getEntity().getUser().isBot(), "self", self); } diff --git a/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java b/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java index 035816f..6d8216d 100644 --- a/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java +++ b/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java @@ -10,6 +10,7 @@ import dev.arbjerg.lavalink.client.Helpers; import dev.arbjerg.lavalink.client.LavalinkClient; import dev.arbjerg.lavalink.client.LavalinkNode; import dev.arbjerg.lavalink.client.NodeOptions; +import dev.arbjerg.lavalink.client.event.PlayerUpdateEvent; import dev.arbjerg.lavalink.client.event.ReadyEvent; import dev.arbjerg.lavalink.client.event.TrackEndEvent; import dev.arbjerg.lavalink.client.event.TrackExceptionEvent; @@ -209,6 +210,11 @@ public class MusicServiceImpl implements MusicService { subscriptions.add(client.on(TrackStuckEvent.class) .subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onTrackStuck(event.getTrack(), event.getThresholdMs())))); + subscriptions.add(client.on(PlayerUpdateEvent.class) + .subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onPlayerUpdate( + event.getState().getTime(), event.getState().getPosition(), + event.getState().getConnected(), event.getState().getPing())))); + subscriptions.add(client.on(WebSocketClosedEvent.class) .subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onWebSocketClosed(event.getCode(), event.getReason(), event.getByRemote())))); } diff --git a/src/main/java/me/bobert/netneutrality/music/QueueManager.java b/src/main/java/me/bobert/netneutrality/music/QueueManager.java index d2524d0..b66c49d 100644 --- a/src/main/java/me/bobert/netneutrality/music/QueueManager.java +++ b/src/main/java/me/bobert/netneutrality/music/QueueManager.java @@ -15,6 +15,7 @@ import org.apache.logging.log4j.Logger; import java.util.ArrayList; import java.util.Collections; import java.util.Deque; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.LinkedBlockingDeque; @@ -63,18 +64,31 @@ public class QueueManager { public void removeHeadOfQueue() { synchronized (queue) { - queue.poll(); - notifyQueueUpdated(); + TrackInfo head = queue.poll(); + notifyQueueUpdated("removed", head == null ? List.of() : List.of(item(0, head))); } } - private void notifyQueueUpdated() { + private static Map item(int index, TrackInfo info) { + Map item = new HashMap<>(); + item.put("index", index); + item.put("identifier", info.getTrack().getInfo().getIdentifier()); + item.put("title", info.getTrack().getInfo().getTitle()); + item.put("uri", info.getTrack().getInfo().getUri()); + item.put("requesterId", info.getRequester() == null ? null : info.getRequester().getId()); + return item; + } + + private void notifyQueueUpdated(String change, List> items) { MusicManager musicManager = scheduler.getMusicManager(); if (musicManager == null || musicManager.getEvents() == null) { return; } - musicManager.getEvents().publish(MusicEvent.of(MusicEvent.Type.QUEUE_UPDATED, guild.getId(), - Map.of("size", queue.size()))); + Map payload = new HashMap<>(); + payload.put("size", queue.size()); + payload.put("change", change); + payload.put("items", items); + musicManager.getEvents().publish(MusicEvent.of(MusicEvent.Type.QUEUE_UPDATED, guild.getId(), payload)); } public boolean isEmpty() { @@ -91,7 +105,7 @@ public class QueueManager { } queue.clear(); - notifyQueueUpdated(); + notifyQueueUpdated("cleared", List.of()); scheduler.getCallback().onQueueEmpty(); return QueueActionResult.success(String.format("Removed %d track%s from the queue", queueSize, (queueSize > 1 ? "s" : ""))); } @@ -109,8 +123,16 @@ public class QueueManager { } TrackInfo first = queue.peek(); + List> removed = new ArrayList<>(); + int position = 0; + for (TrackInfo trackInfo : queue) { + if (member.equals(trackInfo.getRequester())) { + removed.add(item(position, trackInfo)); + } + position++; + } queue.removeIf(trackInfo -> member.equals(trackInfo.getRequester())); - notifyQueueUpdated(); + notifyQueueUpdated("removed", removed); if (first != null && member.equals(first.getRequester())) { skip(1); @@ -148,7 +170,7 @@ public class QueueManager { } else { queue.add(info); } - notifyQueueUpdated(); + notifyQueueUpdated("added", List.of(item(queue.peekFirst() == info ? 0 : queue.size() - 1, info))); if (idle) { TrackInfo head = queue.peek(); @@ -196,8 +218,8 @@ public class QueueManager { public void startNextTrack() { synchronized (queue) { - queue.poll(); - notifyQueueUpdated(); + TrackInfo polled = queue.poll(); + notifyQueueUpdated("removed", polled == null ? List.of() : List.of(item(0, polled))); TrackInfo next = queue.peek(); if (next != null) { @@ -259,7 +281,12 @@ public class QueueManager { queue.clear(); queue.add(current); queue.addAll(newQueue); - notifyQueueUpdated(); + notifyQueueUpdated("shuffled", List.of()); + MusicManager musicManager = scheduler.getMusicManager(); + if (musicManager != null && musicManager.getEvents() != null) { + musicManager.getEvents().publish(MusicEvent.of(MusicEvent.Type.QUEUE_SHUFFLED, guild.getId(), + Map.of("size", queue.size()))); + } return QueueActionResult.success("The queue is now shuffled."); } @@ -279,11 +306,11 @@ public class QueueManager { if (index > 1) { List newQueue = new ArrayList<>(queue); - newQueue.remove(index - 1); + TrackInfo removedInfo = newQueue.remove(index - 1); queue.clear(); queue.addAll(newQueue); - notifyQueueUpdated(); + notifyQueueUpdated("removed", List.of(item(index - 1, removedInfo))); return QueueActionResult.success(String.format("Removed track #%d from the queue", index)); } @@ -327,7 +354,10 @@ public class QueueManager { queue.clear(); queue.addAll(newQueue); - notifyQueueUpdated(); + Map movedItem = item(toIndex - 1, moved); + movedItem.put("fromIndex", fromIndex - 1); + movedItem.put("toIndex", toIndex - 1); + notifyQueueUpdated("moved", List.of(movedItem)); return QueueActionResult.success(String.format("Moved track #%d to position #%d", fromIndex, toIndex)); } diff --git a/src/main/java/me/bobert/netneutrality/music/TrackScheduler.java b/src/main/java/me/bobert/netneutrality/music/TrackScheduler.java index 2ea77de..9938816 100644 --- a/src/main/java/me/bobert/netneutrality/music/TrackScheduler.java +++ b/src/main/java/me/bobert/netneutrality/music/TrackScheduler.java @@ -33,6 +33,8 @@ public class TrackScheduler { private volatile Context context; private volatile Track currentTrack; private final StuckTrackRecoveryHandler stuckTrackRecoveryHandler; + private static final long POSITION_THROTTLE_MS = 5000; + private volatile long lastPositionSent = Long.MIN_VALUE; public TrackScheduler(MusicManager manager) { this.manager = manager; @@ -137,10 +139,7 @@ public class TrackScheduler { // clone of this back to your queue } - private void publish(MusicEvent.Type type, Track track, String reason) { - if (manager.getEvents() == null) { - return; - } + private static java.util.Map trackPayload(Track track) { java.util.Map payload = new java.util.HashMap<>(); payload.put("identifier", track.getInfo().getIdentifier()); payload.put("title", track.getInfo().getTitle()); @@ -148,6 +147,14 @@ public class TrackScheduler { payload.put("length", track.getInfo().getLength()); payload.put("uri", track.getInfo().getUri()); payload.put("artworkUrl", track.getInfo().getArtworkUrl()); + return payload; + } + + private void publish(MusicEvent.Type type, Track track, String reason) { + if (manager.getEvents() == null) { + return; + } + java.util.Map payload = trackPayload(track); if (reason != null) { payload.put("reason", reason); } @@ -157,6 +164,14 @@ public class TrackScheduler { public void onTrackException(Track track, TrackException exception) { logger.error("Guild {}: exception while playing track: {}", guild.getId(), track.getInfo().getIdentifier()); + if (manager.getEvents() != null) { + java.util.Map payload = new java.util.HashMap<>(); + payload.put("track", trackPayload(track)); + payload.put("message", exception.getMessage()); + payload.put("severity", exception.getSeverity() == null ? null : exception.getSeverity().name()); + manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.TRACK_ERROR, guild.getId(), payload)); + } + LogHandler.log(new ContextException( String.format("Error while playing track: %s", track.getInfo().getTitle()), getContext(), @@ -166,9 +181,36 @@ public class TrackScheduler { public void onTrackStuck(Track track, long thresholdMs) { logger.error("Guild {}: currently playing track is stuck: {} (threshold {}ms)", guild.getId(), track.getInfo().getIdentifier(), thresholdMs); + if (manager.getEvents() != null) { + java.util.Map payload = new java.util.HashMap<>(); + payload.put("track", trackPayload(track)); + payload.put("thresholdMs", thresholdMs); + manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.TRACK_STUCK, guild.getId(), payload)); + } stuckTrackRecoveryHandler.handle(track, context); } + public void onPlayerUpdate(long timestamp, long position, boolean connected, long ping) { + onPlayerUpdate(timestamp, position, connected, ping, System.currentTimeMillis()); + } + + void onPlayerUpdate(long timestamp, long position, boolean connected, long ping, long now) { + if (manager.getEvents() == null || currentTrack == null) { + return; + } + if (lastPositionSent != Long.MIN_VALUE && now - lastPositionSent < POSITION_THROTTLE_MS) { + return; + } + lastPositionSent = now; + + java.util.Map payload = new java.util.HashMap<>(); + payload.put("position", position); + payload.put("timestamp", timestamp); + payload.put("connected", connected); + payload.put("ping", ping); + manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.PLAYER_POSITION, guild.getId(), payload)); + } + public void onWebSocketClosed(int code, String reason, boolean byRemote) { logger.warn("Guild {}: voice websocket closed with code {} ({}), by remote: {}", guild.getId(), code, reason, byRemote); currentTrack = null; diff --git a/src/test/java/me/bobert/netneutrality/music/QueueManagerEventsTest.java b/src/test/java/me/bobert/netneutrality/music/QueueManagerEventsTest.java new file mode 100644 index 0000000..dc47221 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/music/QueueManagerEventsTest.java @@ -0,0 +1,147 @@ +package me.bobert.netneutrality.music; +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import dev.arbjerg.lavalink.client.player.Track; +import me.bobert.netneutrality.events.EventPublisher; +import me.bobert.netneutrality.events.MusicEvent; +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.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class QueueManagerEventsTest { + + private final List events = new ArrayList<>(); + private TrackScheduler scheduler; + private QueueManager queueManager; + private Member member; + + @BeforeEach + void setUp() { + Guild guild = mock(Guild.class); + when(guild.getId()).thenReturn("guild1"); + + scheduler = mock(TrackScheduler.class); + MusicManager musicManager = mock(MusicManager.class); + EventPublisher publisher = events::add; + + when(scheduler.getGuild()).thenReturn(guild); + when(scheduler.getMusicManager()).thenReturn(musicManager); + when(scheduler.getCallback()).thenReturn(mock(MusicManagerCallback.class)); + when(musicManager.getEvents()).thenReturn(publisher); + when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(QueueManagerTest.mockTrack("t1")); + + member = mock(Member.class); + when(member.getId()).thenReturn("u1"); + + queueManager = new QueueManager(scheduler); + } + + private void fill(int count) { + for (int i = 1; i <= count; i++) { + Track track = QueueManagerTest.mockTrack("t" + i); + queueManager.queue(track, member); + } + events.clear(); + } + + @SuppressWarnings("unchecked") + private Map payload(MusicEvent event) { + return (Map) event.payload(); + } + + @SuppressWarnings("unchecked") + private List> items(MusicEvent event) { + return (List>) payload(event).get("items"); + } + + @Test + public void addPublishesAddedWithIndex() { + queueManager.queue(QueueManagerTest.mockTrack("t1"), member); + + assertEquals(1, events.size()); + MusicEvent event = events.get(0); + assertEquals(MusicEvent.Type.QUEUE_UPDATED, event.type()); + assertEquals("guild1", event.guildId()); + assertEquals(1, payload(event).get("size")); + assertEquals("added", payload(event).get("change")); + assertEquals(0, items(event).get(0).get("index")); + assertEquals("t1", items(event).get(0).get("identifier")); + assertEquals("u1", items(event).get(0).get("requesterId")); + } + + @Test + public void removePublishesRemovedWithOriginalIndex() { + fill(3); + + queueManager.removeFromQueue(3); + + assertEquals(1, events.size()); + assertEquals("removed", payload(events.get(0)).get("change")); + assertEquals(2, payload(events.get(0)).get("size")); + assertEquals(2, items(events.get(0)).get(0).get("index")); + assertEquals("t3", items(events.get(0)).get(0).get("identifier")); + } + + @Test + public void movePublishesFromAndTo() { + fill(4); + + queueManager.reorder(4, 2); + + assertEquals(1, events.size()); + assertEquals("moved", payload(events.get(0)).get("change")); + Map item = items(events.get(0)).get(0); + assertEquals(3, item.get("fromIndex")); + assertEquals(1, item.get("toIndex")); + assertEquals("t4", item.get("identifier")); + } + + @Test + public void clearPublishesCleared() { + fill(2); + + queueManager.clearQueue(); + + assertEquals(1, events.size()); + assertEquals("cleared", payload(events.get(0)).get("change")); + assertEquals(0, payload(events.get(0)).get("size")); + assertTrue(items(events.get(0)).isEmpty()); + } + + @Test + public void shufflePublishesShuffledAndQueueShuffled() { + fill(4); + + queueManager.shuffle(); + + assertEquals(2, events.size()); + assertEquals(MusicEvent.Type.QUEUE_UPDATED, events.get(0).type()); + assertEquals("shuffled", payload(events.get(0)).get("change")); + assertEquals(4, payload(events.get(0)).get("size")); + assertEquals(MusicEvent.Type.QUEUE_SHUFFLED, events.get(1).type()); + assertEquals("guild1", events.get(1).guildId()); + } + + @Test + public void removeHeadPublishesRemovedAtIndexZero() { + fill(2); + + queueManager.removeHeadOfQueue(); + + assertEquals("removed", payload(events.get(0)).get("change")); + assertEquals(0, items(events.get(0)).get(0).get("index")); + assertEquals(1, payload(events.get(0)).get("size")); + } +} diff --git a/src/test/java/me/bobert/netneutrality/music/TrackSchedulerTest.java b/src/test/java/me/bobert/netneutrality/music/TrackSchedulerTest.java index 0d68e7e..3b88043 100644 --- a/src/test/java/me/bobert/netneutrality/music/TrackSchedulerTest.java +++ b/src/test/java/me/bobert/netneutrality/music/TrackSchedulerTest.java @@ -11,6 +11,7 @@ import net.dv8tion.jda.api.entities.Guild; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.mockito.Mockito.*; @@ -76,6 +77,75 @@ public class TrackSchedulerTest { assertNull(scheduler.getCurrentTrack()); } + @Test + @SuppressWarnings("unchecked") + public void onTrackExceptionPublishesTrackError() { + java.util.List events = new java.util.ArrayList<>(); + when(musicManager.getEvents()).thenReturn(events::add); + dev.arbjerg.lavalink.client.player.TrackException exception = mock(dev.arbjerg.lavalink.client.player.TrackException.class); + when(exception.getMessage()).thenReturn("boom"); + scheduler.attachContext(mock(me.bobert.netneutrality.context.MusicChannelContext.class)); + + scheduler.onTrackException(QueueManagerTest.mockTrack("t1"), exception); + + assertEquals(1, events.size()); + assertEquals(me.bobert.netneutrality.events.MusicEvent.Type.TRACK_ERROR, events.get(0).type()); + assertEquals("guild1", events.get(0).guildId()); + java.util.Map payload = (java.util.Map) events.get(0).payload(); + assertEquals("boom", payload.get("message")); + assertEquals("t1", ((java.util.Map) payload.get("track")).get("identifier")); + } + + @Test + @SuppressWarnings("unchecked") + public void onTrackStuckPublishesTrackStuck() { + java.util.List events = new java.util.ArrayList<>(); + when(musicManager.getEvents()).thenReturn(events::add); + + scheduler.attachContext(mock(me.bobert.netneutrality.context.MusicChannelContext.class)); + + scheduler.onTrackStuck(QueueManagerTest.mockTrack("t1"), 10000L); + + me.bobert.netneutrality.events.MusicEvent event = events.stream() + .filter(e -> e.type() == me.bobert.netneutrality.events.MusicEvent.Type.TRACK_STUCK) + .findFirst().orElseThrow(); + assertEquals("guild1", event.guildId()); + java.util.Map payload = (java.util.Map) event.payload(); + assertEquals(10000L, payload.get("thresholdMs")); + assertEquals("t1", ((java.util.Map) payload.get("track")).get("identifier")); + } + + @Test + @SuppressWarnings("unchecked") + public void onPlayerUpdateIsThrottledPerGuild() { + java.util.List events = new java.util.ArrayList<>(); + when(musicManager.getEvents()).thenReturn(events::add); + scheduler.onTrackStart(QueueManagerTest.mockTrack("t1")); + events.clear(); + + scheduler.onPlayerUpdate(100L, 1500L, true, 20L, 1000L); + scheduler.onPlayerUpdate(101L, 1600L, true, 20L, 3000L); + scheduler.onPlayerUpdate(102L, 6500L, true, 20L, 6000L); + + assertEquals(2, events.size()); + assertEquals(me.bobert.netneutrality.events.MusicEvent.Type.PLAYER_POSITION, events.get(0).type()); + java.util.Map payload = (java.util.Map) events.get(0).payload(); + assertEquals(1500L, payload.get("position")); + assertEquals(100L, payload.get("timestamp")); + assertEquals(true, payload.get("connected")); + assertEquals(20L, payload.get("ping")); + } + + @Test + public void onPlayerUpdateSkippedWhenNothingPlaying() { + java.util.List events = new java.util.ArrayList<>(); + when(musicManager.getEvents()).thenReturn(events::add); + + scheduler.onPlayerUpdate(100L, 1500L, true, 20L, 1000L); + + assertEquals(0, events.size()); + } + @Test public void getDjReturnsNullWhenNoContext() { assertNull(scheduler.getDj());