$listener]); $registry = new EventListenerRegistry(); $registry->listen('test', 'test.event', RecordingListener::class, 'immediate'); $registry->listen( 'test', 'test.event', RecordingListener::class, 'deferred', DeliveryMode::Deferred, ); $registry->freeze(); self::assertSame(0, $container->resolutions); $dispatcher = new EventDispatcher($registry, $container, new NullLogger()); $dispatcher->beginExecution('first'); $dispatcher->dispatch(new Event('test.event')); self::assertSame(1, $listener->immediate); self::assertSame(0, $listener->deferred); $result = $dispatcher->processDeferred('first'); self::assertSame(1, $result->processed); self::assertSame(1, $listener->deferred); $dispatcher->beginExecution('second'); $second = $dispatcher->processDeferred('second'); self::assertSame(0, $second->processed); self::assertSame(0, $second->remaining); } #[Test] #[TestDox('Frozen registries reject new listeners')] public function freezes(): void { $registry = new EventListenerRegistry(); $registry->freeze(); $this->expectException(\LogicException::class); $registry->listen('test', 'test.event', RecordingListener::class, 'immediate'); } #[Test] #[TestDox('Duplicate listener registrations are rejected')] public function rejectsDuplicates(): void { $registry = new EventListenerRegistry(); $registry->listen('test', 'test.event', RecordingListener::class, 'immediate'); $this->expectException(\LogicException::class); $registry->listen('test', 'test.event', RecordingListener::class, 'immediate'); } #[Test] #[TestDox('Invalid listener methods are rejected during registration')] public function validatesMethods(): void { $registry = new EventListenerRegistry(); $this->expectException(\InvalidArgumentException::class); $registry->listen('test', 'test.event', RecordingListener::class, 'missing'); } #[Test] #[TestDox('Unresolvable listener services fail registry compilation')] public function validatesServices(): void { $registry = new EventListenerRegistry(); $registry->listen('test', 'test.event', RecordingListener::class, 'immediate'); $this->expectException(\LogicException::class); $registry->freeze(new RecordingContainer([])); } #[Test] #[TestDox('Listener priority determines dispatch order')] public function prioritizes(): void { $listener = new RecordingListener(); $registry = new EventListenerRegistry(); $registry->listen('test', 'test.event', RecordingListener::class, 'low', priority: 1); $registry->listen('test', 'test.event', RecordingListener::class, 'high', priority: 100); $registry->freeze(); $dispatcher = new EventDispatcher( $registry, new RecordingContainer([RecordingListener::class => $listener]), new NullLogger(), ); $dispatcher->beginExecution('test'); $dispatcher->dispatch(new Event('test.event')); $dispatcher->processDeferred('test'); self::assertSame(['high', 'low'], $listener->order); } #[Test] #[TestDox('Continue failures do not prevent later listeners')] public function continues(): void { $listener = new RecordingListener(); $registry = new EventListenerRegistry(); $registry->listen('test', 'test.event', FailingListener::class, 'fail'); $registry->listen('test', 'test.event', RecordingListener::class, 'immediate'); $registry->freeze(); $dispatcher = new EventDispatcher( $registry, new RecordingContainer([ FailingListener::class => new FailingListener(), RecordingListener::class => $listener, ]), new NullLogger(), ); $dispatcher->beginExecution('test'); $dispatcher->dispatch(new Event('test.event')); self::assertSame(1, $listener->immediate); } #[Test] #[TestDox('Propagate failures reach the publisher')] public function propagates(): void { $registry = new EventListenerRegistry(); $registry->listen( 'test', 'test.event', FailingListener::class, 'fail', failurePolicy: FailurePolicy::Propagate, ); $registry->freeze(); $dispatcher = new EventDispatcher( $registry, new RecordingContainer([FailingListener::class => new FailingListener()]), new NullLogger(), ); $dispatcher->beginExecution('test'); $this->expectException(\RuntimeException::class); $dispatcher->dispatch(new Event('test.event')); } #[Test] #[TestDox('Deferred processing stops at its configured count limit')] public function boundsDeferredWork(): void { $listener = new RecursiveListener(); $registry = new EventListenerRegistry(); $registry->listen( 'test', 'test.event', RecursiveListener::class, 'deferred', DeliveryMode::Deferred, ); $registry->freeze(); $dispatcher = new EventDispatcher( $registry, new RecordingContainer([RecursiveListener::class => $listener]), new NullLogger(), ); $listener->dispatcher = $dispatcher; $dispatcher->beginExecution('test'); $dispatcher->dispatch(new Event('test.event')); $result = $dispatcher->processDeferred('test'); self::assertSame(1000, $result->processed); self::assertSame(1, $result->remaining); self::assertFalse($result->deadlineExceeded); self::assertTrue($result->limitExceeded); } } final class RecordingListener { public int $immediate = 0; public int $deferred = 0; public array $order = []; public function immediate(Event $event): void { $this->immediate++; } public function deferred(Event $event): void { $this->deferred++; } public function high(Event $event): void { $this->order[] = 'high'; } public function low(Event $event): void { $this->order[] = 'low'; } } final class FailingListener { public function fail(Event $event): void { throw new \RuntimeException('Listener failed.'); } } final class RecursiveListener { public EventDispatcher $dispatcher; public function deferred(Event $event): void { $this->dispatcher->dispatch(new Event('test.event')); } } final class RecordingContainer implements ContainerInterface { public int $resolutions = 0; public function __construct( private readonly array $services, ) { } public function get(string $id): mixed { $this->resolutions++; return $this->services[$id] ?? throw new \RuntimeException("Service not found: {$id}"); } public function has(string $id): bool { return array_key_exists($id, $this->services); } }