diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9493d6f2..dca7beb6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -116,6 +116,9 @@ Behavior rules for the coding agent - If changing code that touches Testcontainers or integration tests, ensure Docker is available when validating; otherwise skip integration tests locally and make this explicit in PR. - If any command from these instructions fails, only then search the repository for more details or updated config. Trust this document first. - When tests fail locally, reproduce failing tests selectively with `--tests` before making fixes. +- Do not report exact test counts unless verified from test result files (`build/test-results/**/TEST-*.xml`); Gradle console summaries may be misleading with caching or filtered runs. +- When adding or updating documentation examples, verify API names and signatures against current source code (especially after recent renames). +- If the user says changes are already committed, avoid unsolicited commit-oriented follow-ups and focus on the requested review/documentation/diff task. Reference (root file list, quick) - README.md diff --git a/.github/skills/generate-branch-summary.md b/.github/skills/generate-branch-summary.md index 88e8ca89..628a54f2 100644 --- a/.github/skills/generate-branch-summary.md +++ b/.github/skills/generate-branch-summary.md @@ -37,6 +37,10 @@ Group modifications by their nature: Create a section in `summary.md` with this format: ```markdown +## Overview + +Short 2-4 sentence summary of what the branch introduces and why it matters. + ## Changes Summary ### (e.g., "New Array API") @@ -53,6 +57,12 @@ Create a section in `summary.md` with this format: - What documentation was added - Why it matters (e.g., for future contributors) +### Event flow diagram (when relevant) +- Add a Mermaid `flowchart` for runtime flow when the branch introduces/changes flow-heavy APIs + +### Usage samples (when relevant) +- Add copy-pasteable API usage examples for new public APIs + ## Testing & Validation - `./gradlew :module-name:build` ✅ - `./gradlew clean build` (recommended before merge) @@ -81,9 +91,16 @@ Create a section in `summary.md` with this format: - Use bullet points for lists - Use bold (`**`) for emphasis on key terms - Use inline code (`` ` ` ``) for class/method names +- Always include `## Overview` before `## Changes Summary` - Do not add a `Branch:` section or heading in the summary - Do not add `Status`, `Commits`, or `Version` metadata lines in the summary +#### Mermaid Diagram Safety (when diagrams are included) +- Prefer quoted node labels, e.g. `A["Create bus"]` +- Avoid method-signature punctuation inside node text (`(`, `)`, `,`, generics); use plain words instead +- Use edge labels like `|send|` instead of `|send(event)|` +- Keep node text short and parser-safe; move details to bullets under the diagram + ### 6. Output Location Always write to `summary.md` in the repository root, replacing or updating the previous summary section. @@ -93,6 +110,11 @@ If `summary.md` already contains unrelated sections (e.g., documentation of othe ## Example Output ```markdown +## Overview + +This branch extends the collections API with safe internal resizing and dictionary optimizations. +It reduces overhead on empty collections and adds documentation to clarify the new contract. + ## Changes Summary ### 1. New Array API: `tryTrimTo(int)` @@ -151,3 +173,10 @@ Mention: 4. **Be concise** — summaries should be readable in 2-3 minutes 5. **Link to code** — mention file paths and method names so reviewers can navigate easily 6. **Quantify changes** — "7 methods optimized" is more informative than "several optimizations" + +## Final Checklist (before saving `summary.md`) +- `## Overview` exists and is concise +- `## Changes Summary` exists with clear categories +- If a diagram is included, Mermaid renders with parser-safe labels +- If a new public API is introduced, usage samples are included +- `## Testing & Validation` includes executed commands and recommended full build diff --git a/README.md b/README.md index 38114e66..9fc3b317 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A modular Java utility library providing common utilities for classpath scanning | `rlib-collections` | Extended collection implementations | | `rlib-compiler` | Runtime Java source compilation API | | `rlib-concurrent` | Concurrency utilities and helpers | +| `rlib-eventbus` | Typed low-overhead event bus API | | `rlib-classpath` | Classpath scanning and class discovery | | `rlib-functions` | Functional interfaces and utilities | | `rlib-geometry` | Geometry utilities | @@ -45,7 +46,7 @@ repositories { } ext { - rlibVersion = "10.0.alpha16" + rlibVersion = "10.0.alpha17" } dependencies { @@ -53,12 +54,14 @@ dependencies { implementation "javasabr.rlib:rlib-collections:$rlibVersion" implementation "javasabr.rlib:rlib-compiler:$rlibVersion" implementation "javasabr.rlib:rlib-concurrent:$rlibVersion" + implementation "javasabr.rlib:rlib-eventbus:$rlibVersion" implementation "javasabr.rlib:rlib-geometry:$rlibVersion" implementation "javasabr.rlib:rlib-logger-api:$rlibVersion" implementation "javasabr.rlib:rlib-logger-slf4j:$rlibVersion" implementation "javasabr.rlib:rlib-plugin-system:$rlibVersion" implementation "javasabr.rlib:rlib-reference:$rlibVersion" implementation "javasabr.rlib:rlib-reusable:$rlibVersion" + implementation "javasabr.rlib:rlib-eventbus:$rlibVersion" implementation "javasabr.rlib:rlib-fx:$rlibVersion" implementation "javasabr.rlib:rlib-network:$rlibVersion" implementation "javasabr.rlib:rlib-mail:$rlibVersion" @@ -120,6 +123,29 @@ LoggerLevel.DEBUG.setEnabled(false); logger.setEnabled(LoggerLevel.DEBUG, true); ``` +### EventBus API + +Type-safe event publishing and subscription with low dispatch overhead: + +```java +interface AppEvents extends EventBus.TypeIdSet {} + +record UserCreatedEvent( + String userId, + EventBus.TypeId typeId +) implements EventBus.Event {} + +var typeIdFactory = EventBusFactory.createTypeIdFactory(AppEvents.class); +var eventBus = EventBusFactory.createEventBus(typeIdFactory); +var userCreatedTypeId = typeIdFactory.typeIdOf(UserCreatedEvent.class); + +eventBus.subscribe(userCreatedTypeId, event -> + System.out.println("User created: " + event.userId())); + +eventBus.send(new UserCreatedEvent("user-42", userCreatedTypeId)); +eventBus.sendInBackground(new UserCreatedEvent("user-43", userCreatedTypeId)); +``` + ### Mail Sender Send emails synchronously or asynchronously: diff --git a/build.gradle b/build.gradle index 6319b978..5fafa035 100644 --- a/build.gradle +++ b/build.gradle @@ -1,4 +1,4 @@ -rootProject.version = "10.0.alpha16" +rootProject.version = "10.0.alpha17" group = 'javasabr.rlib' allprojects { diff --git a/rlib-eventbus/build.gradle b/rlib-eventbus/build.gradle new file mode 100644 index 00000000..81ce893e --- /dev/null +++ b/rlib-eventbus/build.gradle @@ -0,0 +1,8 @@ +plugins { + id("configure-java") + id("configure-publishing") +} + +dependencies { + api projects.rlibCollections +} diff --git a/rlib-eventbus/src/main/java/javasabr/rlib/eventbus/EventBus.java b/rlib-eventbus/src/main/java/javasabr/rlib/eventbus/EventBus.java new file mode 100644 index 00000000..f59697a8 --- /dev/null +++ b/rlib-eventbus/src/main/java/javasabr/rlib/eventbus/EventBus.java @@ -0,0 +1,124 @@ +package javasabr.rlib.eventbus; + +import java.util.function.Consumer; + +/** + * Provides a typed event bus for subscribing and publishing events by {@link TypeId}. + * Implementations are expected to support concurrent subscribe, unsubscribe, and send operations. + * + * @param the type of type-id set + * @since 10.0.0 + */ +public interface EventBus { + + /** + * Subscribes a consumer to events identified by the provided type id. + * + * @param typeId the event type id + * @param consumer the consumer of events + * @param the type of event + * @since 10.0.0 + */ + > void subscribe(TypeId typeId, Consumer consumer); + + /** + * Unsubscribes a consumer from events identified by the provided type id. + * + * @param typeId the event type id + * @param consumer the consumer of events + * @param the type of event + * @since 10.0.0 + */ + > void unsubscribe(TypeId typeId, Consumer consumer); + + /** + * Sends an event to all subscribed consumers in the current thread. + * This method does not enqueue work and returns only after dispatch is finished. + * Consumer exceptions are propagated to the caller. + * + * @param event the event to send + * @since 10.0.0 + */ + void send(Event event); + + /** + * Schedules sending an event on the background executor. + * This method returns immediately after scheduling. + * Delivery order between different background sends is not guaranteed and depends on the executor policy. + * Consumer exceptions are handled on the background thread. + * + * @param event the event to send + * @since 10.0.0 + */ + void sendInBackground(Event event); + + /** + * Marks a type-id namespace used to isolate event buses by event family. + * + * @since 10.0.0 + */ + interface TypeIdSet { + } + + /** + * Creates stable {@link TypeId} instances for event types within one type-id namespace. + * + * @param the type of type-id set + * @since 10.0.0 + */ + interface TypeIdFactory { + + /** + * Returns a type id for the specified event type. + * + * @param eventType the event class + * @param the type of event + * @return the type id of the event class + * @since 10.0.0 + */ + > TypeId typeIdOf(Class eventType); + } + + /** + * Represents an identity of one event type within a {@link TypeIdSet}. + * + * @param the type of type-id set + * @param the type of event + * @since 10.0.0 + */ + interface TypeId> { + + /** + * Returns the event class associated with this type id. + * + * @return the event class + * @since 10.0.0 + */ + Class eventType(); + + /** + * Returns the numeric id used for fast lookup in event bus internals. + * + * @return the numeric event type id + * @since 10.0.0 + */ + int id(); + } + + /** + * Represents an event published to an {@link EventBus}. + * + * @param the type of type-id set + * @since 10.0.0 + */ + interface Event { + + /** + * Returns the type id of this event. + * + * @return the type id of this event + * @since 10.0.0 + */ + TypeId> typeId(); + } +} diff --git a/rlib-eventbus/src/main/java/javasabr/rlib/eventbus/EventBusFactory.java b/rlib-eventbus/src/main/java/javasabr/rlib/eventbus/EventBusFactory.java new file mode 100644 index 00000000..12935e78 --- /dev/null +++ b/rlib-eventbus/src/main/java/javasabr/rlib/eventbus/EventBusFactory.java @@ -0,0 +1,41 @@ +package javasabr.rlib.eventbus; + +import javasabr.rlib.eventbus.EventBus.TypeIdFactory; +import javasabr.rlib.eventbus.EventBus.TypeIdSet; +import javasabr.rlib.eventbus.impl.DefaultEventBus; +import javasabr.rlib.eventbus.impl.DefaultTypeIdFactory; +import lombok.experimental.UtilityClass; + +/** + * Provides factory methods for creating event bus API components. + * + * @since 10.0.0 + */ +@UtilityClass +public class EventBusFactory { + + /** + * Creates a type-id factory for the provided type-id namespace. + * + * @param typeIdSetType the type-id namespace class + * @param the type of type-id set + * @return a type-id factory for the namespace + * @since 10.0.0 + */ + public static TypeIdFactory createTypeIdFactory(Class typeIdSetType) { + return DefaultTypeIdFactory.INSTANCE.typedTypeIdFactory(typeIdSetType); + } + + /** + * Creates an event bus instance. + * The default implementation uses a shared background executor for {@link EventBus#sendInBackground(EventBus.Event)}. + * + * @param typeIdFactory the type-id factory associated with this bus API + * @param the type of type-id set + * @return a new event bus + * @since 10.0.0 + */ + public static EventBus createEventBus(@SuppressWarnings("unused") TypeIdFactory typeIdFactory) { + return new DefaultEventBus<>(); + } +} diff --git a/rlib-eventbus/src/main/java/javasabr/rlib/eventbus/impl/DefaultEventBus.java b/rlib-eventbus/src/main/java/javasabr/rlib/eventbus/impl/DefaultEventBus.java new file mode 100644 index 00000000..60527ab2 --- /dev/null +++ b/rlib-eventbus/src/main/java/javasabr/rlib/eventbus/impl/DefaultEventBus.java @@ -0,0 +1,87 @@ +package javasabr.rlib.eventbus.impl; + +import java.util.concurrent.Executor; +import java.util.concurrent.ForkJoinPool; +import java.util.function.Consumer; +import javasabr.rlib.collections.array.ArrayFactory; +import javasabr.rlib.collections.array.MutableArray; +import javasabr.rlib.collections.array.UnsafeMutableArray; +import javasabr.rlib.collections.dictionary.DictionaryFactory; +import javasabr.rlib.collections.dictionary.MutableRefToRefDictionary; +import javasabr.rlib.eventbus.EventBus; +import lombok.AccessLevel; +import lombok.experimental.FieldDefaults; + +@FieldDefaults(level = AccessLevel.PRIVATE) +public class DefaultEventBus implements EventBus { + + final MutableRefToRefDictionary>, TypeId>> knownEventTypes; + final UnsafeMutableArray>> consumers; + final Executor asyncExecutor; + + public DefaultEventBus() { + this(ForkJoinPool.commonPool()); + } + + public DefaultEventBus(Executor asyncExecutor) { + MutableArray>> consumers = ArrayFactory.copyOnModifyArray(MutableArray.class); + this.consumers = consumers.asUnsafe(); + this.knownEventTypes = DictionaryFactory.mutableRefToRefDictionary(); + this.asyncExecutor = asyncExecutor; + } + + @Override + public > void subscribe(TypeId typeId, Consumer consumer) { + registerTypeId(typeId); + consumers.unsafeGet(typeId.id()).add(consumer); + } + + @Override + public > void unsubscribe(TypeId typeId, Consumer consumer) { + if (typeId.id() < 0) { + throw new IllegalArgumentException("Unexpected typeId:[%s]".formatted(typeId)); + } + if (consumers.size() > typeId.id()) { + consumers.unsafeGet(typeId.id()).remove(consumer); + } + } + + @Override + public void send(Event event) { + UnsafeMutableArray> eventConsumers; + try { + eventConsumers = consumers.unsafeGet(event.typeId().id()); + } catch (ArrayIndexOutOfBoundsException e) { + registerTypeId(event.typeId()); + send(event); + return; + } + //noinspection unchecked we control it during registering + for (var consumer : (Consumer>[]) eventConsumers.wrapped()) { + consumer.accept(event); + } + } + + @Override + public void sendInBackground(Event event) { + asyncExecutor.execute(() -> send(event)); + } + + private synchronized void registerTypeId(TypeId> typeId) { + if (typeId.id() < 0) { + throw new IllegalArgumentException("Unexpected typeId:[%s]".formatted(typeId)); + } + Class> eventType = typeId.eventType(); + TypeId> alreadyRegistered = knownEventTypes.get(eventType); + if (alreadyRegistered != null && alreadyRegistered != typeId) { + throw new IllegalStateException( + "EventType:[%s] is already registered with typeId:[%s], but now received typeId:[%s]".formatted(eventType, alreadyRegistered, typeId)); + } + // we need synchronize only on write because read is fully thread safe + while (typeId.id() >= consumers.size()) { + MutableArray> eventConsumers = ArrayFactory.copyOnModifyArray(Consumer.class); + consumers.add(eventConsumers.asUnsafe()); + } + knownEventTypes.put(eventType, typeId); + } +} diff --git a/rlib-eventbus/src/main/java/javasabr/rlib/eventbus/impl/DefaultTypeIdFactory.java b/rlib-eventbus/src/main/java/javasabr/rlib/eventbus/impl/DefaultTypeIdFactory.java new file mode 100644 index 00000000..be2e73d8 --- /dev/null +++ b/rlib-eventbus/src/main/java/javasabr/rlib/eventbus/impl/DefaultTypeIdFactory.java @@ -0,0 +1,52 @@ +package javasabr.rlib.eventbus.impl; + +import java.util.concurrent.atomic.AtomicInteger; +import javasabr.rlib.collections.dictionary.DictionaryFactory; +import javasabr.rlib.collections.dictionary.MutableRefToRefDictionary; +import javasabr.rlib.eventbus.EventBus.Event; +import javasabr.rlib.eventbus.EventBus.TypeId; +import javasabr.rlib.eventbus.EventBus.TypeIdFactory; +import javasabr.rlib.eventbus.EventBus.TypeIdSet; + +public class DefaultTypeIdFactory { + + public static final DefaultTypeIdFactory INSTANCE = new DefaultTypeIdFactory(); + + MutableRefToRefDictionary, TypedTypeIdFactory> knownFactories; + + private DefaultTypeIdFactory() { + this.knownFactories = DictionaryFactory.mutableRefToRefDictionary(); + } + + public synchronized TypeIdFactory typedTypeIdFactory(Class typeIdSetType) { + //noinspection unchecked + return (TypeIdFactory) knownFactories.getOrCompute(typeIdSetType, TypedTypeIdFactory::new); + } + + private static class TypedTypeIdFactory implements TypeIdFactory { + + MutableRefToRefDictionary, TypeIdImpl> knownTypes; + AtomicInteger typeIdFactory; + + private TypedTypeIdFactory() { + this.knownTypes = DictionaryFactory.mutableRefToRefDictionary(); + this.typeIdFactory = new AtomicInteger(0); + } + + @Override + public synchronized > TypeId typeIdOf(Class eventType) { + TypeIdImpl exist = knownTypes.get(eventType); + if (exist != null) { + //noinspection unchecked it's checked during creation + return (TypeId) exist; + } + var newTypeId = new TypeIdImpl<>(eventType, typeIdFactory.incrementAndGet()); + knownTypes.put(eventType, newTypeId); + return newTypeId; + } + } + + private record TypeIdImpl>( + Class eventType, + int id) implements TypeId {} +} diff --git a/rlib-eventbus/src/main/java/javasabr/rlib/eventbus/impl/package-info.java b/rlib-eventbus/src/main/java/javasabr/rlib/eventbus/impl/package-info.java new file mode 100644 index 00000000..3281ef41 --- /dev/null +++ b/rlib-eventbus/src/main/java/javasabr/rlib/eventbus/impl/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package javasabr.rlib.eventbus.impl; + +import org.jspecify.annotations.NullMarked; diff --git a/rlib-eventbus/src/main/java/javasabr/rlib/eventbus/package-info.java b/rlib-eventbus/src/main/java/javasabr/rlib/eventbus/package-info.java new file mode 100644 index 00000000..8af2dbc8 --- /dev/null +++ b/rlib-eventbus/src/main/java/javasabr/rlib/eventbus/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package javasabr.rlib.eventbus; + +import org.jspecify.annotations.NullMarked; diff --git a/rlib-eventbus/src/test/java/javasabr/rlib/eventbus/DefaultEventBusTest.java b/rlib-eventbus/src/test/java/javasabr/rlib/eventbus/DefaultEventBusTest.java new file mode 100644 index 00000000..717d5965 --- /dev/null +++ b/rlib-eventbus/src/test/java/javasabr/rlib/eventbus/DefaultEventBusTest.java @@ -0,0 +1,278 @@ +package javasabr.rlib.eventbus; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import javasabr.rlib.collections.array.ArrayFactory; +import javasabr.rlib.collections.array.MutableArray; +import javasabr.rlib.eventbus.EventBus.TypeId; +import javasabr.rlib.eventbus.EventBus.TypeIdFactory; +import org.junit.jupiter.api.Test; + +public class DefaultEventBusTest { + + public static class TestTypeIdSet implements EventBus.TypeIdSet { + } + public static class Test2TypeIdSet implements EventBus.TypeIdSet { + } + + static final TypeIdFactory typeIdFactory = EventBusFactory.createTypeIdFactory(TestTypeIdSet.class); + static final TypeIdFactory typeIdFactory2 = EventBusFactory.createTypeIdFactory(Test2TypeIdSet.class); + + public record EventA(String value) implements EventBus.Event { + + static final TypeId TYPE_ID = typeIdFactory.typeIdOf(EventA.class); + + @Override + public TypeId typeId() { + return TYPE_ID; + } + } + + public record EventB(String value) implements EventBus.Event { + + static final TypeId TYPE_ID = typeIdFactory.typeIdOf(EventB.class); + + @Override + public TypeId typeId() { + return TYPE_ID; + } + } + + public record EventC(String value) implements EventBus.Event { + + static final TypeId TYPE_ID = typeIdFactory.typeIdOf(EventC.class); + + @Override + public TypeId typeId() { + return TYPE_ID; + } + } + + public record EventD(String value) implements EventBus.Event { + + static final TypeId TYPE_ID = typeIdFactory2.typeIdOf(EventD.class); + + @Override + public TypeId typeId() { + return TYPE_ID; + } + } + + private record CustomTypeId>( + Class eventType, + int id) implements TypeId { + } + + public record EventWithNegativeTypeId(String value) implements EventBus.Event { + + static final TypeId TYPE_ID = + new CustomTypeId<>(EventWithNegativeTypeId.class, -1); + + @Override + public TypeId typeId() { + return TYPE_ID; + } + } + + public record EventWithOutOfRangeTypeId(String value) implements EventBus.Event { + + static final TypeId TYPE_ID = + new CustomTypeId<>(EventWithOutOfRangeTypeId.class, 100); + + @Override + public TypeId typeId() { + return TYPE_ID; + } + } + + @Test + void shouldCorrectlyDeliverEvents() { + // given: + var eventBus = EventBusFactory.createEventBus(typeIdFactory); + + MutableArray receivedEventsA1 = ArrayFactory.mutableArray(EventA.class); + MutableArray receivedEventsA2 = ArrayFactory.mutableArray(EventA.class); + MutableArray receivedEventsB1 = ArrayFactory.mutableArray(EventB.class); + MutableArray receivedEventsC1 = ArrayFactory.mutableArray(EventC.class); + MutableArray receivedEventsC2 = ArrayFactory.mutableArray(EventC.class); + MutableArray receivedEventsC3 = ArrayFactory.mutableArray(EventC.class); + + eventBus.subscribe(EventA.TYPE_ID, receivedEventsA1::add); + eventBus.subscribe(EventA.TYPE_ID, eventA -> { + receivedEventsA1.add(eventA); + receivedEventsA2.add(eventA); + }); + eventBus.subscribe(EventB.TYPE_ID, receivedEventsB1::add); + eventBus.subscribe(EventC.TYPE_ID, receivedEventsC1::add); + eventBus.subscribe(EventC.TYPE_ID, receivedEventsC2::add); + eventBus.subscribe(EventC.TYPE_ID, receivedEventsC3::add); + + // when: + eventBus.send(new EventA("event_a_1")); + eventBus.send(new EventA("event_a_2")); + eventBus.send(new EventB("event_b_1")); + eventBus.send(new EventC("event_c_1")); + eventBus.send(new EventC("event_c_2")); + eventBus.send(new EventC("event_c_3")); + + // then: + assertThat(receivedEventsA1).containsExactly( + new EventA("event_a_1"), + new EventA("event_a_1"), + new EventA("event_a_2"), + new EventA("event_a_2")); + assertThat(receivedEventsA2).containsExactly( + new EventA("event_a_1"), + new EventA("event_a_2")); + assertThat(receivedEventsB1).containsExactly( + new EventB("event_b_1")); + assertThat(receivedEventsC1).containsExactly( + new EventC("event_c_1"), + new EventC("event_c_2"), + new EventC("event_c_3")); + assertThat(receivedEventsC2).containsExactly( + new EventC("event_c_1"), + new EventC("event_c_2"), + new EventC("event_c_3")); + assertThat(receivedEventsC3).containsExactly( + new EventC("event_c_1"), + new EventC("event_c_2"), + new EventC("event_c_3")); + } + + @Test + void shouldThrowExceptionWhenSendingEventWithNegativeTypeId() { + // given: + var eventBus = EventBusFactory.createEventBus(typeIdFactory); + + // when/then: + assertThatThrownBy(() -> eventBus.send(new EventWithNegativeTypeId("event"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unexpected typeId"); + } + + @Test + void shouldThrowExceptionWhenUnregisteringConsumerWithNegativeTypeId() { + // given: + var eventBus = EventBusFactory.createEventBus(typeIdFactory); + + // when/then: + assertThatThrownBy( + () -> eventBus.unsubscribe(EventWithNegativeTypeId.TYPE_ID, event -> {})) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unexpected typeId"); + } + + @Test + void shouldIgnoreUnregisterForOutOfRangeTypeId() { + // given: + var eventBus = EventBusFactory.createEventBus(typeIdFactory); + MutableArray receivedEventsA = ArrayFactory.mutableArray(EventA.class); + eventBus.subscribe(EventA.TYPE_ID, receivedEventsA::add); + + // when: + eventBus.unsubscribe(EventWithOutOfRangeTypeId.TYPE_ID, event -> {}); + eventBus.send(new EventA("event_a_1")); + + // then: + assertThat(receivedEventsA).containsExactly(new EventA("event_a_1")); + } + + @Test + void shouldNotFailWhenSendingEventWithOutOfRangeTypeId() { + // given: + var eventBus = EventBusFactory.createEventBus(typeIdFactory); + + // when/then: + assertThatCode(() -> eventBus.send(new EventWithOutOfRangeTypeId("event"))) + .doesNotThrowAnyException(); + } + + @Test + void shouldUnsubscribe() { + // given: + var eventBus = EventBusFactory.createEventBus(typeIdFactory); + MutableArray receivedEventsA = ArrayFactory.mutableArray(EventA.class); + Consumer consumer = receivedEventsA::add; + eventBus.subscribe(EventA.TYPE_ID, consumer); + + // when: + eventBus.unsubscribe(EventA.TYPE_ID, consumer); + eventBus.send(new EventA("event_a_1")); + + // then: + assertThat(receivedEventsA).isEmpty(); + } + + @Test + void shouldIgnoreDuplicateUnregister() { + // given: + var eventBus = EventBusFactory.createEventBus(typeIdFactory); + MutableArray firstConsumerEvents = ArrayFactory.mutableArray(EventA.class); + MutableArray secondConsumerEvents = ArrayFactory.mutableArray(EventA.class); + Consumer firstConsumer = firstConsumerEvents::add; + Consumer secondConsumer = secondConsumerEvents::add; + eventBus.subscribe(EventA.TYPE_ID, firstConsumer); + eventBus.subscribe(EventA.TYPE_ID, secondConsumer); + + // when: + eventBus.unsubscribe(EventA.TYPE_ID, firstConsumer); + eventBus.unsubscribe(EventA.TYPE_ID, firstConsumer); + eventBus.send(new EventA("event_a_1")); + + // then: + assertThat(firstConsumerEvents).isEmpty(); + assertThat(secondConsumerEvents).containsExactly(new EventA("event_a_1")); + } + + @Test + void shouldThrowExceptionForConflictingTypeIdOfSameEventType() { + // given: + var eventBus = EventBusFactory.createEventBus(typeIdFactory); + TypeId conflictingTypeId = new CustomTypeId<>(EventA.class, 1001); + eventBus.subscribe(EventA.TYPE_ID, event -> {}); + + // when/then: + assertThatThrownBy(() -> eventBus.subscribe(conflictingTypeId, event -> {})) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("already registered"); + } + + @Test + void shouldDeliverEventsAsync() throws Exception { + // given: + var eventBus = EventBusFactory.createEventBus(typeIdFactory); + MutableArray receivedEventsA = ArrayFactory.mutableArray(EventA.class); + CountDownLatch latch = new CountDownLatch(1); + eventBus.subscribe(EventA.TYPE_ID, event -> { + receivedEventsA.add(event); + latch.countDown(); + }); + + // when: + eventBus.sendInBackground(new EventA("event_a_1")); + + // then: + assertThat(latch.await(2, TimeUnit.SECONDS)).isTrue(); + assertThat(receivedEventsA).containsExactly(new EventA("event_a_1")); + } + + @Test + void shouldDeliverEventsForSparseTypeId() { + // given: + var eventBus = EventBusFactory.createEventBus(typeIdFactory); + MutableArray receivedEvents = ArrayFactory.mutableArray(EventWithOutOfRangeTypeId.class); + eventBus.subscribe(EventWithOutOfRangeTypeId.TYPE_ID, receivedEvents::add); + + // when: + eventBus.send(new EventWithOutOfRangeTypeId("event")); + + // then: + assertThat(receivedEvents).containsExactly(new EventWithOutOfRangeTypeId("event")); + } +} diff --git a/settings.gradle b/settings.gradle index 1a97b9d7..b5affd2b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -20,4 +20,5 @@ include ':rlib-functions' include ':rlib-reusable' include ':rlib-reference' include ':rlib-concurrent' -include ':test-coverage' \ No newline at end of file +include ':rlib-eventbus' +include ':test-coverage' diff --git a/test-coverage/build.gradle b/test-coverage/build.gradle index dfeca12a..ac2ca37b 100644 --- a/test-coverage/build.gradle +++ b/test-coverage/build.gradle @@ -19,5 +19,6 @@ dependencies { jacocoAggregation projects.rlibPluginSystem jacocoAggregation projects.rlibReference jacocoAggregation projects.rlibReusable + jacocoAggregation projects.rlibEventbus jacocoAggregation projects.rlibTestcontainers }