From e51a533bf0db884c36ede8db47e85a0f53679c9f Mon Sep 17 00:00:00 2001 From: Tristan Vermeesch Date: Thu, 12 Feb 2026 07:59:42 +0100 Subject: [PATCH 1/2] fix: debug message before language manager init --- src/main/java/me/playbosswar/com/utils/Files.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/me/playbosswar/com/utils/Files.java b/src/main/java/me/playbosswar/com/utils/Files.java index 434a4ca..cb9e047 100644 --- a/src/main/java/me/playbosswar/com/utils/Files.java +++ b/src/main/java/me/playbosswar/com/utils/Files.java @@ -49,7 +49,6 @@ public static void createDataFolders() { File dataFolder = CommandTimerPlugin.getPlugin().getDataFolder(); File enLangFile = new File(dataFolder.getAbsoluteFile() + "/languages/en.json"); if(!enLangFile.exists()) { - Messages.sendDebugConsole("could not find languages/en.json, creating default"); CommandTimerPlugin.getPlugin().saveResource("languages/en.json", false); } CommandTimerPlugin.getPlugin().saveResource("languages/default.json", true); From e47993fef98501fb541f91baf12a5b98cafac948 Mon Sep 17 00:00:00 2001 From: Tristan Vermeesch Date: Sat, 28 Feb 2026 12:46:05 +0100 Subject: [PATCH 2/2] fix: task time execution issues and last execution date issues --- build.gradle | 10 + .../playbosswar/com/tasks/ScheduledTask.java | 16 +- .../java/me/playbosswar/com/tasks/Task.java | 7 + .../me/playbosswar/com/tasks/TaskRunner.java | 1 - .../playbosswar/com/tasks/TasksManager.java | 391 ++++++----- .../playbosswar/com/utils/DatabaseUtils.java | 7 +- .../java/me/playbosswar/com/utils/Files.java | 7 +- .../com/tasks/TasksManagerScheduleTest.java | 618 ++++++++++++++++++ 8 files changed, 880 insertions(+), 177 deletions(-) create mode 100644 src/test/java/me/playbosswar/com/tasks/TasksManagerScheduleTest.java diff --git a/build.gradle b/build.gradle index 8a44139..7d34e94 100644 --- a/build.gradle +++ b/build.gradle @@ -67,6 +67,16 @@ dependencies { compileOnly 'org.spigotmc:spigot-api:1.8.8-R0.1-SNAPSHOT' compileOnly 'me.clip:placeholderapi:2.11.6' compileOnly 'org.jetbrains:annotations:23.0.0' + + testImplementation 'org.junit.jupiter:junit-jupiter:5.9.3' + testImplementation 'org.mockito:mockito-core:4.11.0' + testImplementation 'org.mockito:mockito-junit-jupiter:4.11.0' + testImplementation 'org.spigotmc:spigot-api:1.8.8-R0.1-SNAPSHOT' + testImplementation 'org.jetbrains:annotations:23.0.0' +} + +test { + useJUnitPlatform() } publishing { diff --git a/src/main/java/me/playbosswar/com/tasks/ScheduledTask.java b/src/main/java/me/playbosswar/com/tasks/ScheduledTask.java index 63e9067..65cc5c2 100644 --- a/src/main/java/me/playbosswar/com/tasks/ScheduledTask.java +++ b/src/main/java/me/playbosswar/com/tasks/ScheduledTask.java @@ -2,20 +2,34 @@ import java.time.ZonedDateTime; +import org.jetbrains.annotations.Nullable; + public class ScheduledTask { private final Task task; private final ZonedDateTime date; + @Nullable + private final TaskTime taskTime; public ScheduledTask(Task task, ZonedDateTime date) { + this(task, date, null); + } + + public ScheduledTask(Task task, ZonedDateTime date, @Nullable TaskTime taskTime) { this.task = task; this.date = date; + this.taskTime = taskTime; } public Task getTask() { return task; } - + public ZonedDateTime getDate() { return date; } + + @Nullable + public TaskTime getTaskTime() { + return taskTime; + } } diff --git a/src/main/java/me/playbosswar/com/tasks/Task.java b/src/main/java/me/playbosswar/com/tasks/Task.java index 58c20e0..f9d3b35 100644 --- a/src/main/java/me/playbosswar/com/tasks/Task.java +++ b/src/main/java/me/playbosswar/com/tasks/Task.java @@ -183,6 +183,13 @@ public void setExecutionLimit(int executionLimit) { CommandTimerPlugin.getInstance().getTasksManager().resetScheduleForTask(this); } + public void loadExecutionMetadata(int timesExecuted, Date lastExecuted, int lastExecutedCommandIndex) { + this.timesExecuted = timesExecuted; + this.lastExecuted = lastExecuted; + this.lastExecutedCommandIndex = lastExecutedCommandIndex; + // No storeExecutionMetadata() — we're restoring what's already on disk + } + public int getTimesExecuted() { return timesExecuted; } diff --git a/src/main/java/me/playbosswar/com/tasks/TaskRunner.java b/src/main/java/me/playbosswar/com/tasks/TaskRunner.java index 6218611..2925257 100644 --- a/src/main/java/me/playbosswar/com/tasks/TaskRunner.java +++ b/src/main/java/me/playbosswar/com/tasks/TaskRunner.java @@ -10,7 +10,6 @@ import java.util.ArrayList; import java.util.List; import java.util.TimerTask; -import java.util.stream.Collectors; /** * Runnable responsible for scheduling tasks diff --git a/src/main/java/me/playbosswar/com/tasks/TasksManager.java b/src/main/java/me/playbosswar/com/tasks/TasksManager.java index 494ac50..72c6d5c 100644 --- a/src/main/java/me/playbosswar/com/tasks/TasksManager.java +++ b/src/main/java/me/playbosswar/com/tasks/TasksManager.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.nio.file.Paths; import java.sql.SQLException; +import java.time.Clock; import java.time.LocalDate; import java.time.LocalTime; import java.time.ZoneId; @@ -39,6 +40,15 @@ public class TasksManager { private Thread populateScheduleRunnerThread; public boolean stopRunner = false; public int executionsSinceLastSync = 0; + private Clock clock = Clock.systemDefaultZone(); + + TasksManager() { + // For testing — no plugin loading, no threads + } + + void setClock(Clock clock) { + this.clock = clock; + } public TasksManager(Plugin plugin) { if (plugin.getConfig().getBoolean("database.enabled")) { @@ -186,222 +196,265 @@ public void populateScheduleForTask(Task task) { int executionLimit = task.getExecutionLimit(); int timesExecuted = task.getTimesExecuted(); - long alreadyScheduled = scheduledTasks.stream() - .filter(scheduledTask -> scheduledTask.getTask().getId().equals(task.getId())).count(); + long totalAlreadyScheduled = scheduledTasks.stream() + .filter(st -> st.getTask().getId().equals(task.getId())).count(); - if (alreadyScheduled >= 50) { + if (executionLimit != -1) { + int remaining = executionLimit - timesExecuted - (int) totalAlreadyScheduled; + if (remaining <= 0) { + Messages.sendDebugConsole( + "Task " + task.getName() + " has reached execution limit, skipping scheduling"); + return; + } + } + + ZonedDateTime now = ZonedDateTime.now(clock); + + if (!task.getTimes().isEmpty()) { + for (TaskTime taskTime : task.getTimes()) { + populateScheduleForTaskTime(task, taskTime, now); + } + return; + } + + ZonedDateTime lastExecuted = task.getLastExecuted().toInstant().atZone(ZoneId.systemDefault()); + populateIntervalOnlySchedule(task, lastExecuted, now); + } + + private void populateScheduleForTaskTime(Task task, TaskTime taskTime, ZonedDateTime now) { + long alreadyScheduledForThisTime = scheduledTasks.stream() + .filter(st -> st.getTask().getId().equals(task.getId()) && st.getTaskTime() == taskTime) + .count(); + + if (alreadyScheduledForThisTime >= 50) { Messages.sendDebugConsole( - "Task " + task.getName() + " has already been scheduled 50 times, skipping scheduling"); + "TaskTime for task " + task.getName() + " already has 50 scheduled entries, skipping"); return; } - int maxToSchedule = 50; + int maxToSchedule = 50 - (int) alreadyScheduledForThisTime; + + int executionLimit = task.getExecutionLimit(); if (executionLimit != -1) { - int remaining = executionLimit - timesExecuted - (int) alreadyScheduled; + long totalAlreadyScheduled = scheduledTasks.stream() + .filter(st -> st.getTask().getId().equals(task.getId())).count(); + int remaining = executionLimit - task.getTimesExecuted() - (int) totalAlreadyScheduled; if (remaining <= 0) { - Messages.sendDebugConsole( - "Task " + task.getName() + " has reached execution limit, skipping scheduling"); return; } maxToSchedule = Math.min(maxToSchedule, remaining); } ZonedDateTime latestScheduledDate = scheduledTasks.stream() - .filter(scheduledTask -> scheduledTask.getTask().getId().equals(task.getId())) + .filter(st -> st.getTask().getId().equals(task.getId()) && st.getTaskTime() == taskTime) .map(ScheduledTask::getDate).max(ZonedDateTime::compareTo).orElse(null); - boolean hadNoScheduledTasks = (latestScheduledDate == null); - ZonedDateTime lastExecuted = task.getLastExecuted().toInstant().atZone(ZoneId.systemDefault()); - ZonedDateTime now = ZonedDateTime.now(); if (latestScheduledDate == null) { - latestScheduledDate = lastExecuted; + latestScheduledDate = now; + } else if (latestScheduledDate.isBefore(now)) { + latestScheduledDate = now; + } + + if (taskTime.isRange()) { + if (taskTime.isMinecraftTime()) { + scheduleRangeMinecraftTime(task, taskTime, maxToSchedule, latestScheduledDate); + } else { + scheduleRangeRealTime(task, taskTime, maxToSchedule, latestScheduledDate); + } + } else { + if (taskTime.isMinecraftTime()) { + scheduleFixedMinecraftTime(task, taskTime, maxToSchedule, latestScheduledDate); + } else { + scheduleFixedRealTime(task, taskTime, maxToSchedule, latestScheduledDate); + } } + } - if (maxToSchedule <= 0) { + private void scheduleRangeMinecraftTime(Task task, TaskTime taskTime, int maxToSchedule, + ZonedDateTime latestScheduledDate) { + World world = Bukkit.getWorld(taskTime.getWorld() == null ? "world" : taskTime.getWorld()); + if (world == null) { return; } - if (!task.getTimes().isEmpty()) { - if (latestScheduledDate.isBefore(now)) { - latestScheduledDate = now; - } - List rangeTimes = new ArrayList<>(); - List nonRangeTimes = new ArrayList<>(); - - for (TaskTime taskTime : task.getTimes()) { - if (taskTime.isRange()) { - rangeTimes.add(taskTime); - } else { - nonRangeTimes.add(taskTime); + LocalTime startRange = taskTime.getTime1(); + LocalTime endRange = taskTime.getTime2(); + + LocalTime currentMcTime = Tools.getMinecraftTimeAt(world, ZonedDateTime.now()); + boolean currentlyInWindow = isTimeInRange(currentMcTime, startRange, endRange); + + int mcDay = 0; + boolean firstIteration = true; + while (maxToSchedule > 0) { + ZonedDateTime windowStart; + ZonedDateTime windowEnd; + + if (firstIteration && currentlyInWindow) { + windowStart = ZonedDateTime.now(); + windowEnd = Tools.getNextMinecraftTime(world, endRange, 0); + if (windowEnd.isBefore(windowStart)) { + windowEnd = Tools.getNextMinecraftTime(world, endRange, 1); } - } - - for (TaskTime taskTime : rangeTimes) { - if (taskTime.isMinecraftTime()) { - World world = Bukkit.getWorld(taskTime.getWorld() == null ? "world" : taskTime.getWorld()); - if (world == null) { - continue; - } - - LocalTime startRange = taskTime.getTime1(); - LocalTime endRange = taskTime.getTime2(); - - LocalTime currentMcTime = Tools.getMinecraftTimeAt(world, ZonedDateTime.now()); - boolean currentlyInWindow = isTimeInRange(currentMcTime, startRange, endRange); - - int mcDay = 0; - boolean firstIteration = true; - while (maxToSchedule > 0) { - ZonedDateTime windowStart; - ZonedDateTime windowEnd; - - if (firstIteration && currentlyInWindow) { - windowStart = ZonedDateTime.now(); - windowEnd = Tools.getNextMinecraftTime(world, endRange, 0); - if (windowEnd.isBefore(windowStart)) { - windowEnd = Tools.getNextMinecraftTime(world, endRange, 1); - } - } else { - int dayOffset = (firstIteration && currentlyInWindow) ? 1 : mcDay; - if (firstIteration && !currentlyInWindow) { - dayOffset = 0; - } - windowStart = Tools.getNextMinecraftTime(world, startRange, dayOffset); - windowEnd = Tools.getNextMinecraftTime(world, endRange, dayOffset); - if (windowEnd.isBefore(windowStart)) { - windowEnd = Tools.getNextMinecraftTime(world, endRange, dayOffset + 1); - } - } - firstIteration = false; - - if (!task.getDays().contains(windowStart.toLocalDate().getDayOfWeek())) { - mcDay++; - continue; - } - - if (windowEnd.isBefore(latestScheduledDate)) { - mcDay++; - continue; - } - - ZonedDateTime execTime = windowStart.isBefore(latestScheduledDate) ? latestScheduledDate : windowStart; - long intervalSeconds = task.getInterval().toSeconds(); - if (intervalSeconds <= 0) intervalSeconds = 1; - - while (maxToSchedule > 0 && !execTime.isAfter(windowEnd)) { - if (!execTime.isBefore(latestScheduledDate)) { - scheduledTasks.add(new ScheduledTask(task, execTime)); - maxToSchedule--; - } - execTime = execTime.plusSeconds(intervalSeconds); - } - mcDay++; - } - } else { - LocalTime startRange = taskTime.getTime1(); - LocalTime endRange = taskTime.getTime2(); - - int i = 0; - while (maxToSchedule > 0) { - ZonedDateTime date = ZonedDateTime.of(LocalDate.now(), startRange, ZoneId.systemDefault()) - .plusSeconds(i * task.getInterval().toSeconds()); - if (!task.getDays().contains(date.getDayOfWeek())) { - i++; - continue; - } - - boolean isInRange = Tools.isTimeInRange(date.toLocalTime(), startRange, endRange); - if (!isInRange) { - i++; - continue; - } - - if (date.isBefore(latestScheduledDate)) { - i++; - continue; - } - - scheduledTasks.add(new ScheduledTask(task, date)); - maxToSchedule--; - i++; - } + } else { + int dayOffset = (firstIteration && currentlyInWindow) ? 1 : mcDay; + if (firstIteration && !currentlyInWindow) { + dayOffset = 0; } - } - - if (!nonRangeTimes.isEmpty()) { - List allOccurrences = new ArrayList<>(); - - for (TaskTime taskTime : nonRangeTimes) { - if (taskTime.isMinecraftTime()) { - World world = Bukkit.getWorld(taskTime.getWorld() == null ? "world" : taskTime.getWorld()); - if (world == null) { - continue; - } - - LocalTime time = taskTime.getTime1(); - int i = 0; - int collected = 0; - while (collected < maxToSchedule * 2) { - ZonedDateTime nextMinecraftTime = Tools.getNextMinecraftTime(world, time, i); - if (task.getDays().contains(nextMinecraftTime.getDayOfWeek()) - && !nextMinecraftTime.isBefore(latestScheduledDate)) { - allOccurrences.add(nextMinecraftTime); - collected++; - } - i++; - } - } else { - LocalTime time = taskTime.getTime1(); - int i = 0; - int collected = 0; - while (collected < maxToSchedule * 2) { - ZonedDateTime date = ZonedDateTime.of(LocalDate.now(), time, ZoneId.systemDefault()) - .plusDays(i); - if (task.getDays().contains(date.getDayOfWeek()) - && !date.isBefore(latestScheduledDate)) { - allOccurrences.add(date); - collected++; - } - i++; - } - } + windowStart = Tools.getNextMinecraftTime(world, startRange, dayOffset); + windowEnd = Tools.getNextMinecraftTime(world, endRange, dayOffset); + if (windowEnd.isBefore(windowStart)) { + windowEnd = Tools.getNextMinecraftTime(world, endRange, dayOffset + 1); } - - allOccurrences.sort(ZonedDateTime::compareTo); - - for (ZonedDateTime date : allOccurrences) { - if (maxToSchedule <= 0) { - break; - } - scheduledTasks.add(new ScheduledTask(task, date)); + } + firstIteration = false; + + if (!task.getDays().contains(windowStart.toLocalDate().getDayOfWeek())) { + mcDay++; + continue; + } + + if (windowEnd.isBefore(latestScheduledDate)) { + mcDay++; + continue; + } + + ZonedDateTime execTime = windowStart.isBefore(latestScheduledDate) ? latestScheduledDate : windowStart; + long intervalSeconds = task.getInterval().toSeconds(); + if (intervalSeconds <= 0) intervalSeconds = 1; + + while (maxToSchedule > 0 && !execTime.isAfter(windowEnd)) { + if (execTime.isAfter(latestScheduledDate)) { + scheduledTasks.add(new ScheduledTask(task, execTime, taskTime)); maxToSchedule--; } + execTime = execTime.plusSeconds(intervalSeconds); } - + mcDay++; + } + } + + private void scheduleRangeRealTime(Task task, TaskTime taskTime, int maxToSchedule, + ZonedDateTime latestScheduledDate) { + LocalTime startRange = taskTime.getTime1(); + LocalTime endRange = taskTime.getTime2(); + + int i = 0; + while (maxToSchedule > 0) { + ZonedDateTime date = ZonedDateTime.of(LocalDate.now(clock), startRange, ZoneId.systemDefault()) + .plusSeconds(i * task.getInterval().toSeconds()); + if (!task.getDays().contains(date.getDayOfWeek())) { + i++; + continue; + } + + boolean isInRange = Tools.isTimeInRange(date.toLocalTime(), startRange, endRange); + if (!isInRange) { + i++; + continue; + } + + if (!date.isAfter(latestScheduledDate)) { + i++; + continue; + } + + scheduledTasks.add(new ScheduledTask(task, date, taskTime)); + maxToSchedule--; + i++; + } + } + + private void scheduleFixedMinecraftTime(Task task, TaskTime taskTime, int maxToSchedule, + ZonedDateTime latestScheduledDate) { + World world = Bukkit.getWorld(taskTime.getWorld() == null ? "world" : taskTime.getWorld()); + if (world == null) { return; } + LocalTime time = taskTime.getTime1(); + int i = 0; + int collected = 0; + while (collected < maxToSchedule) { + ZonedDateTime nextMinecraftTime = Tools.getNextMinecraftTime(world, time, i); + if (task.getDays().contains(nextMinecraftTime.getDayOfWeek()) + && nextMinecraftTime.isAfter(latestScheduledDate)) { + scheduledTasks.add(new ScheduledTask(task, nextMinecraftTime, taskTime)); + collected++; + } + i++; + } + } + + private void scheduleFixedRealTime(Task task, TaskTime taskTime, int maxToSchedule, + ZonedDateTime latestScheduledDate) { + LocalTime time = taskTime.getTime1(); + int i = 0; + int collected = 0; + while (collected < maxToSchedule) { + ZonedDateTime date = ZonedDateTime.of(LocalDate.now(clock), time, ZoneId.systemDefault()) + .plusDays(i); + if (task.getDays().contains(date.getDayOfWeek()) + && date.isAfter(latestScheduledDate)) { + scheduledTasks.add(new ScheduledTask(task, date, taskTime)); + collected++; + } + i++; + } + } + + private void populateIntervalOnlySchedule(Task task, ZonedDateTime lastExecuted, ZonedDateTime now) { if (task.getInterval().toSeconds() == 0 && !task.getEvents().isEmpty()) { Messages.sendDebugConsole( "Task " + task.getName() + "has no interval set and uses events, skipping scheduling"); return; } + long alreadyScheduled = scheduledTasks.stream() + .filter(st -> st.getTask().getId().equals(task.getId())).count(); + + if (alreadyScheduled >= 50) { + Messages.sendDebugConsole( + "Task " + task.getName() + " has already been scheduled 50 times, skipping scheduling"); + return; + } + + int maxToSchedule = 50 - (int) alreadyScheduled; + + int executionLimit = task.getExecutionLimit(); + if (executionLimit != -1) { + int remaining = executionLimit - task.getTimesExecuted() - (int) alreadyScheduled; + if (remaining <= 0) { + Messages.sendDebugConsole( + "Task " + task.getName() + " has reached execution limit, skipping scheduling"); + return; + } + maxToSchedule = Math.min(maxToSchedule, remaining); + } + + ZonedDateTime latestScheduledDate = scheduledTasks.stream() + .filter(st -> st.getTask().getId().equals(task.getId())) + .map(ScheduledTask::getDate).max(ZonedDateTime::compareTo).orElse(null); + + boolean hadNoScheduledTasks = (latestScheduledDate == null); + if (latestScheduledDate == null) { + latestScheduledDate = lastExecuted; + } + long intervalSeconds = task.getInterval().toSeconds(); if (intervalSeconds <= 0) intervalSeconds = 1; - + ZonedDateTime nextExpectedExecution = lastExecuted.plusSeconds(intervalSeconds); - + if (hadNoScheduledTasks && nextExpectedExecution.isBefore(now)) { if (task.getDays().contains(nextExpectedExecution.toLocalDate().getDayOfWeek())) { scheduledTasks.add(new ScheduledTask(task, nextExpectedExecution)); maxToSchedule--; } } - + int i = 1; ZonedDateTime startDate = latestScheduledDate; - + while (maxToSchedule > 0) { ZonedDateTime date = startDate.plusSeconds(i * intervalSeconds); if (!task.getDays().contains(date.getDayOfWeek())) { diff --git a/src/main/java/me/playbosswar/com/utils/DatabaseUtils.java b/src/main/java/me/playbosswar/com/utils/DatabaseUtils.java index daffbec..0b386b2 100644 --- a/src/main/java/me/playbosswar/com/utils/DatabaseUtils.java +++ b/src/main/java/me/playbosswar/com/utils/DatabaseUtils.java @@ -13,9 +13,10 @@ public static List getAllTasksFromDatabase() throws SQLException { tasks.forEach(task -> { TaskExecutionMetadata metadata = Files.getOrCreateTaskMetadata(task); if(metadata != null) { - task.setTimesExecuted(metadata.getTimesExecuted()); - task.setLastExecuted(metadata.getLastExecuted()); - task.setLastExecutedCommandIndex(metadata.getLastExecutedCommandIndex()); + task.loadExecutionMetadata( + metadata.getTimesExecuted(), + metadata.getLastExecuted(), + metadata.getLastExecutedCommandIndex()); } }); return tasks; diff --git a/src/main/java/me/playbosswar/com/utils/Files.java b/src/main/java/me/playbosswar/com/utils/Files.java index cb9e047..5960e23 100644 --- a/src/main/java/me/playbosswar/com/utils/Files.java +++ b/src/main/java/me/playbosswar/com/utils/Files.java @@ -256,9 +256,10 @@ public static List deserializeJsonFilesIntoCommandTimers() { TaskExecutionMetadata metadata = getOrCreateTaskMetadata(task); if (metadata != null) { - task.setTimesExecuted(metadata.getTimesExecuted()); - task.setLastExecutedCommandIndex(metadata.getLastExecutedCommandIndex()); - task.setLastExecuted(metadata.getLastExecuted()); + task.loadExecutionMetadata( + metadata.getTimesExecuted(), + metadata.getLastExecuted(), + metadata.getLastExecutedCommandIndex()); } tasks.add(task); diff --git a/src/test/java/me/playbosswar/com/tasks/TasksManagerScheduleTest.java b/src/test/java/me/playbosswar/com/tasks/TasksManagerScheduleTest.java new file mode 100644 index 0000000..e3a8def --- /dev/null +++ b/src/test/java/me/playbosswar/com/tasks/TasksManagerScheduleTest.java @@ -0,0 +1,618 @@ +package me.playbosswar.com.tasks; + +import me.playbosswar.com.CommandTimerPlugin; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.plugin.Plugin; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.lang.reflect.Field; +import java.time.Clock; +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +class TasksManagerScheduleTest { + + // Fixed point: Monday 2024-01-15 at 08:00 UTC + private static final ZoneId ZONE = ZoneId.of("UTC"); + private static final Instant FIXED_INSTANT = ZonedDateTime.of(2024, 1, 15, 8, 0, 0, 0, ZONE).toInstant(); + private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE); + + private TasksManager manager; + + @BeforeAll + static void setUpPlugin() throws Exception { + // Set a mock plugin so Messages static initializer doesn't NPE + CommandTimerPlugin mockPlugin = Mockito.mock(CommandTimerPlugin.class); + FileConfiguration mockConfig = Mockito.mock(FileConfiguration.class); + Mockito.when(mockPlugin.getConfig()).thenReturn(mockConfig); + Mockito.when(mockConfig.getBoolean(Mockito.anyString())).thenReturn(false); + + Field pluginField = CommandTimerPlugin.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(null, mockPlugin); + } + + @AfterAll + static void tearDownPlugin() throws Exception { + Field pluginField = CommandTimerPlugin.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(null, null); + } + + @BeforeEach + void setUp() { + manager = new TasksManager(); + manager.setClock(FIXED_CLOCK); + } + + // ========================= Helpers ========================= + + private static Task createTestTask() { + Task task = new Task(); + setField(task, "id", UUID.randomUUID()); + setField(task, "name", "TestTask"); + setField(task, "active", true); + setField(task, "interval", new TaskInterval(0, 0, 0, 30)); + setField(task, "times", new ArrayList()); + setField(task, "days", allDays()); + setField(task, "executionLimit", -1); + setField(task, "timesExecuted", 0); + setField(task, "lastExecuted", Date.from(FIXED_INSTANT.minusSeconds(300))); + setField(task, "events", new ArrayList<>()); + setField(task, "commands", new ArrayList<>()); + return task; + } + + private static Collection allDays() { + Collection days = new ArrayList<>(); + for (DayOfWeek d : DayOfWeek.values()) { + days.add(d); + } + return days; + } + + private static void setField(Object obj, String fieldName, Object value) { + try { + Field field = obj.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(obj, value); + } catch (Exception e) { + throw new RuntimeException("Failed to set field " + fieldName, e); + } + } + + @SuppressWarnings("unchecked") + private static void addTime(Task task, TaskTime time) { + try { + Field f = Task.class.getDeclaredField("times"); + f.setAccessible(true); + ((Collection) f.get(task)).add(time); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private ZonedDateTime fixedNow() { + return ZonedDateTime.now(FIXED_CLOCK); + } + + private List scheduledFor(Task task) { + return manager.getScheduledTasks().stream() + .filter(st -> st.getTask().getId().equals(task.getId())) + .collect(Collectors.toList()); + } + + // ========================= Tests ========================= + + // --- Interval-only scheduling --- + + @Test + void intervalOnly_schedulesFromLastExecuted() { + Task task = createTestTask(); + // interval=30s, lastExecuted=now-300s, no times + setField(task, "interval", new TaskInterval(0, 0, 0, 30)); + setField(task, "lastExecuted", Date.from(FIXED_INSTANT.minusSeconds(300))); + + manager.populateScheduleForTask(task); + + List scheduled = scheduledFor(task); + assertEquals(50, scheduled.size()); + + // First entry is catch-up at lastExecuted + 30s (in the past) + // Compare instants to avoid timezone mismatch (code uses system default zone) + Instant catchUpInstant = FIXED_INSTANT.minusSeconds(270); + assertTrue(scheduled.stream().anyMatch(st -> st.getDate().toInstant().equals(catchUpInstant)), + "Should have catch-up entry at lastExecuted + interval"); + } + + @Test + void intervalOnly_futureEntriesAreSpacedByInterval() { + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 0, 1, 0)); // 60s + setField(task, "lastExecuted", Date.from(FIXED_INSTANT.minusSeconds(60))); + + manager.populateScheduleForTask(task); + + List scheduled = scheduledFor(task); + // Get entries at or after now + List futureDates = scheduled.stream() + .map(ScheduledTask::getDate) + .filter(d -> !d.isBefore(fixedNow())) + .sorted() + .collect(Collectors.toList()); + + assertTrue(futureDates.size() >= 2, "Should have multiple future entries"); + + // Check spacing between consecutive future entries + for (int i = 1; i < futureDates.size(); i++) { + long gap = java.time.Duration.between(futureDates.get(i - 1), futureDates.get(i)).getSeconds(); + assertEquals(60, gap, "Entries should be 60s apart"); + } + } + + @Test + void intervalOnly_respectsExecutionLimit() { + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 0, 0, 30)); + setField(task, "executionLimit", 5); + setField(task, "timesExecuted", 0); + + manager.populateScheduleForTask(task); + + List scheduled = scheduledFor(task); + assertEquals(5, scheduled.size(), "Should respect execution limit"); + } + + @Test + void intervalOnly_accountsForAlreadyExecuted() { + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 0, 0, 30)); + setField(task, "executionLimit", 10); + setField(task, "timesExecuted", 7); + + manager.populateScheduleForTask(task); + + List scheduled = scheduledFor(task); + assertEquals(3, scheduled.size(), "Should schedule remaining = limit - executed"); + } + + // --- Single fixed time --- + + @Test + void singleFixedTime_schedulesDaily() { + Task task = createTestTask(); + TaskTime time = new TaskTime(LocalTime.of(14, 0), false); + addTime(task, time); + + manager.populateScheduleForTask(task); + + List scheduled = scheduledFor(task); + assertEquals(50, scheduled.size()); + + // All entries should be at 14:00 + for (ScheduledTask st : scheduled) { + assertEquals(LocalTime.of(14, 0), st.getDate().toLocalTime(), + "All entries should be at 14:00"); + } + + // Entries should be on consecutive days + List dates = scheduled.stream() + .map(ScheduledTask::getDate) + .sorted() + .collect(Collectors.toList()); + for (int i = 1; i < dates.size(); i++) { + assertEquals(1, java.time.Duration.between(dates.get(i - 1), dates.get(i)).toDays(), + "Should be on consecutive days"); + } + } + + @Test + void singleFixedTime_firstEntryIsToday() { + Task task = createTestTask(); + // fixedNow is 08:00, so 14:00 today is in the future + TaskTime time = new TaskTime(LocalTime.of(14, 0), false); + addTime(task, time); + + manager.populateScheduleForTask(task); + + List dates = scheduledFor(task).stream() + .map(ScheduledTask::getDate) + .sorted() + .collect(Collectors.toList()); + + assertEquals(fixedNow().toLocalDate(), dates.get(0).toLocalDate(), + "First entry should be today"); + } + + @Test + void singleFixedTime_pastTimeStartsTomorrow() { + Task task = createTestTask(); + // fixedNow is 08:00, so 06:00 today is in the past + TaskTime time = new TaskTime(LocalTime.of(6, 0), false); + addTime(task, time); + + manager.populateScheduleForTask(task); + + List dates = scheduledFor(task).stream() + .map(ScheduledTask::getDate) + .sorted() + .collect(Collectors.toList()); + + assertTrue(dates.get(0).isAfter(fixedNow()), + "All entries should be after now"); + } + + // --- Multiple fixed times (independent budgets) --- + + @Test + void multipleFixedTimes_eachGetsOwnBudget() { + Task task = createTestTask(); + TaskTime time1 = new TaskTime(LocalTime.of(10, 0), false); + TaskTime time2 = new TaskTime(LocalTime.of(14, 0), false); + addTime(task, time1); + addTime(task, time2); + + manager.populateScheduleForTask(task); + + List scheduled = scheduledFor(task); + long count10 = scheduled.stream() + .filter(st -> st.getDate().toLocalTime().equals(LocalTime.of(10, 0))) + .count(); + long count14 = scheduled.stream() + .filter(st -> st.getDate().toLocalTime().equals(LocalTime.of(14, 0))) + .count(); + + assertEquals(50, count10, "10:00 time should get its own 50 slots"); + assertEquals(50, count14, "14:00 time should get its own 50 slots"); + assertEquals(100, scheduled.size(), "Total should be 100 (50+50)"); + } + + @Test + void multipleFixedTimes_eachHasCorrectTaskTimeRef() { + Task task = createTestTask(); + TaskTime time1 = new TaskTime(LocalTime.of(10, 0), false); + TaskTime time2 = new TaskTime(LocalTime.of(14, 0), false); + addTime(task, time1); + addTime(task, time2); + + manager.populateScheduleForTask(task); + + List scheduled = scheduledFor(task); + long ref1 = scheduled.stream().filter(st -> st.getTaskTime() == time1).count(); + long ref2 = scheduled.stream().filter(st -> st.getTaskTime() == time2).count(); + + assertEquals(50, ref1, "time1 entries should reference time1"); + assertEquals(50, ref2, "time2 entries should reference time2"); + } + + // --- Range with interval --- + + @Test + void singleRange_schedulesWithinWindow() { + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 0, 30, 0)); // 30 min + + TaskTime range = new TaskTime(LocalTime.of(10, 0), false); + range.setTime2(LocalTime.of(12, 0)); // 10:00-12:00 range + addTime(task, range); + + manager.populateScheduleForTask(task); + + List scheduled = scheduledFor(task); + assertTrue(scheduled.size() > 0, "Should schedule entries"); + + // All entries must be within the 10:00-12:00 window + for (ScheduledTask st : scheduled) { + LocalTime t = st.getDate().toLocalTime(); + assertTrue(!t.isBefore(LocalTime.of(10, 0)) && !t.isAfter(LocalTime.of(12, 0)), + "Entry at " + t + " should be within 10:00-12:00"); + } + } + + @Test + void singleRange_entriesSpacedByInterval() { + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 0, 30, 0)); // 30 min + + TaskTime range = new TaskTime(LocalTime.of(10, 0), false); + range.setTime2(LocalTime.of(12, 0)); + addTime(task, range); + + manager.populateScheduleForTask(task); + + // Get entries for one specific day + List todayEntries = scheduledFor(task).stream() + .map(ScheduledTask::getDate) + .filter(d -> d.toLocalDate().equals(fixedNow().toLocalDate())) + .sorted() + .collect(Collectors.toList()); + + assertTrue(todayEntries.size() >= 2, "Should have multiple entries today"); + + for (int i = 1; i < todayEntries.size(); i++) { + long gap = java.time.Duration.between(todayEntries.get(i - 1), todayEntries.get(i)).toMinutes(); + assertEquals(30, gap, "Entries should be 30 min apart within the same day"); + } + } + + @Test + void singleRange_fills50Slots() { + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 0, 30, 0)); + + TaskTime range = new TaskTime(LocalTime.of(10, 0), false); + range.setTime2(LocalTime.of(12, 0)); + addTime(task, range); + + manager.populateScheduleForTask(task); + + assertEquals(50, scheduledFor(task).size(), "Should fill all 50 slots"); + } + + // --- Range + fixed time together --- + + @Test + void rangeAndFixedTime_bothGetScheduled() { + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 1, 0, 0)); // 1h interval for range + + TaskTime range = new TaskTime(LocalTime.of(10, 0), false); + range.setTime2(LocalTime.of(12, 0)); // 3 entries/day (10:00, 11:00, 12:00) + addTime(task, range); + + TaskTime fixed = new TaskTime(LocalTime.of(14, 0), false); + addTime(task, fixed); + + manager.populateScheduleForTask(task); + + List scheduled = scheduledFor(task); + long rangeCount = scheduled.stream().filter(st -> st.getTaskTime() == range).count(); + long fixedCount = scheduled.stream().filter(st -> st.getTaskTime() == fixed).count(); + + assertEquals(50, rangeCount, "Range should get 50 slots"); + assertEquals(50, fixedCount, "Fixed should get 50 slots"); + assertEquals(100, scheduled.size(), "Total should be 100"); + } + + // --- No duplicates on repopulation --- + + @Test + void repopulation_doesNotDuplicateFixedTime() { + Task task = createTestTask(); + TaskTime time = new TaskTime(LocalTime.of(14, 0), false); + addTime(task, time); + + manager.populateScheduleForTask(task); + assertEquals(50, scheduledFor(task).size()); + + // Simulate repopulation (as PopulateScheduleRunner does every 10s) + manager.populateScheduleForTask(task); + assertEquals(50, scheduledFor(task).size(), + "Repopulation should NOT add duplicates"); + } + + @Test + void repopulation_doesNotDuplicateInterval() { + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 0, 1, 0)); // 60s + + manager.populateScheduleForTask(task); + int firstCount = scheduledFor(task).size(); + assertEquals(50, firstCount); + + manager.populateScheduleForTask(task); + assertEquals(50, scheduledFor(task).size(), + "Repopulation should NOT add duplicates for interval tasks"); + } + + @Test + void repopulation_doesNotDuplicateRange() { + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 0, 30, 0)); + + TaskTime range = new TaskTime(LocalTime.of(10, 0), false); + range.setTime2(LocalTime.of(12, 0)); + addTime(task, range); + + manager.populateScheduleForTask(task); + assertEquals(50, scheduledFor(task).size()); + + manager.populateScheduleForTask(task); + assertEquals(50, scheduledFor(task).size(), + "Repopulation should NOT add duplicates for range tasks"); + } + + // --- Day of week filtering --- + + @Test + void fixedTime_respectsDayFilter() { + Task task = createTestTask(); + Collection mondayOnly = new ArrayList<>(); + mondayOnly.add(DayOfWeek.MONDAY); + setField(task, "days", mondayOnly); + + TaskTime time = new TaskTime(LocalTime.of(14, 0), false); + addTime(task, time); + + manager.populateScheduleForTask(task); + + List scheduled = scheduledFor(task); + assertTrue(scheduled.size() > 0); + + for (ScheduledTask st : scheduled) { + assertEquals(DayOfWeek.MONDAY, st.getDate().getDayOfWeek(), + "All entries should be on Monday"); + } + } + + @Test + void intervalOnly_respectsDayFilter() { + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 1, 0, 0)); // 1h + // fixedNow is Monday 2024-01-15 08:00 UTC + Collection wednesdayOnly = new ArrayList<>(); + wednesdayOnly.add(DayOfWeek.WEDNESDAY); + setField(task, "days", wednesdayOnly); + + manager.populateScheduleForTask(task); + + List scheduled = scheduledFor(task); + for (ScheduledTask st : scheduled) { + // Catch-up entry may be on a non-matching day, future entries should match + if (!st.getDate().isBefore(fixedNow())) { + assertEquals(DayOfWeek.WEDNESDAY, st.getDate().getDayOfWeek(), + "Future entries should be on Wednesday"); + } + } + } + + @Test + void range_respectsDayFilter() { + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 0, 30, 0)); + Collection tuesdayOnly = new ArrayList<>(); + tuesdayOnly.add(DayOfWeek.TUESDAY); + setField(task, "days", tuesdayOnly); + + TaskTime range = new TaskTime(LocalTime.of(10, 0), false); + range.setTime2(LocalTime.of(12, 0)); + addTime(task, range); + + manager.populateScheduleForTask(task); + + List scheduled = scheduledFor(task); + assertTrue(scheduled.size() > 0); + + for (ScheduledTask st : scheduled) { + assertEquals(DayOfWeek.TUESDAY, st.getDate().getDayOfWeek(), + "All range entries should be on Tuesday"); + } + } + + // --- Inactive task --- + + @Test + void inactiveTask_producesNoSchedule() { + Task task = createTestTask(); + setField(task, "active", false); + + TaskTime time = new TaskTime(LocalTime.of(14, 0), false); + addTime(task, time); + + manager.populateScheduleForTask(task); + + assertEquals(0, scheduledFor(task).size(), + "Inactive task should produce no scheduled entries"); + } + + // --- Execution limit with times --- + + @Test + void fixedTime_respectsExecutionLimit() { + Task task = createTestTask(); + setField(task, "executionLimit", 3); + setField(task, "timesExecuted", 0); + + TaskTime time = new TaskTime(LocalTime.of(14, 0), false); + addTime(task, time); + + manager.populateScheduleForTask(task); + + List scheduled = scheduledFor(task); + assertTrue(scheduled.size() <= 3, + "Should not exceed execution limit, got: " + scheduled.size()); + } + + @Test + void multipleFixedTimes_executionLimitCapsTotal() { + Task task = createTestTask(); + setField(task, "executionLimit", 5); + setField(task, "timesExecuted", 0); + + TaskTime time1 = new TaskTime(LocalTime.of(10, 0), false); + TaskTime time2 = new TaskTime(LocalTime.of(14, 0), false); + addTime(task, time1); + addTime(task, time2); + + manager.populateScheduleForTask(task); + + List scheduled = scheduledFor(task); + assertTrue(scheduled.size() <= 5, + "Total scheduled should not exceed execution limit, got: " + scheduled.size()); + } + + @Test + void executionLimitAlreadyReached_skipsScheduling() { + Task task = createTestTask(); + setField(task, "executionLimit", 10); + setField(task, "timesExecuted", 10); + + TaskTime time = new TaskTime(LocalTime.of(14, 0), false); + addTime(task, time); + + manager.populateScheduleForTask(task); + + assertEquals(0, scheduledFor(task).size(), + "Should not schedule when limit already reached"); + } + + // --- Per-TaskTime budget cap at 50 --- + + @Test + void singleTaskTime_capsAt50() { + Task task = createTestTask(); + TaskTime time = new TaskTime(LocalTime.of(14, 0), false); + addTime(task, time); + + manager.populateScheduleForTask(task); + + long count = scheduledFor(task).stream() + .filter(st -> st.getTaskTime() == time) + .count(); + assertEquals(50, count, "Single TaskTime should cap at 50"); + } + + // --- Edge cases --- + + @Test + void noTimesNoInterval_withEvents_skipsScheduling() { + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 0, 0, 0)); // 0 seconds + Collection events = new ArrayList<>(); + events.add(new Object()); // simulate non-empty events + setField(task, "events", events); + + manager.populateScheduleForTask(task); + + assertEquals(0, scheduledFor(task).size(), + "Should skip scheduling for event-only tasks with no interval"); + } + + @Test + void allEntriesHaveCorrectTaskReference() { + Task task = createTestTask(); + TaskTime time = new TaskTime(LocalTime.of(14, 0), false); + addTime(task, time); + + manager.populateScheduleForTask(task); + + for (ScheduledTask st : scheduledFor(task)) { + assertSame(task, st.getTask(), "ScheduledTask should reference the original task"); + } + } +}