From 6d087650f98c2303b6312494a0023a1817144acd Mon Sep 17 00:00:00 2001 From: Antonio Ramirez Date: Thu, 28 May 2026 13:02:18 +0200 Subject: [PATCH] re #97 add PSR-14 object-based dispatcher to Altair\Happen Implements PSR-14's object-based interfaces alongside the existing name-keyed EventDispatcher, with no BC break to the name-based surface. A single class cannot satisfy both Altair's dispatch(string, ?EventInterface): EventInterface and PSR-14's dispatch(object): object (same method name, incompatible signatures), so the standardization ships as separate classes (issue's design option 1): - Psr14EventDispatcher implements Psr\EventDispatcher\EventDispatcherInterface: dispatches an event object to its listeners, returns the same instance, and honours StoppableEventInterface (checked before each listener, so an already-stopped event invokes nothing). - Psr14ListenerProvider implements Psr\EventDispatcher\ListenerProviderInterface: type-keyed registration, inheritance/interface-aware matching, deterministic ordering (highest priority first, ties keep registration order via a global sequence counter). getListenersForEvent snapshots+sorts before yielding, so a listener registered mid-dispatch does not fire in the same cycle. Tested: dispatch-by-object, propagation halt, already-stopped short-circuit, priority + FIFO ordering, cross-type merge, inheritance/interface matching, mid-dispatch registration isolation. composer qa + rector dry-run green. --- src/Altair/Happen/Psr14EventDispatcher.php | 50 +++++++ src/Altair/Happen/Psr14ListenerProvider.php | 87 ++++++++++++ tests/Happen/Fixtures/BaseEvent.php | 9 ++ tests/Happen/Fixtures/DerivedEvent.php | 9 ++ tests/Happen/Fixtures/MarkerInterface.php | 9 ++ tests/Happen/Fixtures/StoppableEvent.php | 24 ++++ tests/Happen/Fixtures/UnrelatedEvent.php | 9 ++ tests/Happen/Psr14EventDispatcherTest.php | 148 ++++++++++++++++++++ tests/Happen/Psr14ListenerProviderTest.php | 127 +++++++++++++++++ 9 files changed, 472 insertions(+) create mode 100644 src/Altair/Happen/Psr14EventDispatcher.php create mode 100644 src/Altair/Happen/Psr14ListenerProvider.php create mode 100644 tests/Happen/Fixtures/BaseEvent.php create mode 100644 tests/Happen/Fixtures/DerivedEvent.php create mode 100644 tests/Happen/Fixtures/MarkerInterface.php create mode 100644 tests/Happen/Fixtures/StoppableEvent.php create mode 100644 tests/Happen/Fixtures/UnrelatedEvent.php create mode 100644 tests/Happen/Psr14EventDispatcherTest.php create mode 100644 tests/Happen/Psr14ListenerProviderTest.php 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); + } +}