From f1d0a02e72e9036449e399bf525ae16968788a34 Mon Sep 17 00:00:00 2001 From: LambdAurora Date: Tue, 12 Nov 2024 23:51:40 +0100 Subject: [PATCH] Add more tests, fix createWithPhases, add some docs. --- .../java/dev/yumi/commons/event/Event.java | 2 +- .../dev/yumi/commons/event/EventManager.java | 159 ++++++++++++++++-- .../dev/yumi/commons/event/FilteredEvent.java | 115 ++++++++++++- .../yumi/commons/event/InvokableEvent.java | 8 + .../dev/yumi/commons/event/InvokerSubset.java | 18 +- .../commons/event/test/EventCreationTest.java | 5 + .../commons/event/test/ExecutionTester.java | 27 ++- .../commons/event/test/FilteredEventTest.java | 57 +++++-- 8 files changed, 351 insertions(+), 40 deletions(-) diff --git a/libraries/event/src/main/java/dev/yumi/commons/event/Event.java b/libraries/event/src/main/java/dev/yumi/commons/event/Event.java index f7d7d71..86c950f 100644 --- a/libraries/event/src/main/java/dev/yumi/commons/event/Event.java +++ b/libraries/event/src/main/java/dev/yumi/commons/event/Event.java @@ -116,7 +116,7 @@ * } * * @param the phase identifier type - * @param the type of the invoker used to execute an event and the type of the listeners + * @param the type of the listeners, and the type of the invoker used to execute an event * @version 1.0.0 * @since 1.0.0 */ diff --git a/libraries/event/src/main/java/dev/yumi/commons/event/EventManager.java b/libraries/event/src/main/java/dev/yumi/commons/event/EventManager.java index 2bb7f70..75608fd 100644 --- a/libraries/event/src/main/java/dev/yumi/commons/event/EventManager.java +++ b/libraries/event/src/main/java/dev/yumi/commons/event/EventManager.java @@ -36,6 +36,7 @@ import java.util.HashMap; import java.util.Map; import java.util.function.Function; +import java.util.function.Supplier; /** * Represents an {@link Event} manager. @@ -171,7 +172,7 @@ public EventManager(@NotNull I defaultPhaseId, @NotNull Function phas * * @param invokerFactory the factory which generates an invoker implementation using an array of listeners * @param defaultPhases the default phases of this event, in the correct order. - * Must contain {@link EventManager#getDefaultPhaseId() the default phase identifier} + * Must contain {@link EventManager#defaultPhaseId() the default phase identifier} * @param the type of the invoker executed by the event * @return a new event instance * @see #create(Class) @@ -207,7 +208,7 @@ public EventManager(@NotNull I defaultPhaseId, @NotNull Function phas * * @param type the class representing the type of the invoker that is executed by the event * @param defaultPhases the default phases of this event, in the correct order. - * Must contain {@link EventManager#getDefaultPhaseId() the default phase identifier} + * Must contain {@link EventManager#defaultPhaseId() the default phase identifier} * @param the type of the invoker executed by the event * @return a new event instance * @see #create(Class) @@ -242,7 +243,7 @@ public EventManager(@NotNull I defaultPhaseId, @NotNull Function phas * @param type the class representing the type of the invoker that is executed by the event * @param implementation a function which generates an invoker implementation using an array of listeners * @param defaultPhases the default phases of this event, in the correct order. - * Must contain {@link EventManager#getDefaultPhaseId() the default phase identifier} + * Must contain {@link EventManager#defaultPhaseId() the default phase identifier} * @param the type of the invoker executed by the event * @return a new event instance * @see #create(Class) @@ -258,28 +259,130 @@ public EventManager(@NotNull I defaultPhaseId, @NotNull Function phas @NotNull Function implementation, @NotNull I... defaultPhases ) { - this.ensureContainsDefaultPhase(defaultPhases); - YumiAssertions.ensureNoDuplicates(defaultPhases, id -> new IllegalArgumentException("Duplicate event phase: " + id)); - - var event = this.create(type, implementation); + return this.createWithPhases(() -> new Event<>(type, this.defaultPhaseId, implementation), defaultPhases); + } - for (int i = 1; i < defaultPhases.length; ++i) { - event.addPhaseOrdering(defaultPhases[i - 1], defaultPhases[i]); - } + public @NotNull FilteredEvent createFiltered(@NotNull Class type, @NotNull Class contextType) { + return this.createFiltered(type, contextType, new DefaultInvokerFactory<>(type)); + } + public @NotNull FilteredEvent createFiltered( + @NotNull Class type, + @NotNull Class contextType, + @NotNull Function implementation + ) { + var event = new FilteredEvent(type, this.defaultPhaseId, implementation); this.creationEvent.invoker().onEventCreation(this, event); - return event; } - public @NotNull FilteredEvent createFiltered(@NotNull Class type) { - return this.createFiltered(type, new DefaultInvokerFactory<>(type)); + /** + * Create a new instance of {@link Event} with a list of default phases that get invoked in order. + * Exposing the identifiers of the default phases as {@code public static final} constants is encouraged. + *

+ * An event phase is a named group of listeners, which may be ordered before or after other groups of listeners. + * This allows some listeners to take priority over other listeners. + * Adding separate events should be considered before making use of multiple event phases. + *

+ * Phases may be freely added to events created with any of the factory functions, + * however using this function is preferred for widely used event phases. + * If more phases are necessary, discussion with the author of the event is encouraged. + *

+ * Refer to {@link Event#addPhaseOrdering} for an explanation of event phases. + * + * @param invokerFactory the factory which generates an invoker implementation using an array of listeners + * @param defaultPhases the default phases of this event, in the correct order. + * Must contain {@link EventManager#defaultPhaseId() the default phase identifier} + * @param the type of the invoker executed by the event + * @return a new event instance + * @see #create(Class) + * @see #create(InvokerFactory) + * @see #create(Class, Function) + * @see #create(Class, Object, Function) + * @see #createWithPhases(Class, Comparable[]) + * @see #createWithPhases(InvokerFactory, Comparable[]) + */ + @SuppressWarnings("unchecked") + public @NotNull FilteredEvent createFilteredWithPhases( + @NotNull Class contextType, + @NotNull InvokerFactory invokerFactory, + @NotNull I... defaultPhases + ) { + return this.createFilteredWithPhases(invokerFactory.type(), contextType, invokerFactory, defaultPhases); } - public @NotNull FilteredEvent createFiltered(@NotNull Class type, @NotNull Function implementation) { - var event = new FilteredEvent(type, this.defaultPhaseId, implementation); - this.creationEvent.invoker().onEventCreation(this, event); - return event; + /** + * Create a new instance of {@link Event} with a list of default phases that get invoked in order. + * Exposing the identifiers of the default phases as {@code public static final} constants is encouraged. + *

+ * An event phase is a named group of listeners, which may be ordered before or after other groups of listeners. + * This allows some listeners to take priority over other listeners. + * Adding separate events should be considered before making use of multiple event phases. + *

+ * Phases may be freely added to events created with any of the factory functions, + * however using this function is preferred for widely used event phases. + * If more phases are necessary, discussion with the author of the event is encouraged. + *

+ * Refer to {@link Event#addPhaseOrdering} for an explanation of event phases. + *

+ * This method uses an automatically generated invoker implementation. + * + * @param type the class representing the type of the invoker that is executed by the event + * @param defaultPhases the default phases of this event, in the correct order. + * Must contain {@link EventManager#defaultPhaseId() the default phase identifier} + * @param the type of the invoker executed by the event + * @return a new event instance + * @see #create(Class) + * @see #create(InvokerFactory) + * @see #create(Class, Function) + * @see #create(Class, Object, Function) + * @see #createWithPhases(InvokerFactory, Comparable[]) + * @see #createWithPhases(Class, Function, Comparable[]) + */ + @SuppressWarnings("unchecked") + public @NotNull FilteredEvent createFilteredWithPhases( + @NotNull Class type, + @NotNull Class contextType, + @NotNull I... defaultPhases + ) { + return this.createFilteredWithPhases(type, contextType, new DefaultInvokerFactory<>(type), defaultPhases); + } + + /** + * Create a new instance of {@link Event} with a list of default phases that get invoked in order. + * Exposing the identifiers of the default phases as {@code public static final} constants is encouraged. + *

+ * An event phase is a named group of listeners, which may be ordered before or after other groups of listeners. + * This allows some listeners to take priority over other listeners. + * Adding separate events should be considered before making use of multiple event phases. + *

+ * Phases may be freely added to events created with any of the factory functions, + * however using this function is preferred for widely used event phases. + * If more phases are necessary, discussion with the author of the event is encouraged. + *

+ * Refer to {@link Event#addPhaseOrdering} for an explanation of event phases. + * + * @param type the class representing the type of the invoker that is executed by the event + * @param implementation a function which generates an invoker implementation using an array of listeners + * @param defaultPhases the default phases of this event, in the correct order. + * Must contain {@link EventManager#defaultPhaseId() the default phase identifier} + * @param the type of the invoker executed by the event + * @return a new event instance + * @see #create(Class) + * @see #create(InvokerFactory) + * @see #create(Class, Function) + * @see #create(Class, Object, Function) + * @see #createWithPhases(Class, Comparable[]) + * @see #createWithPhases(InvokerFactory, Comparable[]) + */ + @SafeVarargs + public final @NotNull FilteredEvent createFilteredWithPhases( + @NotNull Class type, + @NotNull Class contextType, + @NotNull Function implementation, + @NotNull I... defaultPhases + ) { + return this.createWithPhases(() -> new FilteredEvent<>(type, this.defaultPhaseId, implementation), defaultPhases); } /** @@ -327,7 +430,7 @@ public final void listenAll(Object listener, Event... events) { * {@return the default phase identifier of all the events created by this event manager} */ @Contract(pure = true) - public @NotNull I getDefaultPhaseId() { + public @NotNull I defaultPhaseId() { return this.defaultPhaseId; } @@ -339,6 +442,26 @@ public final void listenAll(Object listener, Event... events) { return this.creationEvent; } + /* Implementation */ + + private > E createWithPhases( + Supplier eventSupplier, + @NotNull I... defaultPhases + ) { + this.ensureContainsDefaultPhase(defaultPhases); + YumiAssertions.ensureNoDuplicates(defaultPhases, id -> new IllegalArgumentException("Duplicate event phase: " + id)); + + var event = eventSupplier.get(); + + for (int i = 1; i < defaultPhases.length; i++) { + event.addPhaseOrdering(defaultPhases[i - 1], defaultPhases[i]); + } + + this.creationEvent.invoker().onEventCreation(this, event); + + return event; + } + private Map, I> getListenedPhases(Class listenerClass) { var map = new HashMap, I>(); diff --git a/libraries/event/src/main/java/dev/yumi/commons/event/FilteredEvent.java b/libraries/event/src/main/java/dev/yumi/commons/event/FilteredEvent.java index 60ff772..3349b9e 100644 --- a/libraries/event/src/main/java/dev/yumi/commons/event/FilteredEvent.java +++ b/libraries/event/src/main/java/dev/yumi/commons/event/FilteredEvent.java @@ -21,11 +21,96 @@ import java.util.function.Function; import java.util.function.Predicate; +/** + * Represents an {@linkplain Event event} which can filter its listeners given an invocation context. + *

+ * This type of event will respect the same rules and assumptions as regular events. + * + *

Example: Registering filtered listeners

+ *

+ * Similar to how you would register a listener in a regular event, you pass an instance of {@code T} into {@link #register}. + * Using the same {@link #register(Object) register} methods from {@link Event} will result in registering a global listener + * which will be invoked no matter the context. + * To make your listener context-specific you need to add a predicate given a context of type {@code C}. + * + *

{@code
+ * // Events are created and managed by an EventManager.
+ * // They are given a type for the phase identifiers and the default phase identifier.
+ * static final EventManager EVENT_MANAGER = new EventManager("default");
+ *
+ * // Events should use a dedicated functional interface for T rather than overloading multiple events to the same type
+ * // to allow those who implement using a class to implement multiple events.
+ * @FunctionalInterface
+ * public interface Example {
+ *     void doSomething();
+ * }
+ *
+ * // Filtered events also have an invocation context.
+ * public record EventContext(String value) {}
+ *
+ * // You can also return this instance of Event from a method, may be useful where a parameter is needed to get
+ * // the right instance of Event.
+ * public static final Event EXAMPLE = EVENT_MANAGER.create(Example.class);
+ *
+ * public void registerEvents() {
+ *     // Since T is a functional interface, we can use the lambda form.
+ *     EXAMPLE.register(() -> {
+ *         // Do something
+ *     }, context -> context.value().equals("test"));
+ *
+ *     // Or we can use a method reference.
+ *     EXAMPLE.register(this::runSomething, context -> context.value().equals("some other context"));
+ *
+ *     // Or implement T using a class.
+ *     // You can also use an anonymous class here; for brevity that is not included.
+ *     EXAMPLE.register(new ImplementedOntoClass()); // This is a global listener.
+ * }
+ *
+ * public void runSomething() {
+ *     // Do something else
+ * }
+ *
+ * // When implementing onto a class, the class must implement the same type as the event invoker.
+ * class ImplementedOntoClass implements Example {
+ *     public void doSomething() {
+ *         // Do something else again
+ *     }
+ * }
+ * }
+ * + *

Example: Executing a filtered event

+ *

+ * While you could execute a filtered event the same way a regular event is executed, + * it would only invoke global listeners due to it not being aware of a context. + * Executing a filtered event is done by first creating a subset of listeners from the event using {@link #forContext(Object)} for a given context, + * then calling a method on the event invoker. Where {@code T} is Example, executing a filtered event + * is done through the following: + * + *

{@code
+ * InvokerSubset subset = EXAMPLE.forContext(new EventContext("test"));
+ *
+ * // Invoke the listeners relevant to the given context.
+ * subset.invoker().doSomething();
+ * }
+ *

+ * This architecture has advantages only if the subset is stored and only re-created if the context is different. + * Otherwise, a regular {@linkplain Event event} would do just fine. + * + * @param the phase identifier type + * @param the type of the listeners, and the type of the invoker used to execute an event + * @param the type of the context used to filter out which listeners should be invoked + * @author LambdAurora + * @version 1.0.0 + * @since 1.0.0 + */ public final class FilteredEvent, T, C> extends Event { /** * Reference queue for cleared invoker subsets. */ private final ReferenceQueue> queue = new ReferenceQueue<>(); + /** + * A cache of currently alive invoker subsets. + */ private final Set>> subsets = new HashSet<>(); FilteredEvent(@NotNull Class type, @NotNull I defaultPhaseId, @NotNull Function invokerFactory) { @@ -63,12 +148,20 @@ public void register(@NotNull I phaseIdentifier, @NotNull T listener, @NotNull P this.lock.lock(); try { this.getOrCreatePhase(phaseIdentifier, true).addListener(new Listener<>(listener, filter)); - this.rebuildInvoker(this.listeners.length + 1); + this.rebuildInvoker(this.listeners.length); } finally { this.lock.unlock(); } } + /** + * Creates an invoker for a subset of listeners that matches the given context. + *

+ * Each subset will be dynamically updated if new listeners which match the given context are registered to this event. + * + * @param context the current context + * @return an invoker for a subset of listeners + */ public InvokerSubset forContext(C context) { var subset = new InvokerSubset<>(this, context); @@ -83,7 +176,10 @@ public InvokerSubset forContext(C context) { return subset; } - @SuppressWarnings("unchecked") + // This may be marked as redundant in some IDEs, but javac is clear: there is an unchecked warning here. + // Though, that warning is very weird: the generics of FilteredPhaseData match this event's generics + // while the generics in the phase fields also match them. + @SuppressWarnings({"unchecked", "RedundantSuppression"}) @Override FilteredPhaseData getOrCreatePhase(@NotNull I id, boolean sortIfCreate) { var phase = this.phases.get(id); @@ -116,7 +212,6 @@ void rebuildInvoker(int newLength) { } } - @SuppressWarnings("unchecked") private void purge() { for (var ref = this.queue.poll(); ref != null; ref = this.queue.poll()) { //noinspection SuspiciousMethodCalls @@ -147,7 +242,21 @@ void addListener(@NotNull Listener listener) { } } + /** + * Represents a listener. + * + * @param listener the listener itself + * @param selector the selector of this listener, may be null if this listener is global + * @param the type of listener + * @param the type of filtering context + */ + @ApiStatus.Internal record Listener(T listener, @Nullable Predicate selector) { + /** + * {@return {@code true} if this listener should listen to the given context, or {@code false} otherwise} + * + * @param context the filtering context + */ boolean shouldListen(C context) { return this.selector == null || this.selector.test(context); } diff --git a/libraries/event/src/main/java/dev/yumi/commons/event/InvokableEvent.java b/libraries/event/src/main/java/dev/yumi/commons/event/InvokableEvent.java index 3ca39af..4503b4a 100644 --- a/libraries/event/src/main/java/dev/yumi/commons/event/InvokableEvent.java +++ b/libraries/event/src/main/java/dev/yumi/commons/event/InvokableEvent.java @@ -11,6 +11,14 @@ import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; +/** + * Represents an event or event-like object which can be invoked by the same method as their listeners. + * + * @param the type of the listeners, and the type of the invoker used to execute an event + * @author LambdAurora + * @version 1.0.0 + * @since 1.0.0 + */ public interface InvokableEvent { /** * {@return the invoker instance used to execute this event} diff --git a/libraries/event/src/main/java/dev/yumi/commons/event/InvokerSubset.java b/libraries/event/src/main/java/dev/yumi/commons/event/InvokerSubset.java index ea3d95c..4df555c 100644 --- a/libraries/event/src/main/java/dev/yumi/commons/event/InvokerSubset.java +++ b/libraries/event/src/main/java/dev/yumi/commons/event/InvokerSubset.java @@ -13,8 +13,24 @@ import java.lang.reflect.Array; import java.util.ArrayList; -public class InvokerSubset, T, C> implements InvokableEvent { +/** + * Represents a subset of listeners of a {@linkplain FilteredEvent filtered event}. + * + * @param the phase identifier type + * @param the type of the listeners, and the type of the invoker used to execute an event + * @param the type of the context used to filter out which listeners should be invoked by this subset + * @author LambdAurora + * @version 1.0.0 + * @since 1.0.0 + */ +public final class InvokerSubset, T, C> implements InvokableEvent { + /** + * The parent filtered event from which the subset comes from. + */ private final FilteredEvent parent; + /** + * The context relevant to this subset of listeners. + */ private final C context; /** * The invoker to execute the callbacks. diff --git a/libraries/event/src/test/java/dev/yumi/commons/event/test/EventCreationTest.java b/libraries/event/src/test/java/dev/yumi/commons/event/test/EventCreationTest.java index e607bd4..85bf20d 100644 --- a/libraries/event/src/test/java/dev/yumi/commons/event/test/EventCreationTest.java +++ b/libraries/event/src/test/java/dev/yumi/commons/event/test/EventCreationTest.java @@ -41,5 +41,10 @@ public void test() { var filterEvent = events.create(FilterTestCallback.class); tester.assertCalled(1); assertSame(filterEvent, lastEvent[0]); + + tester.reset(); + var phasedEvent = events.createWithPhases(TestCallback.class, "pre", "default", "post"); + tester.assertCalled(1); + assertSame(phasedEvent, lastEvent[0]); } } diff --git a/libraries/event/src/test/java/dev/yumi/commons/event/test/ExecutionTester.java b/libraries/event/src/test/java/dev/yumi/commons/event/test/ExecutionTester.java index 06279df..5f72988 100644 --- a/libraries/event/src/test/java/dev/yumi/commons/event/test/ExecutionTester.java +++ b/libraries/event/src/test/java/dev/yumi/commons/event/test/ExecutionTester.java @@ -9,19 +9,42 @@ package dev.yumi.commons.event.test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; public class ExecutionTester { + private int callOrder; + private boolean strictOrder = true; private int calls; - public void reset() { + public ExecutionTester useStrictOrder(boolean strictOrder) { + this.strictOrder = strictOrder; + return this; + } + + public ExecutionTester reset() { + this.callOrder = 0; + this.strictOrder = true; this.calls = 0; + + return this; } public void assertOrder(int order) { - assertEquals(order, this.calls, "Expected listener n°" + order + " to be called."); + if (this.strictOrder) { + assertEquals(order, this.callOrder, "Expected listener n°" + order + " to be called."); + this.callOrder++; + } else { + assertTrue(this.callOrder <= order, "Expected any listener before n°" + order + " to be called."); + this.callOrder = order; + } + this.calls++; } + public void skip() { + this.callOrder++; + } + public void assertCalled(int called) { assertEquals(called, this.calls, "Expected a specific amount of listener calls."); } diff --git a/libraries/event/src/test/java/dev/yumi/commons/event/test/FilteredEventTest.java b/libraries/event/src/test/java/dev/yumi/commons/event/test/FilteredEventTest.java index ea48246..7c1fe93 100644 --- a/libraries/event/src/test/java/dev/yumi/commons/event/test/FilteredEventTest.java +++ b/libraries/event/src/test/java/dev/yumi/commons/event/test/FilteredEventTest.java @@ -10,10 +10,12 @@ import dev.yumi.commons.collections.YumiCollections; import dev.yumi.commons.event.EventManager; +import dev.yumi.commons.event.FilteredEvent; import org.junit.jupiter.api.Test; import java.util.List; import java.util.function.Function; +import java.util.function.Predicate; public class FilteredEventTest { private static final EventManager EVENTS = new EventManager<>("default", Function.identity()); @@ -21,7 +23,7 @@ public class FilteredEventTest { @Test public void test() { var tester = new ExecutionTester(); - var event = EVENTS.createFiltered(TestCallback.class); + var event = EVENTS.createFiltered(TestCallback.class, String.class); event.register(text -> tester.assertOrder(0)); event.register(text -> tester.assertOrder(1)); @@ -45,42 +47,67 @@ public void test() { tester.assertCalled(3); } - // @TODO test phases with filtered event @Test public void testPhases() { var tester = new ExecutionTester(); - record Entry(String phase, List listeners) { - Entry(String phase, TestCallback listener) { + + record Listener(int order, Predicate selector) { + void register(String phase, ExecutionTester tester, FilteredEvent event) { + if (this.selector == null) { + event.register(phase, text -> tester.assertOrder(this.order)); + } else { + event.register(phase, text -> tester.assertOrder(this.order), this.selector); + event.register(phase, text -> tester.skip(), this.selector.negate()); + } + } + } + + record Entry(String phase, List listeners) { + Entry(String phase, Listener listener) { this(phase, List.of(listener)); } } YumiCollections.forAllPermutations( List.of( - new Entry("very_early", text -> tester.assertOrder(0)), - new Entry("early", text -> tester.assertOrder(1)), - new Entry("default", List.of(text -> tester.assertOrder(2), text -> tester.assertOrder(3))), - new Entry("late", text -> tester.assertOrder(4)), + new Entry("very_early", new Listener(0, null)), + new Entry("early", new Listener(1, null)), + new Entry("default", List.of( + new Listener(2, context -> context.equals("contextualized")), + new Listener(3, null) + )), + new Entry("late", new Listener(4, context -> context.equals("contextualized"))), new Entry("very_late", List.of( - text -> tester.assertOrder(5), - text -> tester.assertOrder(6), - text -> tester.assertOrder(7) + new Listener(5, null), + new Listener(6, context -> context.equals("some other context")), + new Listener(7, null) )) ), entries -> { - var event = EVENTS.createWithPhases(TestCallback.class, + var event = EVENTS.createFilteredWithPhases(TestCallback.class, String.class, "very_early", "early", "default", "late", "very_late" ); tester.reset(); for (var entry : entries) { for (var listener : entry.listeners) { - event.register(entry.phase, listener); + listener.register(entry.phase, tester, event); } } - event.invoker().call("Hello World!"); - tester.assertCalled(8); + tester.useStrictOrder(false); + event.invoker().call("Hello world!"); + tester.assertCalled(5); + + tester.reset(); + + event.forContext("contextualized").invoker().call("Hello world!"); + tester.assertCalled(7); + + tester.reset(); + + event.forContext("some other context").invoker().call("Hello world!"); + tester.assertCalled(6); }); } }