From 44cf105c8cd6e46ad1ba38917c8d39afde453ca7 Mon Sep 17 00:00:00 2001 From: LambdAurora Date: Wed, 25 Oct 2023 22:50:29 +0200 Subject: [PATCH] Add listenAll method to EventManager. --- .../dev/yumi/commons/event/EventManager.java | 63 +++++- .../dev/yumi/commons/event/ListenerPhase.java | 34 ++++ .../yumi/commons/event/ListenerPhases.java | 24 +++ .../event/test/EventListenAllTest.java | 183 ++++++++++++++++++ .../yumi/commons/event/test/EventTest.java | 32 +-- .../commons/event/test/ExecutionTester.java | 28 +++ .../event/test/FilterTestCallback.java | 2 +- 7 files changed, 340 insertions(+), 26 deletions(-) create mode 100644 libraries/event/src/main/java/dev/yumi/commons/event/ListenerPhase.java create mode 100644 libraries/event/src/main/java/dev/yumi/commons/event/ListenerPhases.java create mode 100644 libraries/event/src/test/java/dev/yumi/commons/event/test/EventListenAllTest.java create mode 100644 libraries/event/src/test/java/dev/yumi/commons/event/test/ExecutionTester.java 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 d074b3d..8bb521b 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 @@ -32,6 +32,8 @@ import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; +import java.util.HashMap; +import java.util.Map; import java.util.function.Function; /** @@ -43,9 +45,11 @@ */ public final class EventManager> { private final I defaultPhaseId; + private final Function phaseIdParser; - public EventManager(@NotNull I defaultPhaseId) { + public EventManager(@NotNull I defaultPhaseId, @NotNull Function phaseIdParser) { this.defaultPhaseId = defaultPhaseId; + this.phaseIdParser = phaseIdParser; } /** @@ -249,6 +253,47 @@ public EventManager(@NotNull I defaultPhaseId) { return event; } + /** + * Registers the listener to the specified events. + *

+ * The registration of the listener will be rejected if one of the listed event involves generics in its listener type, + * as checking for valid registration is too expensive, please use the regular {@link Event#register(Object)} method + * for those as those checks will be delegated to the Java compiler. + * + * @param listener the listener object + * @param events the events to listen + * @throws IllegalArgumentException if the listener doesn't listen one of the events to listen, or if no events were specified + * @see Event#register(Object) + * @see Event#register(Comparable, Object) + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + @SafeVarargs + public final void listenAll(Object listener, Event... events) { + if (events.length == 0) { + throw new IllegalArgumentException("Tried to register a listener for an empty event list."); + } + + var listenedPhases = this.getListenedPhases(listener.getClass()); + + // Check whether we actually can register stuff. We only commit the registration if all events can. + for (var event : events) { + if (!event.getType().isAssignableFrom(listener.getClass())) { + throw new IllegalArgumentException("Given object " + listener + " is not a listener of event " + event); + } + + if (event.getType().getTypeParameters().length > 0) { + throw new IllegalArgumentException("Cannot register a listener for the event " + event + " which is using generic parameters with listenAll."); + } + + listenedPhases.putIfAbsent(event.getType(), this.defaultPhaseId); + } + + // We can register, so we do! + for (var event : events) { + ((Event) event).register(listenedPhases.get(event.getType()), listener); + } + } + /** * {@return the default phase identifier of all the events created by this event manager} */ @@ -257,6 +302,22 @@ public EventManager(@NotNull I defaultPhaseId) { return this.defaultPhaseId; } + private Map, I> getListenedPhases(Class listenerClass) { + var map = new HashMap, I>(); + + for (var annotation : listenerClass.getAnnotations()) { + if (annotation instanceof ListenerPhase phase) { + map.put(phase.callbackTarget(), this.phaseIdParser.apply(phase.value())); + } else if (annotation instanceof ListenerPhases phases) { + for (var phase : phases.value()) { + map.put(phase.callbackTarget(), this.phaseIdParser.apply(phase.value())); + } + } + } + + return map; + } + private void ensureContainsDefaultPhase(I[] defaultPhases) { for (var id : defaultPhases) { if (id.equals(this.defaultPhaseId)) { diff --git a/libraries/event/src/main/java/dev/yumi/commons/event/ListenerPhase.java b/libraries/event/src/main/java/dev/yumi/commons/event/ListenerPhase.java new file mode 100644 index 0000000..be79b85 --- /dev/null +++ b/libraries/event/src/main/java/dev/yumi/commons/event/ListenerPhase.java @@ -0,0 +1,34 @@ +/* + * Copyright 2023 Yumi Project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.yumi.commons.event; + + +import java.lang.annotation.*; + +/** + * Annotates a specific callback in a listener a specific phase to listen. + * + * @see Event#addPhaseOrdering(Comparable, Comparable) + * @see Event#register(Comparable, Object) + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Repeatable(ListenerPhases.class) +public @interface ListenerPhase { + /** + * {@return the targeted callback interface} + */ + Class callbackTarget(); + + /** + * {@return the identifier of the phase to listen} + */ + String value(); +} diff --git a/libraries/event/src/main/java/dev/yumi/commons/event/ListenerPhases.java b/libraries/event/src/main/java/dev/yumi/commons/event/ListenerPhases.java new file mode 100644 index 0000000..2386f2f --- /dev/null +++ b/libraries/event/src/main/java/dev/yumi/commons/event/ListenerPhases.java @@ -0,0 +1,24 @@ +/* + * Copyright 2023 Yumi Project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.yumi.commons.event; + + +import java.lang.annotation.*; + +/** + * Represents the container type of {@link ListenerPhase} to make it repeatable. + * + * @see ListenerPhase + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface ListenerPhases { + ListenerPhase[] value(); +} diff --git a/libraries/event/src/test/java/dev/yumi/commons/event/test/EventListenAllTest.java b/libraries/event/src/test/java/dev/yumi/commons/event/test/EventListenAllTest.java new file mode 100644 index 0000000..2a028d4 --- /dev/null +++ b/libraries/event/src/test/java/dev/yumi/commons/event/test/EventListenAllTest.java @@ -0,0 +1,183 @@ +/* + * Copyright 2023 Yumi Project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.yumi.commons.event.test; + +import dev.yumi.commons.collections.YumiCollections; +import dev.yumi.commons.event.EventManager; +import dev.yumi.commons.event.ListenerPhase; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class EventListenAllTest { + @Test + public void testListenAllRejectNoEvents() { + var events = new EventManager<>("default", Function.identity()); + + assertThrows(IllegalArgumentException.class, () -> { + events.listenAll(new Listener(new ExecutionTester())); + }); + } + + @Test + public void testListenAllRejectMissingEvents() { + var events = new EventManager<>("default", Function.identity()); + var testEvent = events.create(TestCallback.class); + var testFilterEvent = events.create(FilterTestCallback.class); + + assertThrows(IllegalArgumentException.class, () -> { + events.listenAll(new Object(), testEvent, testFilterEvent); + }); + } + + @SuppressWarnings("unchecked") + @Test + public void testListenAllRejectGenerics() { + var events = new EventManager<>("default", Function.identity()); + var testGenericEvent = events.create(Generic.class, listeners -> arg -> { + for (var listener : listeners) { + listener.invoke(arg); + } + }); + var generic = new Generic() { + @Override + public void invoke(String arg) { + } + }; + + assertThrows(IllegalArgumentException.class, () -> { + events.listenAll(generic, testGenericEvent); + }); + } + + @Test + public void testListenAll() { + var events = new EventManager<>("default", Function.identity()); + var testEvent = events.create(TestCallback.class); + var testFilterEvent = events.create(FilterTestCallback.class); + var tester = new ExecutionTester(); + + events.listenAll(new Listener(tester), testEvent, testFilterEvent); + + testEvent.invoker().call("Test"); + tester.assertCalled(1); + + testFilterEvent.invoker().filter("Yippee"); + tester.assertCalled(2); + } + + @Test + public void testListenAllPhases() { + var events = new EventManager<>("default", Function.identity()); + var tester = new ExecutionTester(); + + YumiCollections.forAllPermutations( + List.of( + List.of(new PhaseListenerEarly(tester)), + List.of(new PhaseListenerDefault(tester), new PhaseListenerExplicitDefault(tester)), + List.of(new PhaseListenerLate(tester)) + ), + listeners -> { + var testEvent = events.createWithPhases(TestCallback.class, "early", "default", "late"); + var filterEvent = events.createWithPhases(FilterTestCallback.class, "early", "default", "late"); + + for (var phaseListeners : listeners) { + for (var listener : phaseListeners) { + events.listenAll(listener, testEvent, filterEvent); + } + } + + tester.reset(); + testEvent.invoker().call("Test"); + tester.assertCalled(4); + tester.reset(); + assertTrue(filterEvent.invoker().filter("Yippee")); + tester.assertCalled(3); + }); + } + + interface Generic { + void invoke(T arg); + } + + @ListenerPhase(callbackTarget = TestCallback.class, value = "early") + @ListenerPhase(callbackTarget = FilterTestCallback.class, value = "late") + record PhaseListenerEarly(ExecutionTester tester) implements TestCallback, FilterTestCallback { + @Override + public void call(String text) { + this.tester.assertOrder(0); + } + + @Override + public boolean filter(String text) { + throw new IllegalStateException("The filter callback should have already returned a value."); + } + } + + record PhaseListenerDefault(ExecutionTester tester) implements TestCallback, FilterTestCallback { + @Override + public void call(String text) { + this.tester.assertOrder(1); + } + + @Override + public boolean filter(String text) { + this.tester.assertOrder(1); + return false; + } + } + + @ListenerPhase(callbackTarget = TestCallback.class, value = "default") + @ListenerPhase(callbackTarget = FilterTestCallback.class, value = "default") + record PhaseListenerExplicitDefault(ExecutionTester tester) implements TestCallback, FilterTestCallback { + @Override + public void call(String text) { + this.tester.assertOrder(2); + } + + @Override + public boolean filter(String text) { + this.tester.assertOrder(2); + return true; + } + } + + @ListenerPhase(callbackTarget = TestCallback.class, value = "late") + @ListenerPhase(callbackTarget = FilterTestCallback.class, value = "early") + record PhaseListenerLate(ExecutionTester tester) implements TestCallback, FilterTestCallback { + @Override + public void call(String text) { + this.tester.assertOrder(3); + } + + @Override + public boolean filter(String text) { + this.tester.assertOrder(0); + return false; + } + } + + record Listener(ExecutionTester tester) implements TestCallback, FilterTestCallback { + + @Override + public void call(String text) { + this.tester.assertOrder(0); + } + + @Override + public boolean filter(String text) { + this.tester.assertOrder(1); + return false; + } + } +} diff --git a/libraries/event/src/test/java/dev/yumi/commons/event/test/EventTest.java b/libraries/event/src/test/java/dev/yumi/commons/event/test/EventTest.java index 9b69f9f..12c6a79 100644 --- a/libraries/event/src/test/java/dev/yumi/commons/event/test/EventTest.java +++ b/libraries/event/src/test/java/dev/yumi/commons/event/test/EventTest.java @@ -14,11 +14,12 @@ import org.junit.jupiter.api.Test; import java.util.List; +import java.util.function.Function; import static org.junit.jupiter.api.Assertions.*; public class EventTest { - private static final EventManager EVENTS = new EventManager<>("default"); + private static final EventManager EVENTS = new EventManager<>("default", Function.identity()); @Test public void test() { @@ -102,7 +103,7 @@ public void testDefaultInvokerForFilter() { var tester = new ExecutionTester(); var event = EVENTS.create(FilterTestCallback.class); - assertFalse(event.invoker().call("Empty")); + assertFalse(event.invoker().filter("Empty")); tester.reset(); event.register(text -> { @@ -110,10 +111,10 @@ public void testDefaultInvokerForFilter() { return text.isEmpty(); }); - assertTrue(event.invoker().call("")); + assertTrue(event.invoker().filter("")); tester.assertCalled(1); tester.reset(); - assertFalse(event.invoker().call("Single listener")); + assertFalse(event.invoker().filter("Single listener")); tester.assertCalled(1); tester.reset(); @@ -122,13 +123,13 @@ public void testDefaultInvokerForFilter() { return line.contains("e"); }); - assertTrue(event.invoker().call("Hello World!")); + assertTrue(event.invoker().filter("Hello World!")); tester.assertCalled(2); tester.reset(); - assertTrue(event.invoker().call("")); + assertTrue(event.invoker().filter("")); tester.assertCalled(1); tester.reset(); - assertFalse(event.invoker().call("Hi World!")); + assertFalse(event.invoker().filter("Hi World!")); tester.assertCalled(2); } @@ -174,21 +175,4 @@ public void testDefaultInvokerForTriStateFilter() { assertEquals(TriState.TRUE, event.invoker().call("\t")); assertEquals(TriState.DEFAULT, event.invoker().call("Whoop")); } - - static class ExecutionTester { - private int calls; - - public void reset() { - this.calls = 0; - } - - public void assertOrder(int order) { - assertEquals(order, this.calls, "Expected listener n°" + order + " to be called."); - this.calls++; - } - - 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/ExecutionTester.java b/libraries/event/src/test/java/dev/yumi/commons/event/test/ExecutionTester.java new file mode 100644 index 0000000..06279df --- /dev/null +++ b/libraries/event/src/test/java/dev/yumi/commons/event/test/ExecutionTester.java @@ -0,0 +1,28 @@ +/* + * Copyright 2023 Yumi Project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.yumi.commons.event.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ExecutionTester { + private int calls; + + public void reset() { + this.calls = 0; + } + + public void assertOrder(int order) { + assertEquals(order, this.calls, "Expected listener n°" + order + " to be called."); + this.calls++; + } + + 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/FilterTestCallback.java b/libraries/event/src/test/java/dev/yumi/commons/event/test/FilterTestCallback.java index c14248e..ff04a04 100644 --- a/libraries/event/src/test/java/dev/yumi/commons/event/test/FilterTestCallback.java +++ b/libraries/event/src/test/java/dev/yumi/commons/event/test/FilterTestCallback.java @@ -9,5 +9,5 @@ package dev.yumi.commons.event.test; public interface FilterTestCallback { - boolean call(String text); + boolean filter(String text); }