diff --git a/src/Altair/Happen/Psr14EventDispatcher.php b/src/Altair/Happen/Psr14EventDispatcher.php new file mode 100644 index 00000000..bf76cec8 --- /dev/null +++ b/src/Altair/Happen/Psr14EventDispatcher.php @@ -0,0 +1,50 @@ +provider->getListenersForEvent($event) as $listener) { + if ($stoppable && $event->isPropagationStopped()) { + break; + } + + $listener($event); + } + + return $event; + } +} diff --git a/src/Altair/Happen/Psr14ListenerProvider.php b/src/Altair/Happen/Psr14ListenerProvider.php new file mode 100644 index 00000000..0c2c13ec --- /dev/null +++ b/src/Altair/Happen/Psr14ListenerProvider.php @@ -0,0 +1,87 @@ +> + */ + private array $listeners = []; + + private int $sequence = 0; + + /** + * Register a listener for an event type (class or interface name). + * + * @param class-string $eventType + */ + public function listen( + string $eventType, + callable $listener, + int $priority = self::NORMAL_PRIORITY + ): self { + $this->listeners[$eventType][] = [ + 'priority' => $priority, + 'sequence' => $this->sequence++, + 'listener' => $listener, + ]; + + return $this; + } + + /** + * @return iterable + */ + #[Override] + public function getListenersForEvent(object $event): iterable + { + $matched = []; + foreach ($this->listeners as $type => $entries) { + if ($event instanceof $type) { + foreach ($entries as $entry) { + $matched[] = $entry; + } + } + } + + usort( + $matched, + static fn(array $a, array $b): int => $b['priority'] <=> $a['priority'] + ?: $a['sequence'] <=> $b['sequence'], + ); + + foreach ($matched as $entry) { + yield $entry['listener']; + } + } +} diff --git a/tests/Happen/Fixtures/BaseEvent.php b/tests/Happen/Fixtures/BaseEvent.php new file mode 100644 index 00000000..322efcf3 --- /dev/null +++ b/tests/Happen/Fixtures/BaseEvent.php @@ -0,0 +1,9 @@ +stopped = true; + } + + public function isPropagationStopped(): bool + { + return $this->stopped; + } +} diff --git a/tests/Happen/Fixtures/UnrelatedEvent.php b/tests/Happen/Fixtures/UnrelatedEvent.php new file mode 100644 index 00000000..d1cf6ce7 --- /dev/null +++ b/tests/Happen/Fixtures/UnrelatedEvent.php @@ -0,0 +1,9 @@ +assertSame($event, $dispatcher->dispatch($event)); + } + + public function testDispatchInvokesMatchingListenerWithTheEvent(): void + { + $received = null; + $provider = new Psr14ListenerProvider(); + $provider->listen(BaseEvent::class, static function (BaseEvent $e) use (&$received): void { + $received = $e; + }); + $dispatcher = new Psr14EventDispatcher($provider); + + $event = new BaseEvent(); + $dispatcher->dispatch($event); + + $this->assertSame($event, $received); + } + + public function testDispatchInvokesNothingWhenNoListenersMatch(): void + { + $dispatcher = new Psr14EventDispatcher(new Psr14ListenerProvider()); + $event = new BaseEvent(); + + $this->assertSame($event, $dispatcher->dispatch($event)); + } + + public function testDispatchInvokesListenersInPriorityOrder(): void + { + $calls = []; + $provider = new Psr14ListenerProvider(); + $provider->listen(BaseEvent::class, static function () use (&$calls): void { + $calls[] = 'normal'; + }); + $provider->listen(BaseEvent::class, static function () use (&$calls): void { + $calls[] = 'high'; + }, Psr14ListenerProvider::HIGH_PRIORITY); + $provider->listen(BaseEvent::class, static function () use (&$calls): void { + $calls[] = 'low'; + }, Psr14ListenerProvider::LOW_PRIORITY); + $dispatcher = new Psr14EventDispatcher($provider); + + $dispatcher->dispatch(new BaseEvent()); + + $this->assertSame(['high', 'normal', 'low'], $calls); + } + + public function testStoppableEventHaltsRemainingListeners(): void + { + $calls = []; + $provider = new Psr14ListenerProvider(); + $provider->listen(StoppableEvent::class, static function (StoppableEvent $e) use (&$calls): void { + $calls[] = 'first'; + $e->stop(); + }); + $provider->listen(StoppableEvent::class, static function () use (&$calls): void { + $calls[] = 'second'; + }); + $dispatcher = new Psr14EventDispatcher($provider); + + $dispatcher->dispatch(new StoppableEvent()); + + $this->assertSame(['first'], $calls); + } + + public function testNonStoppableEventInvokesAllListeners(): void + { + $calls = []; + $provider = new Psr14ListenerProvider(); + $provider->listen(BaseEvent::class, static function () use (&$calls): void { + $calls[] = 'first'; + }); + $provider->listen(BaseEvent::class, static function () use (&$calls): void { + $calls[] = 'second'; + }); + $dispatcher = new Psr14EventDispatcher($provider); + + $dispatcher->dispatch(new BaseEvent()); + + $this->assertSame(['first', 'second'], $calls); + } + + public function testAlreadyStoppedEventInvokesNoListeners(): void + { + $calls = []; + $provider = new Psr14ListenerProvider(); + $provider->listen(StoppableEvent::class, static function () use (&$calls): void { + $calls[] = 'listener'; + }); + $dispatcher = new Psr14EventDispatcher($provider); + + $dispatcher->dispatch(new StoppableEvent(stopped: true)); + + $this->assertSame([], $calls); + } + + public function testInheritanceMatchingDispatchesParentListenersToSubclass(): void + { + $calls = []; + $provider = new Psr14ListenerProvider(); + $provider->listen(BaseEvent::class, static function () use (&$calls): void { + $calls[] = 'base'; + }); + $dispatcher = new Psr14EventDispatcher($provider); + + $dispatcher->dispatch(new DerivedEvent()); + + $this->assertSame(['base'], $calls); + } + + public function testListenerRegisteredDuringDispatchDoesNotFireInTheSameCycle(): void + { + $calls = []; + $provider = new Psr14ListenerProvider(); + $provider->listen(BaseEvent::class, static function () use ($provider, &$calls): void { + $calls[] = 'first'; + $provider->listen(BaseEvent::class, static function () use (&$calls): void { + $calls[] = 'registered-during-dispatch'; + }); + }); + $dispatcher = new Psr14EventDispatcher($provider); + + $dispatcher->dispatch(new BaseEvent()); + + $this->assertSame(['first'], $calls); + } +} diff --git a/tests/Happen/Psr14ListenerProviderTest.php b/tests/Happen/Psr14ListenerProviderTest.php new file mode 100644 index 00000000..7d645329 --- /dev/null +++ b/tests/Happen/Psr14ListenerProviderTest.php @@ -0,0 +1,127 @@ + + */ + private function listenersFor(Psr14ListenerProvider $provider, object $event): array + { + return iterator_to_array($provider->getListenersForEvent($event), false); + } + + public function testReturnsNoListenersWhenNoneRegistered(): void + { + $provider = new Psr14ListenerProvider(); + + $this->assertSame([], $this->listenersFor($provider, new UnrelatedEvent())); + } + + public function testReturnsListenerRegisteredForTheEventType(): void + { + $provider = new Psr14ListenerProvider(); + $listener = static function (BaseEvent $e): void { + }; + + $provider->listen(BaseEvent::class, $listener); + + $this->assertSame([$listener], $this->listenersFor($provider, new BaseEvent())); + } + + public function testDoesNotReturnListenersForUnrelatedEvents(): void + { + $provider = new Psr14ListenerProvider(); + $provider->listen(BaseEvent::class, static function (): void { + }); + + $this->assertSame([], $this->listenersFor($provider, new UnrelatedEvent())); + } + + public function testSubclassEventReceivesParentClassListeners(): void + { + $provider = new Psr14ListenerProvider(); + $listener = static function (BaseEvent $e): void { + }; + $provider->listen(BaseEvent::class, $listener); + + $this->assertSame([$listener], $this->listenersFor($provider, new DerivedEvent())); + } + + public function testInterfaceListenersMatchImplementingEvents(): void + { + $provider = new Psr14ListenerProvider(); + $listener = static function (MarkerInterface $e): void { + }; + $provider->listen(MarkerInterface::class, $listener); + + $this->assertSame([$listener], $this->listenersFor($provider, new BaseEvent())); + } + + public function testListenersAreOrderedByPriorityHighestFirst(): void + { + $provider = new Psr14ListenerProvider(); + $normal = static function (): void { + }; + $high = static function (): void { + }; + $low = static function (): void { + }; + + $provider->listen(BaseEvent::class, $normal); + $provider->listen(BaseEvent::class, $high, Psr14ListenerProvider::HIGH_PRIORITY); + $provider->listen(BaseEvent::class, $low, Psr14ListenerProvider::LOW_PRIORITY); + + $this->assertSame([$high, $normal, $low], $this->listenersFor($provider, new BaseEvent())); + } + + public function testEqualPriorityListenersKeepRegistrationOrder(): void + { + $provider = new Psr14ListenerProvider(); + $first = static function (): void { + }; + $second = static function (): void { + }; + + $provider->listen(BaseEvent::class, $first); + $provider->listen(BaseEvent::class, $second); + + $this->assertSame([$first, $second], $this->listenersFor($provider, new BaseEvent())); + } + + public function testListenersAcrossMatchingTypesAreMergedAndSortedByPriority(): void + { + $provider = new Psr14ListenerProvider(); + $baseNormal = static function (): void { + }; + $derivedHigh = static function (): void { + }; + + $provider->listen(BaseEvent::class, $baseNormal); + $provider->listen(DerivedEvent::class, $derivedHigh, Psr14ListenerProvider::HIGH_PRIORITY); + + $this->assertSame([$derivedHigh, $baseNormal], $this->listenersFor($provider, new DerivedEvent())); + } + + public function testListenReturnsSelfForChaining(): void + { + $provider = new Psr14ListenerProvider(); + + $result = $provider->listen(BaseEvent::class, static function (): void { + }); + + $this->assertSame($provider, $result); + } +}