From e311eda49c1f7e0c9d81079fac8a710c38a27a7e Mon Sep 17 00:00:00 2001
From: Teletha
Date: Mon, 7 Oct 2024 16:26:54 +0900
Subject: [PATCH] feat!: provide virtual scheduler with cron
---
src/main/java/kiss/Cron.java | 211 +++++
src/main/java/kiss/Scheduler.java | 365 ++++++++
src/main/java/kiss/Task.java | 77 ++
src/main/java/kiss/Type.java | 84 ++
src/test/java/kiss/CronTest.java | 876 +++++++++++++++++++
src/test/java/kiss/SchedulerTest.java | 203 +++++
src/test/java/kiss/SchedulerTestSupport.java | 300 +++++++
src/test/java/kiss/ShutdownNowTest.java | 110 +++
src/test/java/kiss/ShutdownTest.java | 117 +++
src/test/java/kiss/StressBench.java | 39 +
src/test/java/kiss/TestableScheduler.java | 197 +++++
src/test/java/kiss/ThreadLocalTest.java | 31 +
12 files changed, 2610 insertions(+)
create mode 100644 src/main/java/kiss/Cron.java
create mode 100644 src/main/java/kiss/Scheduler.java
create mode 100644 src/main/java/kiss/Task.java
create mode 100644 src/main/java/kiss/Type.java
create mode 100644 src/test/java/kiss/CronTest.java
create mode 100644 src/test/java/kiss/SchedulerTest.java
create mode 100644 src/test/java/kiss/SchedulerTestSupport.java
create mode 100644 src/test/java/kiss/ShutdownNowTest.java
create mode 100644 src/test/java/kiss/ShutdownTest.java
create mode 100644 src/test/java/kiss/StressBench.java
create mode 100644 src/test/java/kiss/TestableScheduler.java
create mode 100644 src/test/java/kiss/ThreadLocalTest.java
diff --git a/src/main/java/kiss/Cron.java b/src/main/java/kiss/Cron.java
new file mode 100644
index 00000000..027addff
--- /dev/null
+++ b/src/main/java/kiss/Cron.java
@@ -0,0 +1,211 @@
+/*
+ * Copyright (C) 2024 Nameless Production Committee
+ *
+ * Licensed under the MIT License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://opensource.org/licenses/mit-license.php
+ */
+package kiss;
+
+import java.time.DayOfWeek;
+import java.time.YearMonth;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoField;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Represents a single field in a cron expression.
+ */
+class Cron {
+ private static final Pattern FORMAT = Pattern
+ .compile("(?:(?:(\\*)|(\\?|L)) | ([0-9]{1,2}|[a-z]{3,3})(?:(L|W) | -([0-9]{1,2}|[a-z]{3,3}))?)(?:(/|\\#)([0-9]{1,7}))?", Pattern.CASE_INSENSITIVE | Pattern.COMMENTS);
+
+ final Type type;
+
+ /**
+ * [0] - start
+ * [1] - end
+ * [2] - increment
+ * [3] - modifier
+ * [4] - modifierForIncrement
+ */
+ final List parts = new ArrayList();
+
+ /**
+ * Constructs a new Field instance based on the given type and expression.
+ *
+ * @param type The Type of this field.
+ * @param expr The expression string for this field.
+ * @throws IllegalArgumentException if the expression is invalid.
+ */
+ Cron(Type type, String expr) {
+ this.type = type;
+
+ for (String range : expr.split(",")) {
+ Matcher m = FORMAT.matcher(range);
+ if (!m.matches()) error(range);
+
+ String start = m.group(3);
+ String mod = m.group(4);
+ String end = m.group(5);
+ String incmod = m.group(6);
+ String inc = m.group(7);
+
+ int[] part = {-1, -1, -1, 0, 0};
+ if (start != null) {
+ part[0] = type.map(start);
+ part[3] = mod == null ? 0 : mod.charAt(0);
+ if (end != null) {
+ part[1] = type.map(end);
+ part[2] = 1;
+ } else if (inc != null) {
+ part[1] = type.max;
+ } else {
+ part[1] = part[0];
+ }
+ } else if (m.group(1) != null) {
+ part[0] = type.min;
+ part[1] = type.max;
+ part[2] = 1;
+ } else if (m.group(2) != null) {
+ part[3] = m.group(2).charAt(0);
+ } else {
+ error(range);
+ }
+
+ if (inc != null) {
+ part[4] = incmod.charAt(0);
+ part[2] = Integer.parseInt(inc);
+ }
+
+ // validate range
+ if ((part[0] != -1 && part[0] < type.min) || (part[1] != -1 && part[1] > type.max) || (part[0] != -1 && part[1] != -1 && part[0] > part[1])) {
+ error(range);
+ }
+
+ // validate part
+ if (part[3] != 0 && Arrays.binarySearch(type.modifier, part[3]) < 0) {
+ error(String.valueOf((char) part[3]));
+ } else if (part[4] != 0 && Arrays.binarySearch(type.increment, part[4]) < 0) {
+ error(String.valueOf((char) part[4]));
+ }
+ parts.add(part);
+ }
+
+ Collections.sort(parts, (x, y) -> Integer.compare(x[0], y[0]));
+ }
+
+ /**
+ * Checks if the given date matches this field's constraints.
+ *
+ * @param date The LocalDate to check.
+ * @return true if the date matches, false otherwise.
+ */
+ boolean matches(ZonedDateTime date) {
+ for (int[] part : parts) {
+ if (part[3] == 'L') {
+ YearMonth ym = YearMonth.of(date.getYear(), date.getMonth().getValue());
+ if (type.max == 7) {
+ return date.getDayOfWeek() == DayOfWeek.of(part[0]) && date.getDayOfMonth() > (ym.lengthOfMonth() - 7);
+ } else {
+ return date.getDayOfMonth() == (ym.lengthOfMonth() - (part[0] == -1 ? 0 : part[0]));
+ }
+ } else if (part[3] == 'W') {
+ if (date.getDayOfWeek().getValue() <= 5) {
+ if (date.getDayOfMonth() == part[0]) {
+ return true;
+ } else if (date.getDayOfWeek().getValue() == 5) {
+ return date.plusDays(1).getDayOfMonth() == part[0];
+ } else if (date.getDayOfWeek().getValue() == 1) {
+ return date.minusDays(1).getDayOfMonth() == part[0];
+ }
+ }
+ } else if (part[4] == '#') {
+ if (date.getDayOfWeek() == DayOfWeek.of(part[0])) {
+ int num = date.getDayOfMonth() / 7;
+ return part[2] == (date.getDayOfMonth() % 7 == 0 ? num : num + 1);
+ }
+ return false;
+ } else {
+ int value = date.get(type.field);
+ if (part[3] == '?' || (part[0] <= value && value <= part[1] && (value - part[0]) % part[2] == 0)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Finds the next matching value for this field.
+ *
+ * @param date Array containing a single ZonedDateTime to be updated.
+ * @return true if a match was found, false if the field overflowed.
+ */
+ boolean nextMatch(ZonedDateTime[] date) {
+ int value = date[0].get(type.field);
+
+ for (int[] part : parts) {
+ int nextMatch = nextMatch(value, part);
+ if (nextMatch > -1) {
+ if (nextMatch != value) {
+ if (type.field == ChronoField.MONTH_OF_YEAR) {
+ date[0] = date[0].withMonth(nextMatch).withDayOfMonth(1).truncatedTo(ChronoUnit.DAYS);
+ } else {
+ date[0] = date[0].with(type.field, nextMatch).truncatedTo(type.field.getBaseUnit());
+ }
+ }
+ return true;
+ }
+ }
+
+ if (type.field == ChronoField.MONTH_OF_YEAR) {
+ date[0] = date[0].plusYears(1).withMonth(1).withDayOfMonth(1).truncatedTo(ChronoUnit.DAYS);
+ } else {
+ date[0] = date[0].plus(1, type.upper).with(type.field, type.min).truncatedTo(type.field.getBaseUnit());
+ }
+ return false;
+ }
+
+ /**
+ * Finds the next matching value within a single Part.
+ *
+ * @param value The current value.
+ * @param part The Part to match against.
+ * @return The next matching value, or -1 if no match is found.
+ */
+ private int nextMatch(int value, int[] part) {
+ if (value > part[1]) {
+ return -1;
+ }
+ int nextPotential = Math.max(value, part[0]);
+ if (part[2] == 1 || nextPotential == part[0]) {
+ return nextPotential;
+ }
+
+ int remainder = ((nextPotential - part[0]) % part[2]);
+ if (remainder != 0) {
+ nextPotential += part[2] - remainder;
+ }
+
+ return nextPotential <= part[1] ? nextPotential : -1;
+ }
+
+ /**
+ * Throw the invalid format error.
+ *
+ * @param cron
+ * @return
+ */
+ static int error(String cron) {
+ throw new IllegalArgumentException("Invalid format '" + cron + "'");
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/kiss/Scheduler.java b/src/main/java/kiss/Scheduler.java
new file mode 100644
index 00000000..e62a1ef3
--- /dev/null
+++ b/src/main/java/kiss/Scheduler.java
@@ -0,0 +1,365 @@
+/*
+ * Copyright (C) 2023 Nameless Production Committee
+ *
+ * Licensed under the MIT License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://opensource.org/licenses/mit-license.php
+ */
+package kiss;
+
+import static java.util.concurrent.Executors.*;
+
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.AbstractExecutorService;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.DelayQueue;
+import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.RunnableFuture;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.LongUnaryOperator;
+
+/**
+ * A custom scheduler implementation based on the {@link ScheduledExecutorService} interface,
+ * using virtual threads to schedule tasks with specified delays or intervals.
+ *
+ *
+ * This class extends {@link AbstractExecutorService} and implements
+ * {@link ScheduledExecutorService} to provide scheduling capabilities with a task queue and delay
+ * mechanisms. It leverages {@link DelayQueue} to manage task execution times, and uses virtual
+ * threads to run tasks in a lightweight and efficient manner.
+ *
+ *
+ * Core Functionality
+ *
+ * - Allows scheduling of tasks with delays or fixed intervals.
+ * - Supports scheduling based on cron expressions for periodic execution.
+ * - Uses virtual threads to minimize memory consumption and resource overhead.
+ *
+ *
+ * Thread Management
+ *
+ * Virtual threads are created in an "unstarted" state when tasks are registered. Execution is
+ * delayed until the scheduled time, reducing memory usage. Once the scheduled time arrives, the
+ * virtual threads are started and the tasks are executed. If the task is periodic, it is
+ * rescheduled after completion.
+ *
+ *
+ * Usage
+ *
+ * You can use the following methods to schedule tasks:
+ *
+ * - {@link #schedule(Runnable, long, TimeUnit)}: Schedule a task with a delay.
+ * - {@link #scheduleAtFixedRate(Runnable, long, long, TimeUnit)}: Schedule a task at fixed
+ * intervals.
+ * - {@link #scheduleWithFixedDelay(Runnable, long, long, TimeUnit)}: Schedule a task with a fixed
+ * delay between executions.
+ * - {@link #scheduleAt(Runnable, String)}: Schedule a task based on a cron expression.
+ *
+ *
+ *
+ * Task Lifecycle
+ *
+ * The scheduler maintains internal counters to track running tasks and completed tasks using
+ * {@link AtomicLong}. The task queue is managed through {@link DelayQueue}, which ensures tasks
+ * are executed at the correct time. Each task is wrapped in a custom {@link Task} class that
+ * handles execution, cancellation, and rescheduling (for periodic tasks).
+ *
+ *
+ * Shutdown and Termination
+ *
+ * The scheduler can be shut down using the {@link #shutdown()} or {@link #shutdownNow()} methods,
+ * which stops the execution of any further tasks. The {@link #awaitTermination(long, TimeUnit)}
+ * method can be used to block until all tasks are finished executing after a shutdown request.
+ *
+ *
+ * @see ScheduledExecutorService
+ */
+public class Scheduler extends AbstractExecutorService implements ScheduledExecutorService {
+
+ /** The the running task manager. */
+ protected final Set runs = ConcurrentHashMap.newKeySet();
+
+ /** The task queue. */
+ protected DelayQueue queue = new DelayQueue();
+
+ /** The running state of task queue. */
+ private volatile boolean run = true;
+
+ public Scheduler() {
+ Thread.ofVirtual().start(() -> {
+ try {
+ while (run || !queue.isEmpty()) {
+ Task task = queue.take();
+ // Task execution state management is performed before thread execution because
+ // it is too slow if the task execution state management is performed within the
+ // task's execution thread.
+ runs.add(task);
+
+ // execute task actually
+ task.thread.start();
+ }
+ } catch (InterruptedException e) {
+ // stop
+ }
+ });
+ }
+
+ /**
+ * Execute the task.
+ *
+ * @param task
+ */
+ protected Task executeTask(Task> task) {
+ if (!run) {
+ throw new RejectedExecutionException();
+ }
+
+ if (!task.isCancelled()) {
+ // Threads are created when a task is registered, but execution is delayed until the
+ // scheduled time. Although it would be simpler to immediately schedule the task using
+ // Thread#sleep after execution, this implementation method is used to reduce memory
+ // usage as much as possible. Note that only the creation of the thread is done first,
+ // since the information is not inherited by InheritableThreadLocal if the thread is
+ // simply placed in the task queue.
+ task.thread = Thread.ofVirtual().unstarted(() -> {
+ try {
+ if (!task.isCancelled()) {
+ task.run();
+
+ if (task.interval == null || !run) {
+ // one shot or scheduler is already stopped
+ } else {
+ // reschedule task
+ task.next = task.interval.applyAsLong(task.next);
+ executeTask(task);
+ }
+ }
+ } finally {
+ runs.remove(task);
+ }
+ });
+ queue.add(task);
+ }
+
+ return task;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public ScheduledFuture> schedule(Runnable command, long delay, TimeUnit unit) {
+ return schedule(Executors.callable(command), delay, unit);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public ScheduledFuture schedule(Callable command, long delay, TimeUnit unit) {
+ return executeTask(new Task(command, next(delay, unit), null));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public ScheduledFuture> scheduleAtFixedRate(Runnable command, long delay, long interval, TimeUnit unit) {
+ return executeTask(new Task(callable(command), next(delay, unit), old -> old + unit.toMillis(interval)));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public ScheduledFuture> scheduleWithFixedDelay(Runnable command, long delay, long interval, TimeUnit unit) {
+ return executeTask(new Task(callable(command), next(delay, unit), old -> System.currentTimeMillis() + unit.toMillis(interval)));
+ }
+
+ /**
+ * Schedules a task to be executed periodically based on a cron expression.
+ *
+ * This method uses a cron expression to determine the execution intervals for the given
+ * {@code Runnable} command.
+ * It creates a task that calculates the next execution time using the provided cron format. The
+ * task is executed at each calculated interval, and the next execution time is determined
+ * dynamically after each run.
+ *
+ *
+ * @param command The {@code Runnable} task to be scheduled for periodic execution.
+ * @param format A valid cron expression that defines the schedule for task execution.
+ * The cron format is parsed to calculate the next execution time.
+ *
+ * @return A {@code ScheduledFuture>} representing the pending completion of the task.
+ * The {@code ScheduledFuture} can be used to cancel or check the status of the task.
+ *
+ * @throws IllegalArgumentException If the cron format is invalid or cannot be parsed correctly.
+ */
+ public ScheduledFuture> scheduleAt(Runnable command, String format) {
+ Cron[] fields = parse(format);
+ LongUnaryOperator next = old -> next(fields, ZonedDateTime.now()).toInstant().toEpochMilli();
+
+ return executeTask(new Task(callable(command), next.applyAsLong(0L), next));
+ }
+
+ /**
+ * Parses a cron expression into an array of {@link Cron} objects.
+ * The cron expression is expected to have 5 or 6 parts:
+ * - For a standard cron expression with 5 parts (minute, hour, day of month, month, day of
+ * week), the seconds field will be assumed to be "0".
+ * - For a cron expression with 6 parts (second, minute, hour, day of month, month, day of
+ * week), all fields are used directly from the cron expression.
+ *
+ * @param cron the cron expression to parse
+ * @return an array of {@link Cron} objects representing the parsed cron fields.
+ * @throws IllegalArgumentException if the cron expression does not have 5 or 6 parts
+ */
+ static Cron[] parse(String cron) {
+ String[] parts = cron.split("\\s+");
+ int i = parts.length == 5 ? 0 : parts.length == 6 ? 1 : Cron.error(cron);
+
+ return new Cron[] { //
+ new Cron(Type.SECOND, i == 1 ? parts[0] : "0"), new Cron(Type.MINUTE, parts[i++]), new Cron(Type.HOUR, parts[i++]),
+ new Cron(Type.DAY_OF_MONTH, parts[i++]), new Cron(Type.MONTH, parts[i++]), new Cron(Type.DAY_OF_WEEK, parts[i++])};
+ }
+
+ /**
+ * Calculates the next execution time based on the provided cron fields and a base time.
+ *
+ * The search for the next execution time will start from the base time and continue until
+ * a matching time is found. The search will stop if no matching time is found within four
+ * years.
+ *
+ * @param cron an array of {@link Cron} objects representing the parsed cron fields
+ * @param base the {@link ZonedDateTime} representing the base time to start the search from
+ * @return the next execution time as a {@link ZonedDateTime}
+ * @throws IllegalArgumentException if no matching execution time is found within four years
+ */
+ static ZonedDateTime next(Cron[] cron, ZonedDateTime base) {
+ // The range is four years, taking into account leap years.
+ ZonedDateTime limit = base.plusYears(4);
+
+ ZonedDateTime[] next = {base.plusSeconds(1).truncatedTo(ChronoUnit.SECONDS)};
+ root: while (true) {
+ if (next[0].isAfter(limit)) throw new IllegalArgumentException("Next time is not found before " + limit);
+ if (!cron[4].nextMatch(next)) continue;
+
+ int month = next[0].getMonthValue();
+ while (!(cron[3].matches(next[0]) && cron[5].matches(next[0]))) {
+ next[0] = next[0].plusDays(1).truncatedTo(ChronoUnit.DAYS);
+ if (next[0].getMonthValue() != month) continue root;
+ }
+
+ if (!cron[2].nextMatch(next)) continue;
+ if (!cron[1].nextMatch(next)) continue;
+ if (!cron[0].nextMatch(next)) continue;
+ return next[0];
+ }
+ }
+
+ /**
+ * Calculates the next time point by adding the specified delay to the current system time.
+ *
+ * This method takes the current system time (in milliseconds) and adds the provided delay,
+ * which is converted to milliseconds based on the provided {@link TimeUnit}. The result is the
+ * time point (in milliseconds since the Unix epoch) that corresponds to the current time plus
+ * the delay.
+ *
+ * @param delay the delay to add to the current time
+ * @param unit the {@link TimeUnit} representing the unit of the delay (e.g., seconds, minutes)
+ * @return the next time point in milliseconds since the Unix epoch
+ */
+ static long next(long delay, TimeUnit unit) {
+ return System.currentTimeMillis() + unit.toMillis(delay);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void shutdown() {
+ run = false;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public List shutdownNow() {
+ run = false;
+ for (Task run : runs) {
+ run.thread.interrupt();
+ }
+
+ DelayQueue temp = queue;
+ queue = new DelayQueue();
+ return new ArrayList(temp);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean awaitTermination(long time, TimeUnit unit) throws InterruptedException {
+ long end = next(time, unit);
+ while (!isTerminated()) {
+ long rem = end - System.currentTimeMillis();
+ if (rem < 0) {
+ return false;
+ }
+ Thread.sleep(Math.min(rem + 1, 100));
+ }
+ return true;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean isShutdown() {
+ return !run;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean isTerminated() {
+ return !run && queue.isEmpty() && runs.isEmpty();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void execute(Runnable command) {
+ schedule(command, 0, TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected RunnableFuture newTaskFor(Callable callable) {
+ return new Task(callable, 0, null);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected RunnableFuture newTaskFor(Runnable runnable, T value) {
+ return newTaskFor(Executors.callable(runnable, value));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/kiss/Task.java b/src/main/java/kiss/Task.java
new file mode 100644
index 00000000..b99c75c8
--- /dev/null
+++ b/src/main/java/kiss/Task.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2023 Nameless Production Committee
+ *
+ * Licensed under the MIT License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://opensource.org/licenses/mit-license.php
+ */
+package kiss;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.Delayed;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.function.LongUnaryOperator;
+
+class Task extends FutureTask implements ScheduledFuture {
+
+ /** The next trigger time. (epoch ms) */
+ volatile long next;
+
+ /** The interval calculator. */
+ final LongUnaryOperator interval;
+
+ /** The executing thread. */
+ Thread thread;
+
+ /**
+ * Create new task.
+ *
+ * @param task
+ * @param next
+ * @param interval
+ */
+ Task(Callable task, long next, LongUnaryOperator interval) {
+ super(task);
+
+ this.next = next;
+ this.interval = interval;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void run() {
+ if (interval == null) {
+ // one shot
+ super.run();
+ } else {
+ // periodically
+ runAndReset();
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public long getDelay(TimeUnit unit) {
+ return unit.convert(next - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public int compareTo(Delayed other) {
+ if (other instanceof Task task) {
+ return Long.compare(next, task.next);
+ } else {
+ return 0;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/kiss/Type.java b/src/main/java/kiss/Type.java
new file mode 100644
index 00000000..22ba06eb
--- /dev/null
+++ b/src/main/java/kiss/Type.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2024 Nameless Production Committee
+ *
+ * Licensed under the MIT License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://opensource.org/licenses/mit-license.php
+ */
+package kiss;
+
+import static java.time.temporal.ChronoUnit.*;
+
+import java.time.temporal.ChronoField;
+import java.time.temporal.ChronoUnit;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Represents a type of cron field (e.g., second, minute, hour, etc.).
+ */
+class Type {
+ static final Type SECOND = new Type(ChronoField.SECOND_OF_MINUTE, MINUTES, 0, 59, "", "", "/");
+
+ static final Type MINUTE = new Type(ChronoField.MINUTE_OF_HOUR, HOURS, 0, 59, "", "", "/");
+
+ static final Type HOUR = new Type(ChronoField.HOUR_OF_DAY, DAYS, 0, 23, "", "", "/");
+
+ static final Type DAY_OF_MONTH = new Type(ChronoField.DAY_OF_MONTH, MONTHS, 1, 31, "", "?LW", "/");
+
+ static final Type MONTH = new Type(ChronoField.MONTH_OF_YEAR, YEARS, 1, 12, "JANFEBMARAPRMAYJUNJULAUGSEPOCTNOVDEC", "", "/");
+
+ static final Type DAY_OF_WEEK = new Type(ChronoField.DAY_OF_WEEK, null, 1, 7, "MONTUEWEDTHUFRISATSUN", "?L", "#/");
+
+ final ChronoField field;
+
+ final ChronoUnit upper;
+
+ final int min;
+
+ final int max;
+
+ private final List names;
+
+ final int[] modifier;
+
+ final int[] increment;
+
+ /**
+ * Constructs a new Type instance.
+ *
+ * @param field The ChronoField this type represents.
+ * @param upper The upper ChronoUnit for this type.
+ * @param min The minimum allowed value for this type.
+ * @param max The maximum allowed value for this type.
+ * @param names List of string names for this type (e.g., month names).
+ * @param modifier Allowed modifiers for this type.
+ * @param increment Allowed increment modifiers for this type.
+ */
+ private Type(ChronoField field, ChronoUnit upper, int min, int max, String names, String modifier, String increment) {
+ this.field = field;
+ this.upper = upper;
+ this.min = min;
+ this.max = max;
+ this.names = Arrays.asList(names.split("(?<=\\G...)")); // split every three letters
+ this.modifier = modifier.chars().toArray();
+ this.increment = increment.chars().toArray();
+ }
+
+ /**
+ * Maps a string representation to its corresponding numeric value for this field.
+ *
+ * @param name The string representation to map.
+ * @return The corresponding numeric value.
+ */
+ int map(String name) {
+ int index = names.indexOf(name.toUpperCase());
+ if (index != -1) {
+ return index + min;
+ }
+ int value = Integer.parseInt(name);
+ return value == 0 && field == ChronoField.DAY_OF_WEEK ? 7 : value;
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/kiss/CronTest.java b/src/test/java/kiss/CronTest.java
new file mode 100644
index 00000000..8e32f899
--- /dev/null
+++ b/src/test/java/kiss/CronTest.java
@@ -0,0 +1,876 @@
+/*
+ * Copyright (C) 2023 Nameless Production Committee
+ *
+ * Licensed under the MIT License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://opensource.org/licenses/mit-license.php
+ */
+package kiss;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.YearMonth;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.TimeZone;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import kiss.Cron;
+import kiss.Scheduler;
+
+class CronTest {
+
+ TimeZone original;
+
+ ZoneId zoneId;
+
+ @BeforeEach
+ public void setup() {
+ original = TimeZone.getDefault();
+ TimeZone.setDefault(TimeZone.getTimeZone("Europe/Oslo"));
+ zoneId = TimeZone.getDefault().toZoneId();
+ }
+
+ @AfterEach
+ public void cleanup() {
+ TimeZone.setDefault(original);
+ }
+
+ private boolean matches(Cron field, int value) {
+ return field.matches(ZonedDateTime.now().with(field.type.field, value));
+ }
+
+ private boolean matcheAll(Cron field, int... values) {
+ for (int value : values) {
+ assert matches(field, value);
+ }
+ return true;
+ }
+
+ private boolean unmatcheAll(Cron field, int... values) {
+ for (int value : values) {
+ assert matches(field, value) == false;
+ }
+ return true;
+ }
+
+ @Test
+ public void parseNumber() {
+ Cron field = new Cron(Type.MINUTE, "5");
+ assert matches(field, 5);
+ assert unmatcheAll(field, 2, 4, 6, 8, 10, 30, 59);
+ }
+
+ @Test
+ public void parseNumberWithIncrement() {
+ Cron field = new Cron(Type.MINUTE, "0/15");
+ assert matcheAll(field, 0, 15, 30, 45);
+ assert unmatcheAll(field, 1, 2, 3, 4, 6, 7, 8, 9, 11, 12, 20, 33, 59);
+ }
+
+ @Test
+ public void parseRange() {
+ Cron field = new Cron(Type.MINUTE, "5-10");
+ assert matcheAll(field, 5, 6, 7, 8, 9, 10);
+ assert unmatcheAll(field, 1, 2, 3, 4, 11, 12, 30, 59);
+ }
+
+ @Test
+ public void parseRangeWithIncrement() {
+ Cron field = new Cron(Type.MINUTE, "20-30/2");
+ assert matcheAll(field, 20, 22, 24, 26, 28, 30);
+ assert unmatcheAll(field, 18, 19, 21, 23, 25, 27, 29, 31, 32, 59);
+ }
+
+ @Test
+ public void parseAsterisk() {
+ Cron field = new Cron(Type.DAY_OF_WEEK, "*");
+ assert matcheAll(field, 1, 2, 3, 4, 5, 6, 7);
+ }
+
+ @Test
+ public void parseAsteriskWithIncrement() {
+ Cron field = new Cron(Type.DAY_OF_WEEK, "*/2");
+ assert matcheAll(field, 1, 3, 5, 7);
+ assert unmatcheAll(field, 2, 4, 6);
+ }
+
+ @Test
+ public void ignoreFieldInDayOfWeek() {
+ Cron field = new Cron(Type.DAY_OF_WEEK, "?");
+ assert field.matches(ZonedDateTime.now());
+ }
+
+ @Test
+ public void ignoreFieldInDayOfMonth() {
+ Cron field = new Cron(Type.DAY_OF_MONTH, "?");
+ assert field.matches(ZonedDateTime.now());
+ }
+
+ @Test
+ public void giveErrorIfInvalidCountField() {
+ assertThrows(IllegalArgumentException.class, () -> new Parsed("* 3 *"));
+ }
+
+ @Test
+ public void giveErrorIfMinuteFieldIgnored() {
+ assertThrows(IllegalArgumentException.class, () -> {
+ new Cron(Type.MINUTE, "?");
+ });
+ }
+
+ @Test
+ public void giveErrorIfHourFieldIgnored() {
+ assertThrows(IllegalArgumentException.class, () -> {
+ new Cron(Type.HOUR, "?");
+ });
+ }
+
+ @Test
+ public void giveErrorIfMonthFieldIgnored() {
+ assertThrows(IllegalArgumentException.class, () -> {
+ new Cron(Type.MONTH, "?");
+ });
+ }
+
+ @Test
+ public void giveLastDayOfMonthInLeapYear() {
+ Cron field = new Cron(Type.DAY_OF_MONTH, "L");
+ assert field.matches(ZonedDateTime.of(2012, 02, 29, 0, 0, 0, 0, ZoneId.systemDefault()));
+ }
+
+ @Test
+ public void giveLastDayOfMonth() {
+ Cron field = new Cron(Type.DAY_OF_MONTH, "L");
+ YearMonth now = YearMonth.now();
+ assert field.matches(ZonedDateTime.of(now.getYear(), now.getMonthValue(), now.lengthOfMonth(), 0, 0, 0, 0, ZoneId.systemDefault()));
+ }
+
+ @Test
+ public void all() {
+ Parsed cronExpr = new Parsed("* * * * * *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 1, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 2, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 2, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 13, 2, 1, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 59, 59, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 14, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void invalidInput() {
+ assertThrows(NullPointerException.class, () -> new Parsed(null));
+ }
+
+ @Test
+ public void secondNumber() {
+ Parsed cronExpr = new Parsed("3 * * * * *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 1, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 13, 1, 3, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 1, 3, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 13, 2, 3, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 59, 3, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 14, 0, 3, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 23, 59, 3, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 11, 0, 0, 3, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 30, 23, 59, 3, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 5, 1, 0, 0, 3, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void secondIncrement() {
+ Parsed cronExpr = new Parsed("5/15 * * * * *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 5, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 0, 5, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 20, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 0, 20, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 35, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 0, 35, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 50, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 0, 50, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 13, 1, 5, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ // if rolling over minute then reset second (cron rules - increment affects only values in
+ // own field)
+ after = ZonedDateTime.of(2012, 4, 10, 13, 0, 50, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 13, 1, 10, 0, zoneId);
+ assert new Parsed("10/100 * * * * *").next(after).equals(expected);
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 1, 10, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 13, 2, 10, 0, zoneId);
+ assert new Parsed("10/100 * * * * *").next(after).equals(expected);
+ }
+
+ @Test
+ public void secondList() {
+ Parsed cronExpr = new Parsed("7,19 * * * * *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 7, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 0, 7, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 19, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 0, 19, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 13, 1, 7, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void secondRange() {
+ Parsed cronExpr = new Parsed("42-45 * * * * *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 42, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 0, 42, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 43, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 0, 43, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 44, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 0, 44, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 13, 0, 45, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 0, 45, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 13, 1, 42, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void secondInvalidRange() {
+ assertThrows(IllegalArgumentException.class, () -> new Parsed("42-63 * * * * *"));
+ }
+
+ @Test
+ public void secondInvalidIncrementModifier() {
+ assertThrows(IllegalArgumentException.class, () -> new Parsed("42#3 * * * * *"));
+ }
+
+ @Test
+ public void minuteNumber() {
+ Parsed cronExpr = new Parsed("0 3 * * * *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 1, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 13, 3, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 3, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 14, 3, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void minuteIncrement() {
+ Parsed cronExpr = new Parsed("0 0/15 * * * *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 13, 15, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 15, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 13, 30, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 30, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 13, 45, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 45, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 14, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void minuteList() {
+ Parsed cronExpr = new Parsed("0 7,19 * * * *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 13, 7, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 13, 7, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 13, 19, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void hourNumber() {
+ Parsed cronExpr = new Parsed("0 * 3 * * *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 1, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 11, 3, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 11, 3, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 11, 3, 1, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 11, 3, 59, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 12, 3, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void hourIncrement() {
+ Parsed cronExpr = new Parsed("0 * 0/15 * * *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 15, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 15, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 15, 1, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 15, 59, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 11, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 11, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 11, 0, 1, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 11, 15, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 11, 15, 1, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void hourList() {
+ Parsed cronExpr = new Parsed("0 * 7,19 * * *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 10, 19, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 19, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 10, 19, 1, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 10, 19, 59, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 11, 7, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void hourRun25timesInDST_ChangeToWintertime() {
+ Parsed cron = new Parsed("0 1 * * * *");
+ ZonedDateTime start = ZonedDateTime.of(2011, 10, 30, 0, 0, 0, 0, zoneId);
+ ZonedDateTime slutt = start.plusDays(1);
+ ZonedDateTime tid = start;
+
+ // throws: Unsupported unit: Seconds
+ // assertEquals(25, Duration.between(start.toLocalDate(), slutt.toLocalDate()).toHours());
+
+ int count = 0;
+ ZonedDateTime lastTime = tid;
+ while (tid.isBefore(slutt)) {
+ ZonedDateTime nextTime = cron.next(tid);
+ assert nextTime.isAfter(lastTime);
+ lastTime = nextTime;
+ tid = tid.plusHours(1);
+ count++;
+ }
+ assertEquals(25, count);
+ }
+
+ @Test
+ public void hourRun23timesInDST_ChangeToSummertime() {
+ Parsed cron = new Parsed("0 0 * * * *");
+ ZonedDateTime start = ZonedDateTime.of(2011, 03, 27, 1, 0, 0, 0, zoneId);
+ ZonedDateTime slutt = start.plusDays(1);
+ ZonedDateTime tid = start;
+
+ // throws: Unsupported unit: Seconds
+ // assertEquals(23, Duration.between(start.toLocalDate(), slutt.toLocalDate()).toHours());
+
+ int count = 0;
+ ZonedDateTime lastTime = tid;
+ while (tid.isBefore(slutt)) {
+ ZonedDateTime nextTime = cron.next(tid);
+ assert nextTime.isAfter(lastTime);
+ lastTime = nextTime;
+ tid = tid.plusHours(1);
+ count++;
+ }
+ assertEquals(23, count);
+ }
+
+ @Test
+ public void dayOfMonthNumber() {
+ Parsed cronExpr = new Parsed("0 * * 3 * *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 5, 3, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 5, 3, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 5, 3, 0, 1, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 5, 3, 0, 59, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 5, 3, 1, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 5, 3, 23, 59, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 6, 3, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void dayOfMonthIncrement() {
+ Parsed cronExpr = new Parsed("0 0 0 1/15 * *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 16, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 16, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 5, 1, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 30, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 5, 1, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 5, 16, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void dayOfMonthList() {
+ Parsed cronExpr = new Parsed("0 0 0 7,19 * *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 19, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 19, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 5, 7, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 5, 7, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 5, 19, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 5, 30, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 6, 7, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void dayOfMonthLast() {
+ Parsed cronExpr = new Parsed("0 0 0 L * *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 30, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 2, 12, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 2, 29, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void dayOfMonthNumberLast_L() {
+ Parsed cronExpr = new Parsed("0 0 0 3L * *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 10, 13, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 30 - 3, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 2, 12, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 2, 29 - 3, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void dayOfMonthClosestWeekdayW() {
+ Parsed cronExpr = new Parsed("0 0 0 9W * *");
+
+ // 9 - is weekday in may
+ ZonedDateTime after = ZonedDateTime.of(2012, 5, 2, 0, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 5, 9, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ // 9 - is weekday in may
+ after = ZonedDateTime.of(2012, 5, 8, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ // 9 - saturday, friday closest weekday in june
+ after = ZonedDateTime.of(2012, 5, 9, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 6, 8, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ // 9 - sunday, monday closest weekday in september
+ after = ZonedDateTime.of(2012, 9, 1, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 9, 10, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void dayOfMonthInvalidModifier() {
+ assertThrows(IllegalArgumentException.class, () -> new Parsed("0 0 0 9X * *"));
+ }
+
+ @Test
+ public void dayOfMonthInvalidIncrementModifier() {
+ assertThrows(IllegalArgumentException.class, () -> new Parsed("0 0 0 9#2 * *"));
+ }
+
+ @Test
+ public void monthNumber() {
+ ZonedDateTime after = ZonedDateTime.of(2012, 2, 12, 0, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 5, 1, 0, 0, 0, 0, zoneId);
+ assert new Parsed("0 0 0 1 5 *").next(after).equals(expected);
+ }
+
+ @Test
+ public void monthIncrement() {
+ ZonedDateTime after = ZonedDateTime.of(2012, 2, 12, 0, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 5, 1, 0, 0, 0, 0, zoneId);
+ assert new Parsed("0 0 0 1 5/2 *").next(after).equals(expected);
+
+ after = ZonedDateTime.of(2012, 5, 1, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 7, 1, 0, 0, 0, 0, zoneId);
+ assert new Parsed("0 0 0 1 5/2 *").next(after).equals(expected);
+
+ // if rolling over year then reset month field (cron rules - increments only affect own
+ // field)
+ after = ZonedDateTime.of(2012, 5, 1, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2013, 5, 1, 0, 0, 0, 0, zoneId);
+ assert new Parsed("0 0 0 1 5/10 *").next(after).equals(expected);
+ }
+
+ @Test
+ public void monthList() {
+ Parsed cronExpr = new Parsed("0 0 0 1 3,7,12 *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 2, 12, 0, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 3, 1, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 3, 1, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 7, 1, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 7, 1, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 12, 1, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void monthListByName() {
+ Parsed cronExpr = new Parsed("0 0 0 1 MAR,JUL,DEC *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 2, 12, 0, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 3, 1, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 3, 1, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 7, 1, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 7, 1, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 12, 1, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void monthInvalidModifier() {
+ assertThrows(IllegalArgumentException.class, () -> new Parsed("0 0 0 1 ? *"));
+ }
+
+ @Test
+ public void dowNumber() {
+ Parsed cronExpr = new Parsed("0 0 0 * * 3");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 1, 0, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 4, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 4, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 11, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 12, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 18, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 18, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 25, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void dowIncrement() {
+ Parsed cronExpr = new Parsed("0 0 0 * * 3/2");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 1, 0, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 4, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 4, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 6, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 6, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 8, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 8, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 11, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void dowList() {
+ Parsed cronExpr = new Parsed("0 0 0 * * 1,5,7");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 1, 0, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 2, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 2, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 6, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 6, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 8, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void dowListName() {
+ Parsed cronExpr = new Parsed("0 0 0 * * MON,FRI,SUN");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 1, 0, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 2, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 2, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 6, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 6, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 8, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void dowLastFridayInMonth() {
+ Parsed cronExpr = new Parsed("0 0 0 * * 5L");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 1, 1, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 27, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 4, 27, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 5, 25, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 2, 6, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 2, 24, 0, 0, 0, 0, zoneId);
+ assertEquals(expected, cronExpr.next(after));
+
+ after = ZonedDateTime.of(2012, 2, 6, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 2, 24, 0, 0, 0, 0, zoneId);
+ assert new Parsed("0 0 0 * * FRIL").next(after).equals(expected);
+ }
+
+ @Test
+ public void dowInvalidModifier() {
+ assertThrows(IllegalArgumentException.class, () -> new Parsed("0 0 0 * * 5W"));
+ }
+
+ @Test
+ public void dowInvalidIncrementModifier() {
+ assertThrows(IllegalArgumentException.class, () -> new Parsed("0 0 0 * * 5?3"));
+ }
+
+ @Test
+ public void dowInterpret0Sunday() {
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 1, 0, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 8, 0, 0, 0, 0, zoneId);
+ assert new Parsed("0 0 0 * * 0").next(after).equals(expected);
+
+ expected = ZonedDateTime.of(2012, 4, 29, 0, 0, 0, 0, zoneId);
+ assert new Parsed("0 0 0 * * 0L").next(after).equals(expected);
+
+ expected = ZonedDateTime.of(2012, 4, 8, 0, 0, 0, 0, zoneId);
+ assert new Parsed("0 0 0 * * 0#2").next(after).equals(expected);
+ }
+
+ @Test
+ public void dowInterpret7sunday() {
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 1, 0, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 8, 0, 0, 0, 0, zoneId);
+ assert new Parsed("0 0 0 * * 7").next(after).equals(expected);
+
+ expected = ZonedDateTime.of(2012, 4, 29, 0, 0, 0, 0, zoneId);
+ assert new Parsed("0 0 0 * * 7L").next(after).equals(expected);
+
+ expected = ZonedDateTime.of(2012, 4, 8, 0, 0, 0, 0, zoneId);
+ assert new Parsed("0 0 0 * * 7#2").next(after).equals(expected);
+ }
+
+ @Test
+ public void dowNthDayInMonth() {
+ ZonedDateTime after = ZonedDateTime.of(2012, 4, 1, 0, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2012, 4, 20, 0, 0, 0, 0, zoneId);
+ assert new Parsed("0 0 0 * * 5#3").next(after).equals(expected);
+
+ after = ZonedDateTime.of(2012, 4, 20, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 5, 18, 0, 0, 0, 0, zoneId);
+ assert new Parsed("0 0 0 * * 5#3").next(after).equals(expected);
+
+ after = ZonedDateTime.of(2012, 3, 30, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 4, 1, 0, 0, 0, 0, zoneId);
+ assert new Parsed("0 0 0 * * 7#1").next(after).equals(expected);
+
+ after = ZonedDateTime.of(2012, 4, 1, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 5, 6, 0, 0, 0, 0, zoneId);
+ assert new Parsed("0 0 0 * * 7#1").next(after).equals(expected);
+
+ after = ZonedDateTime.of(2012, 2, 6, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 2, 29, 0, 0, 0, 0, zoneId);
+ assert new Parsed("0 0 0 * * 3#5").next(after).equals(expected); // leapday
+
+ after = ZonedDateTime.of(2012, 2, 6, 0, 0, 0, 0, zoneId);
+ expected = ZonedDateTime.of(2012, 2, 29, 0, 0, 0, 0, zoneId);
+ assert new Parsed("0 0 0 * * WED#5").next(after).equals(expected); // leapday
+ }
+
+ @Test
+ public void notSupportRollingPeriod() {
+ assertThrows(IllegalArgumentException.class, () -> new Parsed("* * 5-1 * * *"));
+ }
+
+ @Test
+ public void non_existing_date_throws_exception() {
+ // Will check for the next 4 years - no 30th of February is found so a IAE is thrown.
+ assertThrows(IllegalArgumentException.class, () -> new Parsed("* * * 30 2 *").next(ZonedDateTime.now()));
+ }
+
+ @Test
+ public void defaultBarrier() {
+ Parsed cronExpr = new Parsed("* * * 29 2 *");
+
+ ZonedDateTime after = ZonedDateTime.of(2012, 3, 1, 0, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2016, 2, 29, 0, 0, 0, 0, zoneId);
+ // the default barrier is 4 years - so leap years are considered.
+ assertEquals(expected, cronExpr.next(after));
+ }
+
+ @Test
+ public void withoutSeconds() {
+ ZonedDateTime after = ZonedDateTime.of(2012, 3, 1, 0, 0, 0, 0, zoneId);
+ ZonedDateTime expected = ZonedDateTime.of(2016, 2, 29, 0, 0, 0, 0, zoneId);
+ assert new Parsed("* * 29 2 *").next(after).equals(expected);
+ }
+
+ @Test
+ public void triggerProblemSameMonth() {
+ assertEquals(ZonedDateTime.parse("2020-01-02T00:50:00Z"), new Parsed("00 50 * 1-8 1 *")
+ .next(ZonedDateTime.parse("2020-01-01T23:50:00Z")));
+ }
+
+ @Test
+ public void triggerProblemNextMonth() {
+ assertEquals(ZonedDateTime.parse("2020-02-01T00:50:00Z"), new Parsed("00 50 * 1-8 2 *")
+ .next(ZonedDateTime.parse("2020-01-31T23:50:00Z")));
+ }
+
+ @Test
+ public void triggerProblemNextYear() {
+ assertEquals(ZonedDateTime.parse("2020-01-01T00:50:00Z"), new Parsed("00 50 * 1-8 1 *")
+ .next(ZonedDateTime.parse("2019-12-31T23:50:00Z")));
+ }
+
+ @Test
+ public void triggerProblemNextMonthMonthAst() {
+ assertEquals(ZonedDateTime.parse("2020-02-01T00:50:00Z"), new Parsed("00 50 * 1-8 * *")
+ .next(ZonedDateTime.parse("2020-01-31T23:50:00Z")));
+ }
+
+ @Test
+ public void triggerProblemNextYearMonthAst() {
+ assertEquals(ZonedDateTime.parse("2020-01-01T00:50:00Z"), new Parsed("00 50 * 1-8 * *")
+ .next(ZonedDateTime.parse("2019-12-31T23:50:00Z")));
+ }
+
+ @Test
+ public void triggerProblemNextMonthDayAst() {
+ assertEquals(ZonedDateTime.parse("2020-02-01T00:50:00Z"), new Parsed("00 50 * * 2 *")
+ .next(ZonedDateTime.parse("2020-01-31T23:50:00Z")));
+ }
+
+ @Test
+ public void triggerProblemNextYearDayAst() {
+ assertEquals(ZonedDateTime.parse("2020-01-01T00:50:00Z"), new Parsed("00 50 * * 1 *")
+ .next(ZonedDateTime.parse("2019-12-31T22:50:00Z")));
+ }
+
+ @Test
+ public void triggerProblemNextMonthAllAst() {
+ assertEquals(ZonedDateTime.parse("2020-02-01T00:50:00Z"), new Parsed("00 50 * * * *")
+ .next(ZonedDateTime.parse("2020-01-31T23:50:00Z")));
+ }
+
+ @Test
+ public void triggerProblemNextYearAllAst() {
+ assertEquals(ZonedDateTime.parse("2020-01-01T00:50:00Z"), new Parsed("00 50 * * * *")
+ .next(ZonedDateTime.parse("2019-12-31T23:50:00Z")));
+ }
+
+ private static class Parsed {
+ Cron[] fields;
+
+ Parsed(String format) {
+ fields = Scheduler.parse(format);
+ }
+
+ ZonedDateTime next(ZonedDateTime base) {
+ return Scheduler.next(fields, base);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/kiss/SchedulerTest.java b/src/test/java/kiss/SchedulerTest.java
new file mode 100644
index 00000000..1c1cc7a0
--- /dev/null
+++ b/src/test/java/kiss/SchedulerTest.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright (C) 2023 Nameless Production Committee
+ *
+ * Licensed under the MIT License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://opensource.org/licenses/mit-license.php
+ */
+package kiss;
+
+import java.util.Arrays;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.RepeatedTest;
+import org.junit.jupiter.api.Test;
+
+@SuppressWarnings("resource")
+class SchedulerTest extends SchedulerTestSupport {
+
+ @RepeatedTest(MULTIPLICITY)
+ void execute() {
+ int[] count = {0};
+ scheduler.execute(() -> {
+ count[0] = 1;
+ });
+ assert scheduler.start().awaitIdling();
+ assert count[0] == 1 : Arrays.toString(count) + " " + count[0] + scheduler;
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void submitCallable() {
+ Verifier verifier = new Verifier("OK");
+ Future future = scheduler.submit((Callable) verifier);
+ assert verifyRunning(future);
+ assert scheduler.start().awaitIdling();
+ assert verifySuccessed(future, "OK");
+ assert verifier.verifyExecutionCount(1);
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void submitCallableCancel() {
+ Verifier verifier = new Verifier("OK");
+ Future future = scheduler.submit((Callable) verifier);
+ future.cancel(false);
+ assert scheduler.start().awaitIdling();
+ assert verifyCanceled(future);
+ assert verifier.verifyExecutionCount(0);
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void submitRunnable() {
+ int[] count = {0};
+ Future> future = scheduler.submit((Runnable) () -> count[0]++);
+ assert verifyRunning(future);
+ assert scheduler.start().awaitIdling();
+ assert verifySuccessed(future, null);
+ assert count[0] == 1;
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void submitRunnableCancle() {
+ int[] count = {0};
+ Future> future = scheduler.submit((Runnable) () -> count[0]++);
+ future.cancel(false);
+ assert scheduler.start().awaitIdling();
+ assert verifyCanceled(future);
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void schedule() {
+ Verifier verifier = new Verifier("OK");
+ ScheduledFuture future = scheduler.schedule((Callable) verifier, 50, TimeUnit.MILLISECONDS);
+ assert verifyRunning(future);
+ assert scheduler.start().awaitIdling();
+ assert verifySuccessed(future, "OK");
+ assert verifier.verifyInitialDelay(50);
+ assert verifier.verifyExecutionCount(1);
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void scheduleMultiSameDelay() {
+ Verifier verifier1 = new Verifier("1");
+ Verifier verifier2 = new Verifier("2");
+ Verifier verifier3 = new Verifier("3");
+ ScheduledFuture future1 = scheduler.schedule((Callable) verifier1, 50, TimeUnit.MILLISECONDS);
+ ScheduledFuture future2 = scheduler.schedule((Callable) verifier2, 50, TimeUnit.MILLISECONDS);
+ ScheduledFuture future3 = scheduler.schedule((Callable) verifier3, 50, TimeUnit.MILLISECONDS);
+ assert verifyRunning(future1, future2, future3);
+ assert scheduler.start().awaitIdling();
+ assert verifySuccessed(future1, "1");
+ assert verifySuccessed(future2, "2");
+ assert verifySuccessed(future3, "3");
+ assert verifier1.verifyInitialDelay(50);
+ assert verifier1.verifyExecutionCount(1);
+ assert verifier2.verifyInitialDelay(50);
+ assert verifier2.verifyExecutionCount(1);
+ assert verifier3.verifyInitialDelay(50);
+ assert verifier3.verifyExecutionCount(1);
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void scheduleMultiDifferentDelay() {
+ Verifier verifier1 = new Verifier();
+ Verifier verifier2 = new Verifier();
+ Verifier verifier3 = new Verifier();
+ ScheduledFuture future1 = scheduler.schedule((Callable) verifier1, 500, TimeUnit.MILLISECONDS);
+ ScheduledFuture future2 = scheduler.schedule((Callable) verifier2, 250, TimeUnit.MILLISECONDS);
+ ScheduledFuture future3 = scheduler.schedule((Callable) verifier3, 10, TimeUnit.MILLISECONDS);
+ assert verifyRunning(future1, future2, future3);
+ assert scheduler.start().awaitIdling();
+ assert verifySuccessed(future1, future2, future3);
+ assert verifyExecutionOrder(verifier3, verifier2, verifier1);
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void scheduleCancel() {
+ Verifier verifier = new Verifier("OK");
+ ScheduledFuture future = scheduler.schedule((Callable) verifier, 50, TimeUnit.MILLISECONDS);
+ future.cancel(false);
+ assert scheduler.start().awaitIdling();
+ assert verifyCanceled(future);
+ assert verifier.verifyExecutionCount(0);
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void scheduleTaskAfterCancel() {
+ Verifier verifier = new Verifier("OK");
+ ScheduledFuture future = scheduler.schedule((Callable) verifier, 50, TimeUnit.MILLISECONDS);
+ future.cancel(false);
+ assert scheduler.start().awaitIdling();
+ assert verifyCanceled(future);
+ assert verifier.verifyExecutionCount(0);
+
+ // reschedule
+ ScheduledFuture reFuture = scheduler.schedule((Callable) verifier, 50, TimeUnit.MILLISECONDS);
+ assert verifyRunning(reFuture);
+ assert scheduler.awaitIdling();
+ assert verifySuccessed(reFuture, "OK");
+ assert verifier.verifyExecutionCount(1);
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void fixedRate() {
+ Verifier verifier = new Verifier();
+ ScheduledFuture> future = scheduler.scheduleAtFixedRate(verifier, 0, 50, TimeUnit.MILLISECONDS);
+
+ assert verifyRunning(future);
+ assert scheduler.start().awaitExecutions(3);
+ assert verifier.verifyExecutionCount(3);
+ assert verifier.verifyRate(0, 30, 30);
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void fixedDelay() {
+ Verifier verifier = new Verifier();
+ ScheduledFuture> future = scheduler.scheduleWithFixedDelay(verifier, 0, 50, TimeUnit.MILLISECONDS);
+
+ assert verifyRunning(future);
+ assert scheduler.start().awaitExecutions(3);
+ assert verifier.verifyExecutionCount(3);
+ assert verifier.verifyInterval(0, 50, 50);
+ }
+
+ @Test
+ void cron() {
+ scheduler.limitAwaitTime(5000);
+
+ Verifier verifier = new Verifier();
+ ScheduledFuture> future = scheduler.scheduleAt(verifier, "* * * * * *");
+
+ assert verifyRunning(future);
+ assert scheduler.start().awaitExecutions(3);
+ assert verifier.verifyExecutionCount(3);
+ assert verifier.verifyRate(0, 1000, 1000);
+ }
+
+ @Test
+ void cronStep() {
+ scheduler.limitAwaitTime(5000);
+
+ Verifier verifier = new Verifier();
+ ScheduledFuture> future = scheduler.scheduleAt(verifier, "*/2 * * * * *");
+
+ assert verifyRunning(future);
+ assert scheduler.start().awaitExecutions(2);
+ assert verifier.verifyExecutionCount(2);
+ assert verifier.verifyInterval(0, 2000);
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void handleExceptionDuringTask() {
+ Verifier verifier = new Verifier(new Error("Fail"));
+ ScheduledFuture> future = scheduler.schedule((Callable) verifier, 50, TimeUnit.MILLISECONDS);
+ assert verifyRunning(future);
+ assert scheduler.start().awaitIdling();
+ assert verifyFailed(future);
+ assert verifier.verifyExecutionCount(1);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/kiss/SchedulerTestSupport.java b/src/test/java/kiss/SchedulerTestSupport.java
new file mode 100644
index 00000000..9b7e063b
--- /dev/null
+++ b/src/test/java/kiss/SchedulerTestSupport.java
@@ -0,0 +1,300 @@
+/*
+ * Copyright (C) 2023 Nameless Production Committee
+ *
+ * Licensed under the MIT License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://opensource.org/licenses/mit-license.php
+ */
+package kiss;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.Future;
+import java.util.concurrent.Future.State;
+import java.util.function.Supplier;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+
+import kiss.I;
+import kiss.WiseSupplier;
+
+public class SchedulerTestSupport {
+
+ protected static final int MULTIPLICITY = 5;
+
+ private static final long TOLERANCE = 15;
+
+ protected TestableScheduler scheduler;
+
+ @BeforeEach
+ void setUp() {
+ scheduler = new TestableScheduler();
+
+ assert !Thread.currentThread().isVirtual();
+ }
+
+ @AfterEach
+ void tearDown() throws InterruptedException {
+ scheduler.shutdown();
+ }
+
+ /**
+ * Verify the given {@link Future} is running.
+ *
+ * @param futures
+ * @return
+ */
+ protected boolean verifyRunning(Future... futures) {
+ for (Future future : futures) {
+ assert future.isCancelled() == false;
+ assert future.isDone() == false;
+ assert future.state() == State.RUNNING;
+ }
+ return true;
+ }
+
+ /**
+ * Verify the given {@link Future} is canceled.
+ *
+ * @param futures
+ * @return
+ */
+ protected boolean verifyCanceled(Future... futures) {
+ for (Future future : futures) {
+ assert future.isCancelled() == true;
+ assert future.isDone() == true;
+ assert future.state() == State.CANCELLED;
+ assertThrows(CancellationException.class, () -> future.get());
+ }
+ return true;
+ }
+
+ /**
+ * Verify the given {@link Future} is canceled.
+ *
+ * @param futures
+ * @return
+ */
+ protected boolean verifyFailed(Future... futures) {
+ for (Future future : futures) {
+ assert future.isCancelled() == false;
+ assert future.isDone() == true;
+ assert future.state() == State.FAILED;
+ }
+ return true;
+ }
+
+ /**
+ * Verify the given {@link Future} is canceled.
+ *
+ * @param futures
+ * @return
+ */
+ protected boolean verifySuccessed(Future... futures) {
+ for (Future future : futures) {
+ assert future.isCancelled() == false;
+ assert future.isDone() == true;
+ assert future.state() == State.SUCCESS;
+ }
+ return true;
+ }
+
+ /**
+ * Verify the given {@link Future} is canceled.
+ *
+ * @param future
+ * @return
+ */
+ protected boolean verifySuccessed(Future future, T result) {
+ try {
+ assert future.isCancelled() == false;
+ assert future.isDone() == true;
+ assert future.state() == State.SUCCESS;
+ assert Objects.equals(future.get(), result);
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ /**
+ * Verify that they are executed in the specified order.
+ *
+ * @return
+ */
+ protected boolean verifyExecutionOrder(Verifier>... verifiers) {
+ assert verifyStartExecutionOrder(verifiers);
+ assert verifyEndExecutionOrder(verifiers);
+ return true;
+ }
+
+ /**
+ * Verify that they are executed in the specified order.
+ *
+ * @return
+ */
+ protected boolean verifyStartExecutionOrder(Verifier>... verifiers) {
+ for (int i = 1; i < verifiers.length; i++) {
+ assert verifiers[i - 1].startTime.getFirst() <= verifiers[i].startTime.getFirst();
+ }
+ return true;
+ }
+
+ /**
+ * Verify that they are executed in the specified order.
+ *
+ * @return
+ */
+ protected boolean verifyEndExecutionOrder(Verifier>... verifiers) {
+ for (int i = 1; i < verifiers.length; i++) {
+ assert verifiers[i - 1].endTime.getFirst() <= verifiers[i].endTime.getFirst();
+ }
+ return true;
+ }
+
+ /**
+ * Verifiable {@link Callable} implementation.
+ */
+ protected class Verifier implements Callable, Runnable {
+
+ private final long created = System.currentTimeMillis();
+
+ private final List startTime = new ArrayList();
+
+ private final List endTime = new ArrayList();
+
+ private final Supplier expectedResult;
+
+ private final Throwable expectedError;
+
+ public Verifier() {
+ this((T) "Success");
+ }
+
+ public Verifier(T expectedResult) {
+ this.expectedResult = () -> expectedResult;
+ this.expectedError = null;
+ }
+
+ public Verifier(WiseSupplier expectedResult) {
+ this.expectedResult = expectedResult;
+ this.expectedError = null;
+ }
+
+ public Verifier(Throwable error) {
+ this.expectedResult = null;
+ this.expectedError = error;
+ }
+
+ public Runnable asRunnable() {
+ return this;
+ }
+
+ public Callable asCallable() {
+ return this;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public T call() throws Exception {
+ startTime.add(System.currentTimeMillis());
+ try {
+ if (expectedError != null) {
+ throw I.quiet(expectedError);
+ } else {
+ return expectedResult.get();
+ }
+ } finally {
+ endTime.add(System.currentTimeMillis());
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void run() {
+ try {
+ call();
+ } catch (Exception e) {
+ throw I.quiet(e);
+ }
+ }
+
+ /**
+ * Verify the initial delay.
+ */
+ protected boolean verifyInitialDelay(long millis) {
+ assert !startTime.isEmpty();
+ assert millis - TOLERANCE <= startTime.get(0) - created;
+
+ return true;
+ }
+
+ /**
+ * Verify rate.
+ */
+ protected boolean verifyRate(long... millis) {
+ assert startTime.size() == millis.length;
+ for (int i = 0; i < millis.length; i++) {
+ long diff = startTime.get(i) - (i == 0 ? created : startTime.get(i - 1));
+ assert millis[i] - TOLERANCE <= diff : diff;
+ }
+ return true;
+ }
+
+ /**
+ * Verify interval.
+ */
+ protected boolean verifyInterval(long... millis) {
+ assert startTime.size() == millis.length;
+ for (int i = 0; i < millis.length; i++) {
+ long diff = startTime.get(i) - (i == 0 ? created : endTime.get(i - 1));
+ assert millis[i] - TOLERANCE <= diff : diff;
+ }
+ return true;
+ }
+
+ /**
+ * Verify the execution count.
+ */
+ protected boolean verifyExecutionCount(long beforeAndAfter) {
+ return verifyExecutionCount(beforeAndAfter, beforeAndAfter);
+ }
+
+ /**
+ * Verify the execution count.
+ */
+ protected boolean verifyExecutionCount(long before, long after) {
+ assert startTime.size() == before;
+ assert endTime.size() == after;
+ return true;
+ }
+
+ /**
+ * Verify the execution count.
+ */
+ protected boolean verifyBeforeExecutionCount(long expected) {
+ assert startTime.size() == expected;
+ return true;
+ }
+
+ /**
+ * Verify the execution count.
+ */
+ protected boolean verifyAfterExecutionCount(long expected) {
+ assert endTime.size() == expected;
+ return true;
+ }
+ }
+}
diff --git a/src/test/java/kiss/ShutdownNowTest.java b/src/test/java/kiss/ShutdownNowTest.java
new file mode 100644
index 00000000..5fd94c74
--- /dev/null
+++ b/src/test/java/kiss/ShutdownNowTest.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright (C) 2024 Nameless Production Committee
+ *
+ * Licensed under the MIT License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://opensource.org/licenses/mit-license.php
+ */
+package kiss;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.List;
+import java.util.concurrent.Future;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.RepeatedTest;
+
+@SuppressWarnings("resource")
+public class ShutdownNowTest extends SchedulerTestSupport {
+
+ @RepeatedTest(MULTIPLICITY)
+ void rejectNewTask() {
+ assert scheduler.isShutdown() == false;
+ assert scheduler.isTerminated() == false;
+
+ List remains = scheduler.start().shutdownNow();
+ assert scheduler.isShutdown();
+ assert scheduler.isTerminated();
+ assert remains.isEmpty();
+
+ assertThrows(RejectedExecutionException.class, () -> scheduler.execute(new Verifier()));
+ assertThrows(RejectedExecutionException.class, () -> scheduler.submit(new Verifier().asCallable()));
+ assertThrows(RejectedExecutionException.class, () -> scheduler.submit(new Verifier().asRunnable()));
+ assertThrows(RejectedExecutionException.class, () -> scheduler.schedule(new Verifier().asRunnable(), 10, TimeUnit.SECONDS));
+ assertThrows(RejectedExecutionException.class, () -> scheduler.schedule(new Verifier().asCallable(), 10, TimeUnit.SECONDS));
+ assertThrows(RejectedExecutionException.class, () -> scheduler.scheduleAtFixedRate(new Verifier(), 10, 10, TimeUnit.SECONDS));
+ assertThrows(RejectedExecutionException.class, () -> scheduler.scheduleAtFixedRate(new Verifier(), 10, 10, TimeUnit.SECONDS));
+ assertThrows(RejectedExecutionException.class, () -> scheduler.scheduleAt(new Verifier(), "* * * * *"));
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void processExecutingTask() {
+ Verifier verifier = new Verifier(() -> {
+ try {
+ Thread.sleep(250);
+ return "Long Task";
+ } catch (InterruptedException e) {
+ return "Stop";
+ }
+ });
+
+ Future future = scheduler.submit(verifier.asCallable());
+ assert scheduler.start().awaitRunning();
+
+ List remains = scheduler.shutdownNow();
+ assert remains.isEmpty();
+ assert scheduler.isShutdown();
+ // Although the running task is interrupted and starts moving, the checking #isTerminated
+ // is not performed because it is still uncertain at this moment whether the task is running
+ // to the end or not.
+ // assert scheduler.isTerminated() == false;
+
+ assert scheduler.awaitIdling();
+ assert scheduler.isTerminated();
+ assert verifySuccessed(future, "Stop");
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void processQueuedTask() {
+ Verifier> verifier = new Verifier("Queued");
+
+ Future> future = scheduler.schedule(verifier.asCallable(), 250, TimeUnit.MILLISECONDS);
+ List remains = scheduler.start().shutdownNow();
+ assert scheduler.isShutdown();
+ assert scheduler.isTerminated();
+ assert remains.size() == 1;
+ assert remains.get(0) == future;
+
+ assert scheduler.isTerminated();
+ assert verifyRunning(future);
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void awaitTermination() throws InterruptedException {
+ Verifier verifier = new Verifier(() -> {
+ try {
+ Thread.sleep(150);
+ return "Long Task";
+ } catch (InterruptedException e) {
+ Thread.sleep(100);
+ return "Long Stop";
+ }
+ });
+
+ Future> future = scheduler.submit(verifier.asCallable());
+ assert scheduler.start().awaitRunning();
+
+ List remains = scheduler.shutdownNow();
+ assert scheduler.isShutdown();
+ assert scheduler.isTerminated() == false;
+ assert remains.isEmpty();
+
+ assert scheduler.awaitTermination(300, TimeUnit.MILLISECONDS);
+ assert scheduler.isTerminated();
+ assert verifySuccessed(future);
+ }
+}
diff --git a/src/test/java/kiss/ShutdownTest.java b/src/test/java/kiss/ShutdownTest.java
new file mode 100644
index 00000000..6ea72e6f
--- /dev/null
+++ b/src/test/java/kiss/ShutdownTest.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2024 Nameless Production Committee
+ *
+ * Licensed under the MIT License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://opensource.org/licenses/mit-license.php
+ */
+package kiss;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.concurrent.Future;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.RepeatedTest;
+
+@SuppressWarnings("resource")
+public class ShutdownTest extends SchedulerTestSupport {
+
+ @RepeatedTest(MULTIPLICITY)
+ void rejectNewTask() {
+ assert scheduler.isShutdown() == false;
+ assert scheduler.isTerminated() == false;
+
+ scheduler.start().shutdown();
+ assert scheduler.isShutdown();
+ assert scheduler.isTerminated();
+
+ assertThrows(RejectedExecutionException.class, () -> scheduler.execute(new Verifier()));
+ assertThrows(RejectedExecutionException.class, () -> scheduler.submit(new Verifier().asCallable()));
+ assertThrows(RejectedExecutionException.class, () -> scheduler.submit(new Verifier().asRunnable()));
+ assertThrows(RejectedExecutionException.class, () -> scheduler.schedule(new Verifier().asRunnable(), 10, TimeUnit.SECONDS));
+ assertThrows(RejectedExecutionException.class, () -> scheduler.schedule(new Verifier().asCallable(), 10, TimeUnit.SECONDS));
+ assertThrows(RejectedExecutionException.class, () -> scheduler.scheduleAtFixedRate(new Verifier(), 10, 10, TimeUnit.SECONDS));
+ assertThrows(RejectedExecutionException.class, () -> scheduler.scheduleAtFixedRate(new Verifier(), 10, 10, TimeUnit.SECONDS));
+ assertThrows(RejectedExecutionException.class, () -> scheduler.scheduleAt(new Verifier(), "* * * * *"));
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void processExecutingTask() {
+ Verifier verifier = new Verifier(() -> {
+ try {
+ Thread.sleep(250);
+ return "Long Task";
+ } catch (InterruptedException e) {
+ return "Stop";
+ }
+ });
+
+ Future future = scheduler.submit(verifier.asCallable());
+ scheduler.start().shutdown();
+ assert scheduler.isShutdown();
+ // The result of isTerminated is undefined here because it is not necessarily retrieved from
+ // the queue at this time, although the queued task will certainly be executed in the
+ // future.
+ // assert scheduler.isTerminated() == false;
+
+ assert scheduler.awaitIdling();
+ assert scheduler.isTerminated();
+ assert verifySuccessed(future, "Long Task");
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void processQueuedTask() {
+ Verifier> verifier = new Verifier("Queued");
+
+ Future> future = scheduler.schedule(verifier.asCallable(), 150, TimeUnit.MILLISECONDS);
+ scheduler.start().shutdown();
+ assert scheduler.isShutdown();
+ assert scheduler.isTerminated() == false;
+
+ assert scheduler.awaitIdling();
+ assert scheduler.isTerminated();
+ assert verifySuccessed(future);
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void awaitTermination() throws InterruptedException {
+ Verifier> verifier = new Verifier("Queued");
+
+ Future> future = scheduler.schedule(verifier.asCallable(), 150, TimeUnit.MILLISECONDS);
+ scheduler.start().shutdown();
+ assert scheduler.isShutdown();
+ assert scheduler.isTerminated() == false;
+
+ assert scheduler.awaitTermination(300, TimeUnit.MILLISECONDS);
+ assert scheduler.isTerminated();
+ assert verifySuccessed(future);
+ }
+
+ @RepeatedTest(MULTIPLICITY)
+ void awaitTerminationLongTask() throws InterruptedException {
+ Verifier verifier = new Verifier(() -> {
+ try {
+ Thread.sleep(150);
+ return "Long Task";
+ } catch (InterruptedException e) {
+ Thread.sleep(100);
+ return "Long Stop";
+ }
+ });
+
+ Future> future = scheduler.submit(verifier.asCallable());
+ assert scheduler.start().awaitRunning();
+
+ scheduler.shutdown();
+ assert scheduler.isShutdown();
+ assert scheduler.isTerminated() == false;
+
+ assert scheduler.awaitTermination(300, TimeUnit.MILLISECONDS);
+ assert scheduler.isTerminated();
+ assert verifySuccessed(future);
+ }
+}
diff --git a/src/test/java/kiss/StressBench.java b/src/test/java/kiss/StressBench.java
new file mode 100644
index 00000000..fc0a2188
--- /dev/null
+++ b/src/test/java/kiss/StressBench.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2023 Nameless Production Committee
+ *
+ * Licensed under the MIT License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://opensource.org/licenses/mit-license.php
+ */
+package kiss;
+
+import java.util.Random;
+import java.util.concurrent.TimeUnit;
+
+import kiss.Scheduler;
+
+public class StressBench {
+
+ @SuppressWarnings("resource")
+ public static void main(String args[]) throws Exception {
+ Random random = new Random();
+ Scheduler scheduler = new Scheduler();
+
+ for (int counter = 0; counter < 1000_000; ++counter) {
+ scheduler.schedule(new Job(counter), random.nextLong(5000, 1000 * 90), TimeUnit.MILLISECONDS);
+ }
+
+ scheduler.scheduleAtFixedRate(System::gc, 30, 20, TimeUnit.SECONDS);
+
+ Thread.sleep(1000 * 90);
+ }
+
+ record Job(int id) implements Runnable {
+ @Override
+ public void run() {
+ System.out.println(id);
+ }
+ }
+}
diff --git a/src/test/java/kiss/TestableScheduler.java b/src/test/java/kiss/TestableScheduler.java
new file mode 100644
index 00000000..806b353a
--- /dev/null
+++ b/src/test/java/kiss/TestableScheduler.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright (C) 2023 Nameless Production Committee
+ *
+ * Licensed under the MIT License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://opensource.org/licenses/mit-license.php
+ */
+package kiss;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+
+import kiss.I;
+import kiss.Scheduler;
+
+public class TestableScheduler extends Scheduler {
+
+ private long awaitingLimit = 1000;
+
+ private final AtomicBoolean starting = new AtomicBoolean();
+
+ private List startingBuffer = new ArrayList();
+
+ private AtomicLong executed = new AtomicLong();
+
+ private Runnable wrap(Runnable task) {
+ return () -> {
+ try {
+ task.run();
+ } finally {
+ executed.incrementAndGet();
+ }
+ };
+ }
+
+ private Callable wrap(Callable task) {
+ return () -> {
+ try {
+ return task.call();
+ } finally {
+ executed.incrementAndGet();
+ }
+ };
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public ScheduledFuture> schedule(Runnable command, long delay, TimeUnit unit) {
+ return super.schedule(wrap(command), delay, unit);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public ScheduledFuture schedule(Callable command, long delay, TimeUnit unit) {
+ return super.schedule(wrap(command), delay, unit);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public ScheduledFuture> scheduleAtFixedRate(Runnable command, long delay, long interval, TimeUnit unit) {
+ return super.scheduleAtFixedRate(wrap(command), delay, interval, unit);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public ScheduledFuture> scheduleWithFixedDelay(Runnable command, long delay, long interval, TimeUnit unit) {
+ return super.scheduleWithFixedDelay(wrap(command), delay, interval, unit);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public ScheduledFuture> scheduleAt(Runnable command, String format) {
+ return super.scheduleAt(wrap(command), format);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected Task executeTask(Task task) {
+ if (starting.get()) {
+ super.executeTask(task);
+ } else {
+ startingBuffer.add(task);
+ }
+ return task;
+ }
+
+ /**
+ * Start task handler thread.
+ *
+ * @return
+ */
+ protected final TestableScheduler start() {
+ if (starting.compareAndSet(false, true)) {
+ for (Task task : startingBuffer) {
+ super.executeTask(task);
+ }
+ startingBuffer.clear();
+ }
+ return this;
+ }
+
+ /**
+ * Await any task is running.
+ */
+ protected boolean awaitRunning() {
+ int count = 0; // await at least once
+ long start = System.currentTimeMillis();
+ while (count++ == 0 || runs.isEmpty()) {
+ try {
+ Thread.sleep(3);
+ } catch (InterruptedException e) {
+ throw I.quiet(e);
+ }
+
+ if (awaitingLimit <= System.currentTimeMillis() - start) {
+ throw new Error("No task is active. " + this);
+ }
+ }
+ return true;
+ }
+
+ protected TestableScheduler limitAwaitTime(long millis) {
+ awaitingLimit = millis;
+ return this;
+ }
+
+ /**
+ * Await all tasks are executed.
+ */
+ protected boolean awaitIdling() {
+ int count = 0; // await at least once
+ long start = System.currentTimeMillis();
+
+ while (count++ == 0 || !queue.isEmpty() || !runs.isEmpty()) {
+ try {
+ Thread.sleep(3);
+ } catch (InterruptedException e) {
+ throw I.quiet(e);
+ }
+
+ if (awaitingLimit <= System.currentTimeMillis() - start) {
+ throw new Error("Too long task is active. " + this);
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Awaits until the specified number of tasks have been executed.
+ *
+ * @param required
+ * @return
+ */
+ protected boolean awaitExecutions(long required) {
+ long start = System.currentTimeMillis();
+
+ while (executed.get() < required) {
+ try {
+ Thread.sleep(10);
+ } catch (InterruptedException e) {
+ throw I.quiet(e);
+ }
+
+ if (awaitingLimit <= System.currentTimeMillis() - start) {
+ throw new Error("Too long task is active. " + this);
+ }
+ }
+ return true;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public String toString() {
+ return "Executor [running: " + runs.size() + " executed: " + executed + " queue: " + queue + "]";
+ }
+}
diff --git a/src/test/java/kiss/ThreadLocalTest.java b/src/test/java/kiss/ThreadLocalTest.java
new file mode 100644
index 00000000..4b7ed63e
--- /dev/null
+++ b/src/test/java/kiss/ThreadLocalTest.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2023 Nameless Production Committee
+ *
+ * Licensed under the MIT License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://opensource.org/licenses/mit-license.php
+ */
+package kiss;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+
+import org.junit.jupiter.api.Test;
+
+@SuppressWarnings("resource")
+class ThreadLocalTest extends SchedulerTestSupport {
+
+ @Test
+ void inherit() {
+ InheritableThreadLocal local = new InheritableThreadLocal();
+ local.set("ROOT");
+
+ Verifier verifier = new Verifier(() -> local.get());
+ Future future = scheduler.submit((Callable) verifier);
+
+ assert scheduler.start().awaitIdling();
+ assert verifySuccessed(future, "ROOT");
+ }
+}