diff --git a/composer.json b/composer.json index c444b5a..97a09de 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ ], "require": { "php": ">=7.2", - "fastforward/factories": "^1.0.0" + "fastforward/data-structures": "^1.0.0", + "fastforward/factories": "^1.2.0" }, "require-dev": { "phpunit/phpunit": "^8" diff --git a/readme.md b/readme.md index 4a03b4f..2632a14 100644 --- a/readme.md +++ b/readme.md @@ -16,7 +16,8 @@ fast and easy. It addresses the common tasks and provides configurable and/or ex to use. Currently **FF** is composed of the following features: -1. Services and a Service Factory +1. Services and the Service Factory +2. Events and the Event Broker More features will follow (see the Road Map section below). @@ -59,7 +60,7 @@ of certain domain. Common attributes of services should be ## Writing services [**CONVENTION**] Service classes extend `FF\Services\AbstractService`. -[**CONVENTION**] Services should be located in your project's `MyProject\Services` namespace (or any sub namespace +[**CONVENTION**] Services must be located in your project's `MyProject\Services` namespace (or any sub namespace thereof) to be found by the `ServicesFactory`. ***Example: Basic service implementation*** @@ -115,7 +116,7 @@ For convenience reasons there is a shorthand class `SF` that lets you retrieve o ***Example: Getting a single service*** - use FF\Services\SF; + use FF\Factories\SF; use MyProject\Services\MyService; /** @var MyService $myService */ @@ -123,8 +124,8 @@ For convenience reasons there is a shorthand class `SF` that lets you retrieve o ***Example: Getting multiple services at once*** - use FF\Services\Events\EventBroker; - use FF\Services\SF; + use FF\Factories\SF; + use FF\Services\Events\EventBroker; use MyProject\Services\MyService; /** @var EventBroker $eventBroker */ @@ -160,10 +161,115 @@ naming conventions and registering the `ServiceFactory` as shown in the **Bootst /** @var MyProject\Services\Runtime\ExceptionHandler $exceptionHandler */ $exceptionHandler = SF::get('Runtime\ExceptionHandler'); +# Events and the Event Broker + +This feature provides a basic observer/observable pattern implementation. The key class is the `EventBroker`. Any +other class may act as an observable by firing events using the `EventBroker`'s api. Other classes my act as observers +by subscribing to specific types of events and being notified by the `EventBroker` in each time this type of event was +fired. + +## Firing Events + +If an observable wants to notify potential observers of notable changes it simply fires a suitable event using the +`EventBroker`'s api. + +***Example: Fire an event*** + + use FF\Factories\SF; + use FF\Services\Events\EventBroker; + + class MyExceptionHandler + { + /** + * Generic exception handler callback + * + * @param \Throwable $e + * @see http://php.net/set_exception_handler + */ + public function onException(\Throwable $e) + { + try { + /** @var EventBroker $eventBroker */ + $eventBroker = SF::i()->get('Events\EventBroker'); + $eventBroker->fire('Runtime\Exception', $e); + } catch (\Exception $e) { + // do not handle exceptions thrown while + // processing the on-exception event + } + } + } + +The `fire` method of the `EventBroker` uses the `EventsFactory` to instantiate the actual event object passing any +additional argument provided to the event class's constructor. + +## Subscribing to Events + +A valid event handling method must meet the following requirements: + +- must be public +- must not be static or abstract +- accept exactly one argument: the event classes instance + +***Example: Subscribe to an event*** + + use FF\Events; + use FF\Factories\SF; + use FF\Services\Events\EventBroker; + + class MyErrorObserver + { + /** + * Event handling callback + * + * @param Runtime\Error $event + public function onRuntimeError(Runtime\Error $event) + { + // handle the Error event + var_dump( + $event->getErrNo(), + $event->getErrMsg(), + $event->getErrFile(), + $event->getErrLine() + ); + } + } + + // subscribe to the Runtime\Error event + /** @var EventBroker $eventBroker */ + $eventBroker = SF::i()->get('Events\EventBroker'); + $eventBroker->subscribe([new MyErrorObserver, 'onRuntimeError'], 'Runtime\Error'); + +The subscription is bases on the class identifier of the event class. This is exactly the same string to use by the +observable when firing the event. + +## Defining Custom Events + +[**CONVENTION**] Event classes extend `FF\Events\AbstractEvent`. +[**CONVENTION**] Events must be located in your project's `MyProject\Events` namespace (or any sub namespace +thereof) to be found by the `EventsFactory`. + +***Example: A custom event*** + + namespace MyProject\Events; + + use FF\Events\AbstractEvent; + + /** + * This event's class identifier would just be 'Logoff' + */ + class Logoff extends AbstractEvent + { + /** + * Define constructor arguments (the event data) to meet your needs. + */ + public function __construct($eventData) + { + var_dump($eventData); + } + } # Road map -- Events - Runtime - Controllers - Sessions diff --git a/src/Events/AbstractEvent.php b/src/Events/AbstractEvent.php new file mode 100644 index 0000000..016558f --- /dev/null +++ b/src/Events/AbstractEvent.php @@ -0,0 +1,70 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Events; + +use FF\Factories\ClassLocators\ClassIdentifierAwareInterface; + +/** + * Class AbstractEvent + * + * @package FF\Events + */ +abstract class AbstractEvent implements ClassIdentifierAwareInterface +{ + /** + * For use with the BaseNamespaceClassLocator of the EventsFactory + */ + const COMMON_NS_SUFFIX = 'Events'; + + /** + * @var bool + */ + protected $isCanceled = false; + + /** + * @return bool + */ + public function isCanceled(): bool + { + return $this->isCanceled; + } + + /** + * @param bool $isCanceled + * @return $this + */ + public function setIsCanceled(bool $isCanceled) + { + $this->isCanceled = $isCanceled; + return $this; + } + + /** + * @return $this + */ + public function cancel() + { + return $this->setIsCanceled(true); + } + + /** + * {@inheritDoc} + */ + public static function getClassIdentifier(): string + { + $className = get_called_class(); + $needle = '\\' . self::COMMON_NS_SUFFIX . '\\'; + $pos = strpos($className, $needle); + if ($pos === false) return $className; + + return substr($className, $pos + strlen($needle)); + } +} \ No newline at end of file diff --git a/src/Factories/EventsFactory.php b/src/Factories/EventsFactory.php new file mode 100644 index 0000000..f398f8a --- /dev/null +++ b/src/Factories/EventsFactory.php @@ -0,0 +1,81 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Factories; + +use FF\Events\AbstractEvent; +use FF\Factories\ClassLocators\BaseNamespaceClassLocator; +use FF\Factories\ClassLocators\ClassLocatorInterface; + +/** + * Class EventsFactory + * + * @package FF\Factories + */ +class EventsFactory extends AbstractFactory +{ + /** + * @var EventsFactory + */ + protected static $instance; + + /** + * Declared protected to prevent external usage. + * Uses a BaseNamespaceClassLocator pre-configured with the 'Events' as common suffix and the FF namespace. + * + * @see \FF\Factories\ClassLocators\BaseNamespaceClassLocator + */ + protected function __construct() + { + parent::__construct(new BaseNamespaceClassLocator(AbstractEvent::COMMON_NS_SUFFIX, 'FF')); + } + + /** + * Declared protected to prevent external usage + */ + protected function __clone() + { + + } + + /** + * {@inheritDoc} + * @return BaseNamespaceClassLocator + */ + public function getClassLocator(): ClassLocatorInterface + { + return parent::getClassLocator(); + } + + /** + * Retrieves the singleton instance of this class + * + * @return EventsFactory + */ + public static function getInstance(): EventsFactory + { + if (is_null(self::$instance)) { + self::$instance = new EventsFactory(); + } + + return self::$instance; + } + + /** + * {@inheritdoc} + * @return AbstractEvent + */ + public function create(string $classIdentifier, ...$args) + { + /** @var AbstractEvent $event */ + $event = parent::create($classIdentifier, ...$args); + return $event; + } +} \ No newline at end of file diff --git a/src/Services/SF.php b/src/Factories/SF.php similarity index 95% rename from src/Services/SF.php rename to src/Factories/SF.php index 2fa7c42..122d9e1 100644 --- a/src/Services/SF.php +++ b/src/Factories/SF.php @@ -8,7 +8,7 @@ */ declare(strict_types=1); -namespace FF\Services; +namespace FF\Factories; /** * Class SF diff --git a/src/Services/ServicesFactory.php b/src/Factories/ServicesFactory.php similarity index 87% rename from src/Services/ServicesFactory.php rename to src/Factories/ServicesFactory.php index b4e1fae..6f9dab0 100644 --- a/src/Services/ServicesFactory.php +++ b/src/Factories/ServicesFactory.php @@ -8,18 +8,18 @@ */ declare(strict_types=1); -namespace FF\Services; +namespace FF\Factories; -use FF\Factories\AbstractSingletonFactory; +use FF\Factories\ClassLocators\BaseNamespaceClassLocator; use FF\Factories\ClassLocators\ClassLocatorInterface; -use FF\Factories\ClassLocators\NamespaceClassLocator; use FF\Factories\Exceptions\ClassNotFoundException; +use FF\Services\AbstractService; use FF\Services\Exceptions\ConfigurationException; /** * Class ServicesFactory * - * @package FF\Services + * @package FF\Factories */ class ServicesFactory extends AbstractSingletonFactory { @@ -34,13 +34,14 @@ class ServicesFactory extends AbstractSingletonFactory protected $servicesOptions; /** - * Uses a NamespaceClassLocator pre-configured with the FF\Services namespace. + * Uses a BaseNamespaceClassLocator pre-configured with the 'Services' as common suffix and the FF namespace. + * * @param array $servicesOptions - * @see \FF\Factories\ClassLocators\NamespaceClassLocator + * @see \FF\Factories\ClassLocators\BaseNamespaceClassLocator */ public function __construct(array $servicesOptions = []) { - parent::__construct(new NamespaceClassLocator(__NAMESPACE__)); + parent::__construct(new BaseNamespaceClassLocator(AbstractService::COMMON_NS_SUFFIX, 'FF')); $this->servicesOptions = $servicesOptions; } @@ -116,7 +117,7 @@ class ServicesFactory extends AbstractSingletonFactory /** * {@inheritDoc} - * @return NamespaceClassLocator + * @return BaseNamespaceClassLocator */ public function getClassLocator(): ClassLocatorInterface { diff --git a/src/Services/AbstractService.php b/src/Services/AbstractService.php index 29f1495..8c5ab33 100644 --- a/src/Services/AbstractService.php +++ b/src/Services/AbstractService.php @@ -10,16 +10,24 @@ declare(strict_types=1); namespace FF\Services; +use FF\Factories\ClassLocators\ClassIdentifierAwareInterface; use FF\Services\Exceptions\ConfigurationException; +use FF\Services\Traits\EventEmitterTrait; +use FF\Services\Traits\ServiceLocatorTrait; /** * Class AbstractService * * @package FF\Services */ -abstract class AbstractService +abstract class AbstractService implements ClassIdentifierAwareInterface { - use ServiceLocatorTrait; + use EventEmitterTrait, ServiceLocatorTrait; + + /** + * For use with the BaseNamespaceClassLocator of the ServicesFactory + */ + const COMMON_NS_SUFFIX = 'Services'; /** * @var array @@ -57,6 +65,19 @@ abstract class AbstractService return $this->options[$key] ?? $default; } + /** + * {@inheritDoc} + */ + public static function getClassIdentifier(): string + { + $className = get_called_class(); + $needle = '\\' . self::COMMON_NS_SUFFIX . '\\'; + $pos = strpos($className, $needle); + if ($pos === false) return $className; + + return substr($className, $pos + strlen($needle)); + } + /** * Initializes the service * diff --git a/src/Services/Events/EventBroker.php b/src/Services/Events/EventBroker.php new file mode 100644 index 0000000..26d99ac --- /dev/null +++ b/src/Services/Events/EventBroker.php @@ -0,0 +1,245 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Services\Events; + +use FF\DataStructures\IndexedCollection; +use FF\DataStructures\OrderedCollection; +use FF\Events\AbstractEvent; +use FF\Factories\EventsFactory; +use FF\Factories\Exceptions\ClassNotFoundException; +use FF\Services\AbstractService; + +/** + * Class EventBroker + * + * @package FF\Services + */ +class EventBroker extends AbstractService +{ + /** + * @var IndexedCollection + */ + protected $subscriptions; + + /** + * @var EventsFactory + */ + protected $eventsFactory; + + /** + * {@inheritDoc} + */ + protected function initialize(array $options) + { + parent::initialize($options); + + $this->subscriptions = new IndexedCollection(); + $this->eventsFactory = EventsFactory::getInstance(); + } + + /** + * @return EventsFactory + */ + public function getEventsFactory(): EventsFactory + { + return $this->eventsFactory; + } + + /** + * @return IndexedCollection + */ + public function getSubscriptions(): IndexedCollection + { + return $this->subscriptions; + } + + /** + * @param string $classIdentifier + * @return OrderedCollection + */ + public function getSubscribers(string $classIdentifier): OrderedCollection + { + $this->initializeSubscribersCollection($classIdentifier); + return $this->subscriptions->get($classIdentifier); + } + + /** + * Appends a listener to the subscribers list of an event + * + * Removes any previous subscriptions of the listener first to the named event. + * + * @param callable $listener + * @param string $classIdentifier + * @return $this + */ + public function subscribe(callable $listener, string $classIdentifier) + { + $this->unsubscribe($listener, $classIdentifier); + $this->initializeSubscribersCollection($classIdentifier); + + $this->subscriptions->get($classIdentifier)->push($listener); + return $this; + } + + /** + * Prepends a listener to the subscriber list of an event + * + * Removes any previous subscriptions of the listener first to the named event. + * + * @param callable $listener + * @param string $classIdentifier + * @return $this + */ + public function subscribeFirst(callable $listener, string $classIdentifier) + { + $this->unsubscribe($listener, $classIdentifier); + $this->initializeSubscribersCollection($classIdentifier); + + $this->subscriptions->get($classIdentifier)->unshift($listener); + return $this; + } + + /** + * Unsubscribes a listener + * + * If $name is omitted, the listener will be unsubscribed from each event it was subscribed to. + * + * @param callable $listener + * @param string $classIdentifier + * @return $this + */ + public function unsubscribe(callable $listener, string $classIdentifier = null) + { + /** @var OrderedCollection $listenerCollection */ + foreach ($this->subscriptions as $name => $listenerCollection) { + if (!is_null($classIdentifier) && $classIdentifier != $name) continue; + + $index = $listenerCollection->search($listener, true); + if (is_null($index)) continue; + + // remove listener from event + unset($listenerCollection[$index]); + } + + return $this; + } + + /** + * Removes all subscriptions for this event + * + * @param string $classIdentifier + * @return $this + */ + public function unsubscribeAll(string $classIdentifier) + { + unset($this->subscriptions[$classIdentifier]); + return $this; + } + + /** + * Checks whether any listeners where subscribed to the named event + * + * @param string $classIdentifier + * @return bool + */ + public function hasSubscribers(string $classIdentifier): bool + { + return $this->subscriptions->has($classIdentifier) && !$this->subscriptions->get($classIdentifier)->isEmpty(); + } + + /** + * Checks of the listener has been subscribed to the given event + * + * @param callable $listener + * @param string $classIdentifier + * @return bool + */ + public function isSubscribed(callable $listener, string $classIdentifier): bool + { + if (!$this->hasSubscribers($classIdentifier)) return false; + + return !is_null($this->subscriptions->get($classIdentifier)->search($listener)); + } + + /**Notifies all listeners of events of the given type + * + * Listeners will be notified in the order of their subscriptions. + * Does nothing if no listeners subscribed to the type of the event. + * + * Creates an event instance and fires it. + * Does nothing if no suitable event model could be created. + * + * Any given $args will be passed to the constructor of the suitable event + * model class in the given order. + * + * @param string $classIdentifier + * @param mixed ...$args + * @return $this + */ + public function fire(string $classIdentifier, ...$args) + { + $event = $this->createEvent($classIdentifier, ...$args); + if (is_null($event)) return $this; + + foreach ($this->getSubscribers($classIdentifier) as $listener) { + $this->notify($listener, $event); + + if ($event->isCanceled()) { + // stop notifying further listeners if event has been canceled + break; + } + } + + return $this; + } + + /** + * Initialize listener collection if necessary + * + * @param string $classIdentifier + */ + protected function initializeSubscribersCollection(string $classIdentifier) + { + if ($this->subscriptions->has($classIdentifier)) return; + + $this->subscriptions->set($classIdentifier, new OrderedCollection()); + } + + /** + * Create a fresh event instance + * + * @param string $classIdentifier + * @param mixed ...$args + * @return AbstractEvent|null + */ + protected function createEvent(string $classIdentifier, ...$args): ?AbstractEvent + { + try { + return $this->eventsFactory->create($classIdentifier, ...$args); + } catch (ClassNotFoundException $e) { + return null; + } + } + + /** + * Passes the event to the listener + * + * The listener will be invoked with the event as the first and only argument. + * Any return values of the listener will be discarded. + * + * @param callable $listener + * @param AbstractEvent $event + */ + protected function notify(callable $listener, AbstractEvent $event) + { + call_user_func($listener, $event); + } +} \ No newline at end of file diff --git a/src/Services/Traits/EventEmitterTrait.php b/src/Services/Traits/EventEmitterTrait.php new file mode 100644 index 0000000..2b8da54 --- /dev/null +++ b/src/Services/Traits/EventEmitterTrait.php @@ -0,0 +1,45 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Services\Traits; + +use FF\Factories\SF; +use FF\Services\Events\EventBroker; + +/** + * Trait EventEmitterTrait + * + * @package FF\Services\Traits + */ +trait EventEmitterTrait +{ + /** + * Creates an event instance and fires it + * + * Delegates the execution to the EventBroker provided by the ServiceFactory. + * + * @param string $classIdentifier + * @param mixed ...$args + * @return $this + */ + protected function fire(string $classIdentifier, ...$args) + { + /** @var EventBroker $eventBroker */ + static $eventBroker = null; + + if (is_null($eventBroker)) { + $eventBroker = SF::i()->get('Events\EventBroker'); + } + + $eventBroker->fire($classIdentifier, ...$args); + + return $this; + } +} \ No newline at end of file diff --git a/src/Services/ServiceLocatorTrait.php b/src/Services/Traits/ServiceLocatorTrait.php similarity index 82% rename from src/Services/ServiceLocatorTrait.php rename to src/Services/Traits/ServiceLocatorTrait.php index 8373ceb..f29e33d 100644 --- a/src/Services/ServiceLocatorTrait.php +++ b/src/Services/Traits/ServiceLocatorTrait.php @@ -8,12 +8,15 @@ */ declare(strict_types=1); -namespace FF\Services; +namespace FF\Services\Traits; + +use FF\Factories\SF; +use FF\Services\AbstractService; /** * Trait ServiceLocatorTrait * - * @package FF\Services + * @package FF\Services\Traits */ trait ServiceLocatorTrait { diff --git a/tests/Events/AbstractEventTest.php b/tests/Events/AbstractEventTest.php new file mode 100644 index 0000000..075ff80 --- /dev/null +++ b/tests/Events/AbstractEventTest.php @@ -0,0 +1,68 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Tests\Events; + +use FF\Events\AbstractEvent; +use PHPUnit\Framework\TestCase; + +/** + * Test AbstractEventTest + * + * @package FF\Tests + */ +class AbstractEventTest extends TestCase +{ + /** + * @var MyEvent + */ + protected $uut; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->uut = new MyEvent(); + } + + /** + * Tests the namesake method/feature + */ + public function testSetGetIsCanceled() + { + $same = $this->uut->setIsCanceled(true); + $this->assertSame($this->uut, $same); + $this->assertTrue($this->uut->isCanceled()); + } + + /** + * Tests the namesake method/feature + */ + public function testCancel() + { + $same = $this->uut->cancel(); + $this->assertSame($this->uut, $same); + $this->assertTrue($this->uut->isCanceled()); + } + + /** + * Tests the namesake method/feature + */ + public function testGetClassIdentifier() + { + $this->assertEquals('MyEvent', MyEvent::getClassIdentifier()); + } +} + +class MyEvent extends AbstractEvent +{ + +} \ No newline at end of file diff --git a/tests/Factories/EventsFactoryTest.php b/tests/Factories/EventsFactoryTest.php new file mode 100644 index 0000000..930b1f2 --- /dev/null +++ b/tests/Factories/EventsFactoryTest.php @@ -0,0 +1,41 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Tests\Factories; + +use FF\Factories\ClassLocators\BaseNamespaceClassLocator; +use FF\Factories\EventsFactory; +use PHPUnit\Framework\TestCase; + +/** + * Test EventsFactoryTest + * + * @package FF\Tests + */ +class EventsFactoryTest extends TestCase +{ + /** + * Tests the namesake method/feature + */ + public function testGetInstance() + { + $instance = EventsFactory::getInstance(); + $this->assertInstanceOf(EventsFactory::class, $instance); + $this->assertSame($instance, EventsFactory::getInstance()); + } + + /** + * Tests the namesake method/feature + */ + public function testGetClassLocator() + { + $this->assertInstanceOf(BaseNamespaceClassLocator::class, EventsFactory::getInstance()->getClassLocator()); + } +} \ No newline at end of file diff --git a/tests/Services/SFTest.php b/tests/Factories/SFTest.php similarity index 88% rename from tests/Services/SFTest.php rename to tests/Factories/SFTest.php index a4cc8dc..685049a 100644 --- a/tests/Services/SFTest.php +++ b/tests/Factories/SFTest.php @@ -8,10 +8,10 @@ */ declare(strict_types=1); -namespace FF\Tests\Services; +namespace FF\Tests\Factories; -use FF\Services\ServicesFactory; -use FF\Services\SF; +use FF\Factories\ServicesFactory; +use FF\Factories\SF; use PHPUnit\Framework\TestCase; /** diff --git a/tests/Factories/ServicesFactoryTest.php b/tests/Factories/ServicesFactoryTest.php new file mode 100644 index 0000000..12e3cde --- /dev/null +++ b/tests/Factories/ServicesFactoryTest.php @@ -0,0 +1,146 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Tests\Factories { + + use FF\Factories\ClassLocators\BaseNamespaceClassLocator; + use FF\Factories\Exceptions\ClassNotFoundException; + use FF\Factories\ServicesFactory; + use FF\Services\Exceptions\ConfigurationException; + use FF\Tests\Services\ServiceOne; + use FF\Tests\Services\ServiceTwo; + use PHPUnit\Framework\TestCase; + + /** + * Test ServicesFactoryTest + * + * @package FF\Tests + */ + class ServicesFactoryTest extends TestCase + { + const TEST_OPTIONS = ['ServiceOne' => ['foo' => 'bar']]; + + /** + * {@inheritdoc} + */ + public static function setUpBeforeClass(): void + { + ServicesFactory::clearInstance(); + } + + /** + * Tests the namesake method/feature + */ + public function testGetInstanceConfigException() + { + $this->expectException(ConfigurationException::class); + + ServicesFactory::getInstance(); + } + + /** + * Tests the namesake method/feature + */ + public function testSetGetInstance() + { + $instance = new ServicesFactory(self::TEST_OPTIONS); + $instance->getClassLocator()->prependNamespaces('FF\Tests'); + ServicesFactory::setInstance($instance); + + $this->assertSame($instance, ServicesFactory::getInstance()); + } + + /** + * Tests the namesake method/feature + */ + public function testGetClassLocator() + { + $this->assertInstanceOf( + BaseNamespaceClassLocator::class, + ServicesFactory::getInstance()->getClassLocator() + ); + } + + /** + * Tests the namesake method/feature + * + * @depends testSetGetInstance + */ + public function testSetServiceOptions() + { + $this->assertEquals( + self::TEST_OPTIONS['ServiceOne'], + ServicesFactory::getInstance()->getServiceOptions('ServiceOne') + ); + $this->assertEquals([], ServicesFactory::getInstance()->getServiceOptions('ServiceTwo')); + $this->assertEquals([], ServicesFactory::getInstance()->getServiceOptions('unknown')); + } + + /** + * Tests the namesake method/feature + * + * @depends testSetGetInstance + */ + public function testGetSingle() + { + $service = ServicesFactory::getInstance()->get('ServiceOne'); + $this->assertInstanceOf(ServiceOne::class, $service); + $this->assertEquals(self::TEST_OPTIONS['ServiceOne'], $service->getOptions()); + } + + /** + * Tests the namesake method/feature + * + * @depends testSetGetInstance + */ + public function testGetMultiples() + { + $services = ServicesFactory::getInstance()->get('ServiceOne', 'ServiceTwo'); + $this->assertEquals(2, count($services)); + $this->assertInstanceOf(ServiceOne::class, $services[0]); + $this->assertInstanceOf(ServiceTwo::class, $services[1]); + } + + /** + * Tests the namesake method/feature + * + * @depends testSetGetInstance + */ + public function testGetClassNotFound() + { + $this->expectException(ClassNotFoundException::class); + + ServicesFactory::getInstance()->get('ServiceUnknown'); + } + } +} + +namespace FF\Tests\Services { + + use FF\Services\AbstractService; + + class ServiceOne extends AbstractService + { + protected function validateOptions(array $options, array &$errors): bool + { + if (isset($options['foo']) && $options['foo'] != 'bar') { + $errors[] = 'foo is not bar'; + return false; + } + + return parent::validateOptions($options, $errors); + } + } + + class ServiceTwo extends AbstractService + { + + } +} \ No newline at end of file diff --git a/tests/Services/AbstractServiceTest.php b/tests/Services/AbstractServiceTest.php index 37090d7..067f7d9 100644 --- a/tests/Services/AbstractServiceTest.php +++ b/tests/Services/AbstractServiceTest.php @@ -71,6 +71,14 @@ class AbstractServiceTest extends TestCase new MyService(['foo' => 'baz']); } + + /** + * Tests the namesake method/feature + */ + public function testGetClassIdentifier() + { + $this->assertEquals('MyService', MyService::getClassIdentifier()); + } } class MyService extends AbstractService diff --git a/tests/Services/Events/EventBrokerTest.php b/tests/Services/Events/EventBrokerTest.php new file mode 100644 index 0000000..9ab656e --- /dev/null +++ b/tests/Services/Events/EventBrokerTest.php @@ -0,0 +1,288 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Tests\Services\Events; + +use FF\DataStructures\IndexedCollection; +use FF\DataStructures\OrderedCollection; +use FF\Events\AbstractEvent; +use FF\Factories\EventsFactory; +use FF\Services\Events\EventBroker; +use PHPUnit\Framework\TestCase; + +/** + * Test EventBrokerTest + * + * @package FF\Tests + */ +class EventBrokerTest extends TestCase +{ + /** + * @var EventBroker + */ + protected $uut; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->uut = new EventBroker(); + $this->uut->getEventsFactory() + ->getClassLocator() + ->prependNamespaces('FF\Tests\Services'); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void + { + $this->uut->unsubscribeAll('EventA'); + } + + /** + * Tests the namesake method/feature + */ + public function testGetEventsFactory() + { + $this->assertInstanceOf(EventsFactory::class, $this->uut->getEventsFactory()); + } + + /** + * Tests the namesake method/feature + */ + public function testGetSubscriptions() + { + $this->assertInstanceOf(IndexedCollection::class, $this->uut->getSubscriptions()); + } + + /** + * Tests the namesake method/feature + */ + public function testGetSubscribers() + { + $this->assertInstanceOf(OrderedCollection::class, $this->uut->getSubscribers('EventA')); + } + + /** + * Tests the namesake method/feature + */ + public function testSubscribe() + { + $callback = [new ListenerA(), 'shoutA']; + + $same = $this->uut->subscribe($callback, 'EventA'); + $this->assertSame($this->uut, $same); + $this->assertEquals(1, count($this->uut->getSubscribers('EventA'))); + $this->assertSame($callback, $this->uut->getSubscribers('EventA')->getFirst()); + } + + /** + * Tests the namesake method/feature + */ + public function testSubscribeRepeated() + { + $callback = [new ListenerA(), 'shoutA']; + + $this->uut->subscribe($callback, 'EventA')->subscribe($callback, 'EventA'); + $this->assertEquals(1, count($this->uut->getSubscribers('EventA'))); + } + + /** + * Tests the namesake method/feature + */ + public function testSubscribeAppend() + { + $callback1 = [new ListenerA(), 'shoutA']; + $callback2 = [new ListenerA(), 'shoutA']; + + $this->uut->subscribe($callback1, 'EventA') + ->subscribe($callback2, 'EventA'); + $this->assertSame($callback1, $this->uut->getSubscribers('EventA')->get(0)); + $this->assertSame($callback2, $this->uut->getSubscribers('EventA')->get(1)); + } + + /** + * Tests the namesake method/feature + */ + public function testSubscribeFirst() + { + $callback1 = [new ListenerA(), 'shoutA']; + $callback2 = [new ListenerA(), 'shoutA']; + + $same = $this->uut->subscribe($callback1, 'EventA') + ->subscribeFirst($callback2, 'EventA'); + $this->assertSame($this->uut, $same); + $this->assertSame($callback1, $this->uut->getSubscribers('EventA')->get(1)); + $this->assertSame($callback2, $this->uut->getSubscribers('EventA')->get(0)); + } + + /** + * Tests the namesake method/feature + */ + public function testUnsubscribe() + { + $callback1 = [new ListenerA(), 'shoutA']; + + $this->uut->subscribe($callback1, 'EventA') + ->subscribe($callback1, 'EventB') + ->unsubscribe($callback1); + $this->assertTrue($this->uut->getSubscribers('EventA')->isEmpty()); + $this->assertTrue($this->uut->getSubscribers('EventB')->isEmpty()); + } + + /** + * Tests the namesake method/feature + */ + public function testUnsubscribeNamed() + { + $callback1 = [new ListenerA(), 'shoutA']; + $callback2 = [new ListenerA(), 'shoutA']; + + $same = $this->uut->subscribe($callback1, 'EventA') + ->subscribe($callback2, 'EventA') + ->unsubscribe($callback1, 'EventA'); + $this->assertSame($this->uut, $same); + $this->assertEquals(1, count($this->uut->getSubscribers('EventA'))); + $this->assertSame($callback2, $this->uut->getSubscribers('EventA')->get(0)); + } + + /** + * Tests the namesake method/feature + */ + public function testUnsubscribeAll() + { + $callback1 = [new ListenerA(), 'shoutA']; + $callback2 = [new ListenerA(), 'shoutA']; + + $same = $this->uut->subscribe($callback1, 'EventA') + ->subscribe($callback2, 'EventA') + ->unsubscribeAll('EventA'); + $this->assertSame($this->uut, $same); + $this->assertTrue($this->uut->getSubscribers('EventA')->isEmpty()); + } + + /** + * Tests the namesake method/feature + */ + public function testHasSubscribers() + { + $callback1 = [new ListenerA(), 'shoutA']; + + $this->uut->subscribe($callback1, 'EventA'); + + $this->assertTrue($this->uut->hasSubscribers('EventA')); + $this->assertFalse($this->uut->hasSubscribers('EventB')); + } + + /** + * Tests the namesake method/feature + */ + public function testIsSubscribed() + { + $callback1 = [new ListenerA(), 'shoutA']; + $callback2 = [new ListenerA(), 'shoutB']; + + $this->uut->subscribe($callback1, 'EventA'); + + $this->assertTrue($this->uut->isSubscribed($callback1, 'EventA')); + $this->assertFalse($this->uut->isSubscribed($callback1, 'EventB')); + $this->assertFalse($this->uut->isSubscribed($callback2, 'EventA')); + $this->assertFalse($this->uut->isSubscribed($callback2, 'EventB')); + } + + /** + * Tests the namesake method/feature + */ + public function testFire() + { + $same = $this->uut->fire('EventA', 'foo'); + $this->assertSame($this->uut, $same); + } + + /** + * Tests the namesake method/feature + */ + public function testFireWithListener() + { + $this->expectOutputString('foo'); + + $this->uut->subscribe([new ListenerA(), 'shoutA'], 'EventA') + ->fire('EventA', 'foo'); + } + + /** + * Tests the namesake method/feature + */ + public function testFireCancel() + { + $this->expectOutputString('foo'); // output would be 'foofoo' if event canceling does not work + + $this->uut->subscribe([new ListenerA(), 'cancelA'], 'EventA') + ->subscribe([new ListenerB(), 'neverToReach'], 'EventA') + ->fire('EventA', 'foo'); + } + + /** + * Tests the namesake method/feature + */ + public function testGetClassIdentifier() + { + $this->assertEquals('Events\EventBroker', EventBroker::getClassIdentifier()); + } +} + +class ListenerA +{ + public function shoutA(EventA $event) + { + print $event->content; + } + + public function shoutB(EventB $event) + { + print $event->content; + } + + public function cancelA(EventA $event) + { + print $event->content; + $event->cancel(); + } +} + +class ListenerB extends ListenerA +{ + public function shoutB(EventB $event) + { + print $event->content; + } + + public function neverToReach(EventA $event) + { + print $event->content; + } +} + +class EventA extends AbstractEvent +{ + public $content; + + public function __construct(string $content) + { + $this->content = $content; + } +} + +class EventB extends EventA +{ + +} \ No newline at end of file diff --git a/tests/Services/ServicesFactoryTest.php b/tests/Services/ServicesFactoryTest.php deleted file mode 100644 index 6e49311..0000000 --- a/tests/Services/ServicesFactoryTest.php +++ /dev/null @@ -1,126 +0,0 @@ - - * @copyright 2019-forever Marco Stoll - * @filesource - */ -declare(strict_types=1); - -namespace FF\Tests\Services; - -use FF\Factories\Exceptions\ClassNotFoundException; -use FF\Services\AbstractService; -use FF\Services\Exceptions\ConfigurationException; -use FF\Services\ServicesFactory; -use PHPUnit\Framework\TestCase; - -/** - * Test ServicesFactoryTest - * - * @package FF\Tests - */ -class ServicesFactoryTest extends TestCase -{ - const TEST_OPTIONS = ['ServiceOne' => ['foo' => 'bar']]; - - /** - * {@inheritdoc} - */ - public static function setUpBeforeClass(): void - { - ServicesFactory::clearInstance(); - } - - /** - * Tests the namesake method/feature - */ - public function testGetInstanceConfigException() - { - $this->expectException(ConfigurationException::class); - - ServicesFactory::getInstance(); - } - - /** - * Tests the namesake method/feature - */ - public function testSetGetInstance() - { - $instance = new ServicesFactory(self::TEST_OPTIONS); - $instance->getClassLocator()->prependNamespaces(__NAMESPACE__); - ServicesFactory::setInstance($instance); - - $this->assertSame($instance, ServicesFactory::getInstance()); - } - - /** - * Tests the namesake method/feature - * - * @depends testSetGetInstance - */ - public function testSetServiceOptions() - { - $this->assertEquals( - self::TEST_OPTIONS['ServiceOne'], - ServicesFactory::getInstance()->getServiceOptions('ServiceOne') - ); - $this->assertEquals([], ServicesFactory::getInstance()->getServiceOptions('unknown')); - } - - /** - * Tests the namesake method/feature - * - * @depends testSetGetInstance - */ - public function testGetSingle() - { - $service = ServicesFactory::getInstance()->get('ServiceOne'); - $this->assertInstanceOf(ServiceOne::class, $service); - $this->assertEquals(self::TEST_OPTIONS['ServiceOne'], $service->getOptions()); - } - - /** - * Tests the namesake method/feature - * - * @depends testSetGetInstance - */ - public function testGetMultiples() - { - $services = ServicesFactory::getInstance()->get('ServiceOne', 'ServiceOne'); - $this->assertEquals(2, count($services)); - $this->assertInstanceOf(ServiceOne::class, $services[0]); - $this->assertInstanceOf(ServiceOne::class, $services[1]); - } - - /** - * Tests the namesake method/feature - * - * @depends testSetGetInstance - */ - public function testGetClassNotFound() - { - $this->expectException(ClassNotFoundException::class); - - ServicesFactory::getInstance()->get('ServiceUnknown'); - } -} - -class ServiceOne extends AbstractService -{ - protected function validateOptions(array $options, array &$errors): bool - { - if (isset($options['foo']) && $options['foo'] != 'bar') { - $errors[] = 'foo is not bar'; - return false; - } - - return parent::validateOptions($options, $errors); - } -} - -class ServiceTwo extends AbstractService -{ - -} \ No newline at end of file