Registered service identifiers.
+ */
+ public function get (string $tag):array {
+
+ return $this->tags[$tag] ?? [];
+
+ }
+
+}
\ No newline at end of file
diff --git a/src/Resolver.php b/src/Resolver.php
index 27e8a05..7d2a1f8 100644
--- a/src/Resolver.php
+++ b/src/Resolver.php
@@ -21,10 +21,11 @@
use FireHub\Container\Internal\ {
Autowirer, CandidateSelector, ConstructorMetadataCache, ContextualBindingRegistry, ContextualCandidateSelector,
ContextualResolver, DependencyPlanCache, InstanceStore, ParameterBindingRegistry, ResolutionContext, ScopeState,
- TypeMatcher
+ TagRegistry, TypeMatcher
};
use FireHub\Container\Exception\ {
- ScopeRequiredException, ServiceNotFoundException, ServiceTypeMismatchException, UnsupportedDefinitionException
+ CircularDependencyException, ScopeRequiredException, ServiceNotFoundException, ServiceTypeMismatchException,
+ UnsupportedDefinitionException
};
use FireHub\Runtime;
@@ -52,6 +53,15 @@
*/
private ConstructorMetadataCache $metadata;
+ /**
+ * ### Dependency type matcher
+ *
+ * Provides shared type compatibility checks for dependency resolution, candidate selection, and collection
+ * injection.
+ * @since 1.0.0
+ */
+ private TypeMatcher $matcher;
+
/**
* ### Reusable dependency autowirer
* @since 1.0.0
@@ -78,6 +88,9 @@
* @param \FireHub\Container\Internal\ContextualBindingRegistry $contextual
* Contextual binding registry.
*
+ * @param \FireHub\Container\Internal\TagRegistry $tags
+ * Service tag registry.
+ *
*
* @return void
*/
@@ -85,30 +98,33 @@ public function __construct (
private Registry $registry,
private InstanceStore $singletons,
ParameterBindingRegistry $parameters,
- ContextualBindingRegistry $contextual
+ ContextualBindingRegistry $contextual,
+ private TagRegistry $tags
) {
$this->metadata = new ConstructorMetadataCache;
- $matcher = new TypeMatcher(
+ $this->matcher = new TypeMatcher(
$this->metadata
);
$selector = new CandidateSelector(
- $matcher,
+ $this->matcher,
fn (string $id):bool => $this->has($id),
fn ():iterable => $this->registry->implementations()
);
$plans = new DependencyPlanCache(
$this->metadata,
- $matcher
+ $this->matcher
);
$this->autowirer = new Autowirer(
fn (string $id, ResolutionContext $context):object => $this->resolve($id, $context),
+ fn (string $tag, ResolutionContext $context):array => $this->tagged($tag, $context),
+ fn (string $type, ResolutionContext $context):array => $this->all($type, $context),
fn (string $id):bool => $this->has($id),
- $matcher,
+ $this->matcher,
$selector,
$plans,
$parameters,
@@ -117,6 +133,26 @@ public function __construct (
}
+ /**
+ * ### Checks whether a service is registered
+ *
+ * Checks whether a service identifier exists without constructing or resolving its instance.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Registry::has() To check for service existence.
+ *
+ * @param class-string $id
+ * Service identifier.
+ *
+ *
+ * @return bool True if the service is registered.
+ */
+ public function has (string $id):bool {
+
+ return $this->registry->has($id);
+
+ }
+
/**
* ### Gets a registered service
*
@@ -213,22 +249,226 @@ public function getScoped (string $id, InstanceStore $scoped, ScopeState $scope)
}
/**
- * ### Checks whether a service is registered
+ * ### Resolves all registered services matching a type
*
- * Checks whether a service identifier exists without constructing or resolving its instance.
+ * Resolves explicitly registered services compatible with the specified class or interface.
+ *
+ * Services are returned in registration order while preserving their configured lifetimes and the active
+ * resolution context.
+ *
+ * Service aliases are resolved to their underlying targets and do not produce duplicate collection entries.
* @since 1.0.0
*
- * @uses \FireHub\Container\Registry::has() To check for service existence.
+ * @uses \FireHub\Container\Registry::all() To retrieve all registered service identifiers.
+ * @uses \FireHub\Container\Resolver::canonicalId() To resolve service aliases.
+ * @uses \FireHub\Container\Resolver::matchesCollectionType() To check whether a service matches the specified type.
*
- * @param class-string $id
- * Service identifier.
+ * @template TService of object
+ *
+ * @param class-string $type
+ * Required service type.
+ *
+ * @param \FireHub\Container\Internal\ResolutionContext $context
+ * Active resolution context.
*
*
- * @return bool True if the service is registered.
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible
+ * with its registered identifier.
+ * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
+ *
+ * @return list Matching service instances.
*/
- public function has (string $id):bool {
+ public function all (string $type, ResolutionContext $context):array {
- return $this->registry->has($id);
+ $services = []; $resolved = [];
+ foreach ($this->registry->all() as $id => $definition) {
+
+ $target = $this->canonicalId($id);
+
+ if (isset($resolved[$target]))
+ continue;
+
+ if (!$this->matchesCollectionType($id, $target, $type))
+ continue;
+
+ $instance = $this->resolve($id, $context);
+
+ if (!$instance instanceof $type)
+ throw new ServiceTypeMismatchException(
+ context: [
+ 'service' => $id,
+ 'implementation' => $instance::class,
+ ]
+ );
+
+ $services[] = $instance;
+
+ $resolved[$target] = true;
+
+ }
+
+ return $services;
+
+ }
+
+ /**
+ * ### Resolves all matching services within a scope
+ *
+ * Resolves collection services using the supplied scoped instance store and scope state.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Resolver::all() To resolve all matching services.
+ * @uses \FireHub\Container\Internal\ScopeState::enter() To enter the scope.
+ * @uses \FireHub\Container\Internal\ScopeState::leave() To leave the scope.
+ *
+ * @template TService of object
+ *
+ * @param class-string $type
+ * Required service type.
+ *
+ * @param \FireHub\Container\Internal\InstanceStore $scoped
+ * Scoped instance store.
+ *
+ * @param \FireHub\Container\Internal\ScopeState $scope
+ * Scope state.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible
+ * with its registered identifier.
+ * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
+ * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed.
+ *
+ * @return list Matching service instances.
+ */
+ public function allScoped (string $type, InstanceStore $scoped, ScopeState $scope):array {
+
+ $scope->enter();
+
+ try {
+
+ return $this->all(
+ $type,
+ new ResolutionContext(
+ scoped: $scoped,
+ scope: $scope
+ )
+ );
+
+ } finally {
+
+ $scope->leave();
+
+ }
+
+ }
+
+ /**
+ * ### Resolves services associated with a tag
+ *
+ * Resolves all service identifiers registered under the specified tag in their original registration order.
+ *
+ * Each service is resolved through the active resolution context, preserving singleton, scoped, and transient
+ * lifetime semantics.
+ *
+ * An unknown tag produces an empty collection.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\TagRegistry::get() To retrieve tagged service identifiers.
+ * @uses \FireHub\Container\Resolver::resolve() To resolve each tagged service.
+ *
+ * @param non-empty-string $tag
+ * Service tag name.
+ *
+ * @param \FireHub\Container\Internal\ResolutionContext $context
+ * Active dependency resolution context.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If a tagged service is no longer registered.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If a scoped service is resolved without a scope.
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible
+ * with its registered identifier.
+ * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
+ *
+ * @return list Resolved service instances.
+ */
+ public function tagged (string $tag, ResolutionContext $context):array {
+
+ $services = [];
+
+ foreach ($this->tags->get($tag) as $id)
+ $services[] = $this->resolve($id, $context);
+
+ return $services;
+
+ }
+
+ /**
+ * ### Resolves tagged services within a scope
+ *
+ * Resolves all services associated with a tag using the provided scoped instance store and lifecycle state.
+ *
+ * All tagged services share the same resolution context, preserving scoped instance caching and circular
+ * dependency detection.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Internal\ScopeState::enter() To enter the scope.
+ * @uses \FireHub\Container\Internal\ScopeState::leave() To leave the scope.
+ * @uses \FireHub\Container\Resolver::tagged() To resolve tagged services.
+ *
+ * @param non-empty-string $tag
+ * Service tag name.
+ *
+ * @param \FireHub\Container\Internal\InstanceStore $scoped
+ * Scoped instance storage.
+ *
+ * @param \FireHub\Container\Internal\ScopeState $scope
+ * Scope lifecycle state.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed.
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If a tagged service is no longer registered.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If a scoped service is resolved without a scope.
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible
+ * with its registered identifier.
+ * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
+ *
+ * @return list Resolved service instances.
+ */
+ public function taggedScoped (string $tag, InstanceStore $scoped, ScopeState $scope):array {
+
+ $scope->enter();
+
+ try {
+
+ return $this->tagged(
+ $tag,
+ new ResolutionContext(
+ scoped: $scoped,
+ scope: $scope
+ )
+ );
+
+ } finally {
+
+ $scope->leave();
+
+ }
}
@@ -489,6 +729,87 @@ function (string $id) use ($context):bool {
}
+ /**
+ * ### Resolves the canonical service identifier
+ *
+ * Follows registered service aliases until a non-alias definition is reached.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Registry::find() To retrieve a service definition.
+ *
+ * @param class-string $id
+ * Service identifier.
+ *
+ *
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ *
+ * @return class-string Canonical service identifier.
+ */
+ private function canonicalId (string $id):string {
+
+ $visited = []; $path = [];
+ while (true) {
+
+ if (isset($visited[$id]))
+ throw new CircularDependencyException(
+ context: [
+ 'path' => [...$path, $id],
+ ]
+ );
+
+ $visited[$id] = true;
+ $path[] = $id;
+
+ $definition = $this->registry->find($id);
+
+ if (!$definition instanceof Alias)
+ return $id;
+
+ $id = $definition->target;
+
+ }
+
+ }
+
+ /**
+ * ### Checks collection candidate compatibility
+ *
+ * Determines whether a registered service is compatible with the requested collection element type.
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Registry::find() To retrieve the service definition.
+ * @uses \FireHub\Container\Internal\TypeMatcher::matches() To check if the service identifier matches the type.
+ *
+ * @param class-string $id
+ * Service identifier.
+ *
+ * @param class-string $target
+ * Canonical service identifier.
+ *
+ * @param class-string $type
+ * Required collection element type.
+ *
+ *
+ * @return bool True if the service matches the requested type.
+ */
+ private function matchesCollectionType (string $id, string $target, string $type):bool {
+
+ if ($this->matcher->matches($id, $type))
+ return true;
+
+ if ($this->matcher->matches($target, $type))
+ return true;
+
+ $definition = $this->registry->find($target);
+
+ return match (true) {
+ $definition instanceof Cls => $this->matcher->matches($definition->class, $type),
+ $definition instanceof Instance => $definition->instance instanceof $type,
+ default => false
+ };
+
+ }
+
/**
* ### Creates a service instance
*
diff --git a/src/Scope.php b/src/Scope.php
index 6ddf816..f1be407 100644
--- a/src/Scope.php
+++ b/src/Scope.php
@@ -70,6 +70,78 @@ public function __construct (
}
+ /**
+ * ### Resolves tagged services within the current scope
+ *
+ * Resolves all services associated with the specified tag using the current scope.
+ *
+ * Scoped services are reused within this scope, singleton services are shared with the parent container, and
+ * transient services are constructed on each resolution.
+ *
+ * Resolution is prohibited after the scope has been closed.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->singleton(AuthenticationMiddleware::class);
+ * $container->scoped(AuthorizationMiddleware::class);
+ * $container->transient(LoggingMiddleware::class);
+ *
+ * $container->tag(
+ * 'http.middleware',
+ * AuthenticationMiddleware::class,
+ * AuthorizationMiddleware::class,
+ * LoggingMiddleware::class
+ * );
+ *
+ * $scope = $container->createScope();
+ *
+ * $first = $scope->tagged('http.middleware');
+ * $second = $scope->tagged('http.middleware');
+ *
+ * // Singleton and scoped instances are reused.
+ * $first[0] === $second[0]; // true
+ * $first[1] === $second[1]; // true
+ *
+ * // Transient instances are created on each resolution.
+ * $first[2] !== $second[2]; // true
+ *
+ * $scope->close();
+ *
+ *
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Resolver::taggedScoped() To resolve tagged services within the scope.
+ *
+ * @param non-empty-string $tag
+ * Service tag name.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed.
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If a tagged service is no longer registered.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If a scoped service is resolved without a scope.
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible
+ * with its registered identifier.
+ * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
+ *
+ * @return list Resolved service instances.
+ */
+ public function tagged (string $tag):array {
+
+ return $this->resolver->taggedScoped(
+ $tag,
+ $this->instances,
+ $this->state
+ );
+
+ }
+
/**
* ### Resolves a registered service
*
@@ -79,6 +151,25 @@ public function __construct (
* transient services are created on each resolution.
*
* Resolution is prohibited after the scope has been closed.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->scoped(DatabaseConnection::class);
+ *
+ * $scope = $container->createScope();
+ *
+ * $first = $scope->get(DatabaseConnection::class);
+ * $second = $scope->get(DatabaseConnection::class);
+ *
+ * // Scoped services are reused within the same scope.
+ * $first === $second; // true
+ *
+ * $scope->close();
+ *
+ *
* @since 1.0.0
*
* @uses \FireHub\Container\Resolver::getScoped() To resolve a scoped service.
@@ -89,12 +180,13 @@ public function __construct (
* Service identifier.
*
*
- * @throws \FireHub\Container\Exception\ScopeClosedException If the scope has been closed.
* @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered.
- * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible
- * with its registered identifier.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible with its registered identifier.
* @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported.
* @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided.
+ * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
*
* @return TService Resolved service.
*/
@@ -116,6 +208,22 @@ public function get (string $id):object {
* The operation does not instantiate the service.
*
* Registration checks are prohibited after the scope has been closed.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->singleton(Logger::class);
+ *
+ * $scope = $container->createScope();
+ *
+ * $scope->has(Logger::class); // true
+ * $scope->has(Cache::class); // false
+ *
+ * $scope->close();
+ *
+ *
* @since 1.0.0
*
* @uses \FireHub\Container\Internal\ScopeState::assertOpen() To check if the scope is open.
@@ -137,6 +245,62 @@ public function has (string $id):bool {
}
+ /**
+ * ### Resolves all matching services within the current scope
+ *
+ * Resolves all explicitly registered services compatible with the specified class or interface.
+ *
+ * Scoped services are reused within the current scope, singleton services are shared with the parent container,
+ * and transient services are created for each resolution.
+ *
+ * Resolution is prohibited after the scope has been closed.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->scoped(FileLogger::class);
+ * $container->singleton(ConsoleLogger::class);
+ *
+ * $scope = $container->createScope();
+ *
+ * $loggers = $scope->all(LoggerInterface::class);
+ *
+ * $scope->close();
+ *
+ *
+ * @since 1.0.0
+ *
+ * @uses \FireHub\Container\Resolver::allScoped() To resolve matching services within the scope.
+ *
+ * @template TService of object
+ *
+ * @param class-string $type
+ * Required service type.
+ *
+ *
+ * @throws \FireHub\Container\Exception\ServiceNotFoundException If the service identifier is not registered.
+ * @throws \FireHub\Container\Exception\ServiceTypeMismatchException If the resolved service is not compatible
+ * with its registered identifier.
+ * @throws \FireHub\Container\Exception\UnsupportedDefinitionException If the service definition is not supported.
+ * @throws \FireHub\Container\Exception\ScopeRequiredException If the service requires a scope but none is provided.
+ * @throws \FireHub\Container\Exception\CircularDependencyException If a circular dependency is detected.
+ * @throws \FireHub\Container\Exception\AmbiguousDependencyException If multiple candidates are eligible.
+ * @throws \FireHub\Container\Exception\ScopeClosedException If the scope is closed.
+ *
+ * @return list Matching service instances.
+ */
+ public function all (string $type):array {
+
+ return $this->resolver->allScoped(
+ $type,
+ $this->instances,
+ $this->state
+ );
+
+ }
+
/**
* ### Executes a callback within the scope
*
@@ -145,6 +309,31 @@ public function has (string $id):bool {
* The scope is automatically closed after callback execution, including when the callback throws an exception.
*
* Nested execution of the same scope is not permitted.
+ *
+ *
+ * use FireHub\Container\Container;
+ * use FireHub\Core\Boundary\Resolver;
+ *
+ * $container = new Container;
+ *
+ * $container->scoped(DatabaseConnection::class);
+ *
+ * $scope = $container->createScope();
+ *
+ * $scope->run(
+ * function (Resolver $resolver):void {
+ *
+ * $connection = $resolver->get(DatabaseConnection::class);
+ *
+ * $connection->connect();
+ *
+ * }
+ * );
+ *
+ * // The scope is automatically closed after execution.
+ * $scope->isClosed(); // true
+ *
+ *
* @since 1.0.0
*
* @uses \FireHub\Container\Internal\ScopeState::beginRun() To begin scope execution.
@@ -188,6 +377,21 @@ public function run (Closure $callback):mixed {
/**
* ### Checks whether the scope is closed
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $scope = $container->createScope();
+ *
+ * $scope->isClosed(); // false
+ *
+ * $scope->close();
+ *
+ * $scope->isClosed(); // true
+ *
+ *
* @since 1.0.0
*
* @uses \FireHub\Container\Internal\ScopeState::isClosed() To check the scope lifecycle state.
@@ -209,6 +413,25 @@ public function isClosed ():bool {
*
* Closing a scope does not affect singleton instances maintained by the parent container or instances stored in
* other scopes.
+ *
+ *
+ * use FireHub\Container\Container;
+ *
+ * $container = new Container;
+ *
+ * $container->scoped(DatabaseConnection::class);
+ *
+ * $scope = $container->createScope();
+ *
+ * $connection = $scope->get(DatabaseConnection::class);
+ *
+ * // Release cached scoped instances.
+ * $scope->close();
+ *
+ * // The scope can no longer resolve services.
+ * $scope->isClosed(); // true
+ *
+ *
* @since 1.0.0
*
* @uses \FireHub\Container\Internal\ScopeState::close() To close the scope.
diff --git a/tests/Stubs/AbstractLogger.php b/tests/Stubs/AbstractLogger.php
new file mode 100644
index 0000000..d5b3799
--- /dev/null
+++ b/tests/Stubs/AbstractLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+abstract class AbstractLogger {}
diff --git a/tests/Stubs/AnotherTestLogger.php b/tests/Stubs/AnotherTestLogger.php
new file mode 100644
index 0000000..e086d3c
--- /dev/null
+++ b/tests/Stubs/AnotherTestLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.4
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class AnotherTestLogger implements TestLogger, TestResettable {}
\ No newline at end of file
diff --git a/tests/Stubs/BoundConnection.php b/tests/Stubs/BoundConnection.php
new file mode 100644
index 0000000..b87dffe
--- /dev/null
+++ b/tests/Stubs/BoundConnection.php
@@ -0,0 +1,28 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * ### BoundConnection
+ * @since 1.0.0
+ */
+final readonly class BoundConnection {
+
+ public function __construct (
+ public string $host,
+ public int $port,
+ public string $database
+ ) {}
+
+}
diff --git a/tests/Stubs/BoundDefaultService.php b/tests/Stubs/BoundDefaultService.php
new file mode 100644
index 0000000..8e2d498
--- /dev/null
+++ b/tests/Stubs/BoundDefaultService.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class BoundDefaultService {
+
+ public function __construct (
+ public int $port = 8080
+ ) {}
+
+}
diff --git a/tests/Stubs/BoundMixedService.php b/tests/Stubs/BoundMixedService.php
new file mode 100644
index 0000000..7716af8
--- /dev/null
+++ b/tests/Stubs/BoundMixedService.php
@@ -0,0 +1,27 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class BoundMixedService {
+
+ public function __construct (
+ public Logger $logger,
+ public string $host,
+ public int $port = 3306
+ ) {}
+
+}
diff --git a/tests/Stubs/BoundNullableService.php b/tests/Stubs/BoundNullableService.php
new file mode 100644
index 0000000..6cd4813
--- /dev/null
+++ b/tests/Stubs/BoundNullableService.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class BoundNullableService {
+
+ public function __construct (
+ public ?Logger $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/BoundObjectService.php b/tests/Stubs/BoundObjectService.php
new file mode 100644
index 0000000..bfa0658
--- /dev/null
+++ b/tests/Stubs/BoundObjectService.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class BoundObjectService {
+
+ public function __construct (
+ public Logger $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/BoundOtherConnection.php b/tests/Stubs/BoundOtherConnection.php
new file mode 100644
index 0000000..6c0b20e
--- /dev/null
+++ b/tests/Stubs/BoundOtherConnection.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class BoundOtherConnection {
+
+ public function __construct (
+ public string $host
+ ) {}
+
+}
diff --git a/tests/Stubs/CircularA.php b/tests/Stubs/CircularA.php
new file mode 100644
index 0000000..2c60cf9
--- /dev/null
+++ b/tests/Stubs/CircularA.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CircularA {
+
+ public function __construct (
+ public CircularB $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/CircularB.php b/tests/Stubs/CircularB.php
new file mode 100644
index 0000000..85ea091
--- /dev/null
+++ b/tests/Stubs/CircularB.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CircularB {
+
+ public function __construct (
+ public CircularA $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/CollectionAbstractLogger.php b/tests/Stubs/CollectionAbstractLogger.php
new file mode 100644
index 0000000..4809620
--- /dev/null
+++ b/tests/Stubs/CollectionAbstractLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+abstract class CollectionAbstractLogger {}
diff --git a/tests/Stubs/CollectionAlternateImplementation.php b/tests/Stubs/CollectionAlternateImplementation.php
new file mode 100644
index 0000000..a5e7e14
--- /dev/null
+++ b/tests/Stubs/CollectionAlternateImplementation.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final class CollectionAlternateImplementation implements CollectionAlternateLogger {}
diff --git a/tests/Stubs/CollectionAlternateLogger.php b/tests/Stubs/CollectionAlternateLogger.php
new file mode 100644
index 0000000..5ea4368
--- /dev/null
+++ b/tests/Stubs/CollectionAlternateLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface CollectionAlternateLogger extends CollectionLogger {}
diff --git a/tests/Stubs/CollectionConcreteAbstractLogger.php b/tests/Stubs/CollectionConcreteAbstractLogger.php
new file mode 100644
index 0000000..d0d549e
--- /dev/null
+++ b/tests/Stubs/CollectionConcreteAbstractLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final class CollectionConcreteAbstractLogger extends CollectionAbstractLogger {}
diff --git a/tests/Stubs/CollectionConsoleLogger.php b/tests/Stubs/CollectionConsoleLogger.php
new file mode 100644
index 0000000..4eb1969
--- /dev/null
+++ b/tests/Stubs/CollectionConsoleLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final class CollectionConsoleLogger implements CollectionLogger {}
diff --git a/tests/Stubs/CollectionDatabaseLogger.php b/tests/Stubs/CollectionDatabaseLogger.php
new file mode 100644
index 0000000..c4e378d
--- /dev/null
+++ b/tests/Stubs/CollectionDatabaseLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final class CollectionDatabaseLogger implements CollectionLogger {}
diff --git a/tests/Stubs/CollectionFileLogger.php b/tests/Stubs/CollectionFileLogger.php
new file mode 100644
index 0000000..05f2040
--- /dev/null
+++ b/tests/Stubs/CollectionFileLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final class CollectionFileLogger implements CollectionLogger {}
diff --git a/tests/Stubs/CollectionInjectionCircularA.php b/tests/Stubs/CollectionInjectionCircularA.php
new file mode 100644
index 0000000..945dd38
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionCircularA.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\InjectAll;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionCircularA {
+ public function __construct (
+ #[InjectAll(CollectionLogger::class)]
+ public array $loggers
+ ) {}
+}
diff --git a/tests/Stubs/CollectionInjectionCircularB.php b/tests/Stubs/CollectionInjectionCircularB.php
new file mode 100644
index 0000000..7ea9611
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionCircularB.php
@@ -0,0 +1,21 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionCircularB implements CollectionLogger {
+ public function __construct (public CollectionInjectionCircularA $dependency) {}
+}
diff --git a/tests/Stubs/CollectionInjectionConflictingAttributesConsumer.php b/tests/Stubs/CollectionInjectionConflictingAttributesConsumer.php
new file mode 100644
index 0000000..9f9db85
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionConflictingAttributesConsumer.php
@@ -0,0 +1,29 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\ {
+ InjectAll, Tagged
+};
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionConflictingAttributesConsumer {
+ public function __construct (
+ #[InjectAll(CollectionLogger::class)]
+ #[Tagged('logging')]
+ public array $loggers
+ ) {}
+}
diff --git a/tests/Stubs/CollectionInjectionConsumer.php b/tests/Stubs/CollectionInjectionConsumer.php
new file mode 100644
index 0000000..9ccde8c
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionConsumer.php
@@ -0,0 +1,27 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\InjectAll;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionConsumer {
+ /** @param list $loggers */
+ public function __construct (
+ #[InjectAll(CollectionLogger::class)]
+ public array $loggers
+ ) {}
+}
diff --git a/tests/Stubs/CollectionInjectionDefaultConsumer.php b/tests/Stubs/CollectionInjectionDefaultConsumer.php
new file mode 100644
index 0000000..8de04d8
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionDefaultConsumer.php
@@ -0,0 +1,27 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\InjectAll;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionDefaultConsumer {
+ /** @param list $loggers */
+ public function __construct (
+ #[InjectAll(CollectionLogger::class)]
+ public array $loggers = []
+ ) {}
+}
diff --git a/tests/Stubs/CollectionInjectionEmptyTypeConsumer.php b/tests/Stubs/CollectionInjectionEmptyTypeConsumer.php
new file mode 100644
index 0000000..7e35d96
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionEmptyTypeConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\InjectAll;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionEmptyTypeConsumer {
+ public function __construct (
+ #[InjectAll('')]
+ public array $loggers
+ ) {}
+}
diff --git a/tests/Stubs/CollectionInjectionMultipleAttributesConsumer.php b/tests/Stubs/CollectionInjectionMultipleAttributesConsumer.php
new file mode 100644
index 0000000..779a04c
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionMultipleAttributesConsumer.php
@@ -0,0 +1,27 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\InjectAll;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionMultipleAttributesConsumer {
+ public function __construct (
+ #[InjectAll(CollectionLogger::class)]
+ #[InjectAll(CollectionLogger::class)]
+ public array $loggers
+ ) {}
+}
diff --git a/tests/Stubs/CollectionInjectionNestedConsumer.php b/tests/Stubs/CollectionInjectionNestedConsumer.php
new file mode 100644
index 0000000..87519c5
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionNestedConsumer.php
@@ -0,0 +1,21 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionNestedConsumer {
+ public function __construct (public CollectionInjectionConsumer $consumer) {}
+}
diff --git a/tests/Stubs/CollectionInjectionNullableConsumer.php b/tests/Stubs/CollectionInjectionNullableConsumer.php
new file mode 100644
index 0000000..8a6e5dd
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionNullableConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\InjectAll;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionNullableConsumer {
+ public function __construct (
+ #[InjectAll(CollectionLogger::class)]
+ public ?array $loggers
+ ) {}
+}
diff --git a/tests/Stubs/CollectionInjectionScalarConsumer.php b/tests/Stubs/CollectionInjectionScalarConsumer.php
new file mode 100644
index 0000000..2ba327f
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionScalarConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\InjectAll;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionScalarConsumer {
+ public function __construct (
+ #[InjectAll(CollectionLogger::class)]
+ public string $loggers
+ ) {}
+}
diff --git a/tests/Stubs/CollectionInjectionUnknownTypeConsumer.php b/tests/Stubs/CollectionInjectionUnknownTypeConsumer.php
new file mode 100644
index 0000000..4035dab
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionUnknownTypeConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\InjectAll;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class CollectionInjectionUnknownTypeConsumer {
+ public function __construct (
+ #[InjectAll('FireHub\Tests\Container\Stubs\UnknownCollectionType')]
+ public array $loggers
+ ) {}
+}
diff --git a/tests/Stubs/CollectionInjectionUntypedConsumer.php b/tests/Stubs/CollectionInjectionUntypedConsumer.php
new file mode 100644
index 0000000..1e81e97
--- /dev/null
+++ b/tests/Stubs/CollectionInjectionUntypedConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\InjectAll;
+
+/**
+ * @since 1.0.0
+ */
+final class CollectionInjectionUntypedConsumer {
+ public function __construct (
+ #[InjectAll(CollectionLogger::class)]
+ public $loggers
+ ) {}
+}
diff --git a/tests/Stubs/CollectionLogger.php b/tests/Stubs/CollectionLogger.php
new file mode 100644
index 0000000..b226b4b
--- /dev/null
+++ b/tests/Stubs/CollectionLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface CollectionLogger {}
diff --git a/tests/Stubs/CollectionOtherService.php b/tests/Stubs/CollectionOtherService.php
new file mode 100644
index 0000000..c57f64e
--- /dev/null
+++ b/tests/Stubs/CollectionOtherService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final class CollectionOtherService {}
diff --git a/tests/Stubs/CollectionUnrelatedService.php b/tests/Stubs/CollectionUnrelatedService.php
new file mode 100644
index 0000000..2bb7455
--- /dev/null
+++ b/tests/Stubs/CollectionUnrelatedService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final class CollectionUnrelatedService {}
diff --git a/tests/Stubs/ContextualCache.php b/tests/Stubs/ContextualCache.php
new file mode 100644
index 0000000..bf3abfc
--- /dev/null
+++ b/tests/Stubs/ContextualCache.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface ContextualCache {}
diff --git a/tests/Stubs/ContextualCacheService.php b/tests/Stubs/ContextualCacheService.php
new file mode 100644
index 0000000..7e4f50d
--- /dev/null
+++ b/tests/Stubs/ContextualCacheService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualCacheService implements ContextualCache {}
diff --git a/tests/Stubs/ContextualCircularA.php b/tests/Stubs/ContextualCircularA.php
new file mode 100644
index 0000000..207456e
--- /dev/null
+++ b/tests/Stubs/ContextualCircularA.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualCircularA {
+
+ public function __construct (
+ public ContextualCircularB $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualCircularB.php b/tests/Stubs/ContextualCircularB.php
new file mode 100644
index 0000000..e61e347
--- /dev/null
+++ b/tests/Stubs/ContextualCircularB.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualCircularB {
+
+ public function __construct (
+ public ContextualCircularA $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualConsoleLogger.php b/tests/Stubs/ContextualConsoleLogger.php
new file mode 100644
index 0000000..7eb4b23
--- /dev/null
+++ b/tests/Stubs/ContextualConsoleLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualConsoleLogger implements ContextualLogger {}
diff --git a/tests/Stubs/ContextualDatabaseLogger.php b/tests/Stubs/ContextualDatabaseLogger.php
new file mode 100644
index 0000000..7f25c9a
--- /dev/null
+++ b/tests/Stubs/ContextualDatabaseLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualDatabaseLogger implements ContextualLogger {}
diff --git a/tests/Stubs/ContextualDefaultConsumer.php b/tests/Stubs/ContextualDefaultConsumer.php
new file mode 100644
index 0000000..8773d1f
--- /dev/null
+++ b/tests/Stubs/ContextualDefaultConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualDefaultConsumer {
+
+ public function __construct (
+ public ?ContextualLogger $logger = null
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualDnfConsumer.php b/tests/Stubs/ContextualDnfConsumer.php
new file mode 100644
index 0000000..9fae622
--- /dev/null
+++ b/tests/Stubs/ContextualDnfConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualDnfConsumer {
+
+ public function __construct (
+ public (ContextualLogger&ContextualResettable)|ContextualCache $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualFileLogger.php b/tests/Stubs/ContextualFileLogger.php
new file mode 100644
index 0000000..f9af963
--- /dev/null
+++ b/tests/Stubs/ContextualFileLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualFileLogger implements ContextualLogger, ContextualResettable {}
diff --git a/tests/Stubs/ContextualIntersectionConsumer.php b/tests/Stubs/ContextualIntersectionConsumer.php
new file mode 100644
index 0000000..5af38b8
--- /dev/null
+++ b/tests/Stubs/ContextualIntersectionConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualIntersectionConsumer {
+
+ public function __construct (
+ public ContextualLogger&ContextualResettable $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualLogger.php b/tests/Stubs/ContextualLogger.php
new file mode 100644
index 0000000..014439c
--- /dev/null
+++ b/tests/Stubs/ContextualLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface ContextualLogger {}
diff --git a/tests/Stubs/ContextualNamedConsumer.php b/tests/Stubs/ContextualNamedConsumer.php
new file mode 100644
index 0000000..57ab77e
--- /dev/null
+++ b/tests/Stubs/ContextualNamedConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualNamedConsumer {
+
+ public function __construct (
+ public ContextualLogger $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualNestedConsumer.php b/tests/Stubs/ContextualNestedConsumer.php
new file mode 100644
index 0000000..08f43f6
--- /dev/null
+++ b/tests/Stubs/ContextualNestedConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualNestedConsumer {
+
+ public function __construct (
+ public ContextualNamedConsumer $consumer
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualOtherConsumer.php b/tests/Stubs/ContextualOtherConsumer.php
new file mode 100644
index 0000000..f1b550b
--- /dev/null
+++ b/tests/Stubs/ContextualOtherConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualOtherConsumer {
+
+ public function __construct (
+ public ContextualLogger $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualResettable.php b/tests/Stubs/ContextualResettable.php
new file mode 100644
index 0000000..a8140d4
--- /dev/null
+++ b/tests/Stubs/ContextualResettable.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface ContextualResettable {}
diff --git a/tests/Stubs/ContextualScopedConsumer.php b/tests/Stubs/ContextualScopedConsumer.php
new file mode 100644
index 0000000..fc97347
--- /dev/null
+++ b/tests/Stubs/ContextualScopedConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualScopedConsumer {
+
+ public function __construct (
+ public ContextualLogger $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualSerializable.php b/tests/Stubs/ContextualSerializable.php
new file mode 100644
index 0000000..3fc73b1
--- /dev/null
+++ b/tests/Stubs/ContextualSerializable.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface ContextualSerializable {}
diff --git a/tests/Stubs/ContextualSerializableCache.php b/tests/Stubs/ContextualSerializableCache.php
new file mode 100644
index 0000000..6df395b
--- /dev/null
+++ b/tests/Stubs/ContextualSerializableCache.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualSerializableCache implements ContextualCache, ContextualSerializable {}
diff --git a/tests/Stubs/ContextualSingletonConsumer.php b/tests/Stubs/ContextualSingletonConsumer.php
new file mode 100644
index 0000000..9c4ce5e
--- /dev/null
+++ b/tests/Stubs/ContextualSingletonConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualSingletonConsumer {
+
+ public function __construct (
+ public ContextualLogger $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualTwoIntersectionsConsumer.php b/tests/Stubs/ContextualTwoIntersectionsConsumer.php
new file mode 100644
index 0000000..109758e
--- /dev/null
+++ b/tests/Stubs/ContextualTwoIntersectionsConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualTwoIntersectionsConsumer {
+
+ public function __construct (
+ public (ContextualLogger&ContextualResettable)|(ContextualCache&ContextualSerializable) $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualUnionConsumer.php b/tests/Stubs/ContextualUnionConsumer.php
new file mode 100644
index 0000000..3bf2120
--- /dev/null
+++ b/tests/Stubs/ContextualUnionConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualUnionConsumer {
+
+ public function __construct (
+ public ContextualLogger|ContextualCache $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/ContextualUniversalService.php b/tests/Stubs/ContextualUniversalService.php
new file mode 100644
index 0000000..2bbf837
--- /dev/null
+++ b/tests/Stubs/ContextualUniversalService.php
@@ -0,0 +1,23 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ContextualUniversalService implements
+ ContextualLogger,
+ ContextualResettable,
+ ContextualCache,
+ ContextualSerializable {}
diff --git a/tests/Stubs/DnfCache.php b/tests/Stubs/DnfCache.php
new file mode 100644
index 0000000..47bcd0c
--- /dev/null
+++ b/tests/Stubs/DnfCache.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface DnfCache {}
diff --git a/tests/Stubs/DnfCacheService.php b/tests/Stubs/DnfCacheService.php
new file mode 100644
index 0000000..a4f25d0
--- /dev/null
+++ b/tests/Stubs/DnfCacheService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfCacheService implements DnfCache {}
diff --git a/tests/Stubs/DnfConsoleLogger.php b/tests/Stubs/DnfConsoleLogger.php
new file mode 100644
index 0000000..57f1225
--- /dev/null
+++ b/tests/Stubs/DnfConsoleLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfConsoleLogger implements DnfLogger {}
diff --git a/tests/Stubs/DnfDefaultConsumer.php b/tests/Stubs/DnfDefaultConsumer.php
new file mode 100644
index 0000000..cc6b884
--- /dev/null
+++ b/tests/Stubs/DnfDefaultConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfDefaultConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|DnfCache|null $dependency = null
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfFactoryConsumer.php b/tests/Stubs/DnfFactoryConsumer.php
new file mode 100644
index 0000000..9bd19df
--- /dev/null
+++ b/tests/Stubs/DnfFactoryConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfFactoryConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|DnfCache $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfFileLogger.php b/tests/Stubs/DnfFileLogger.php
new file mode 100644
index 0000000..eac86c3
--- /dev/null
+++ b/tests/Stubs/DnfFileLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfFileLogger implements DnfLogger, DnfResettable {}
diff --git a/tests/Stubs/DnfFirstBranchConsumer.php b/tests/Stubs/DnfFirstBranchConsumer.php
new file mode 100644
index 0000000..df7bee5
--- /dev/null
+++ b/tests/Stubs/DnfFirstBranchConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfFirstBranchConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|DnfCache $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfLogger.php b/tests/Stubs/DnfLogger.php
new file mode 100644
index 0000000..a16ff1d
--- /dev/null
+++ b/tests/Stubs/DnfLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface DnfLogger {}
diff --git a/tests/Stubs/DnfLoggerCache.php b/tests/Stubs/DnfLoggerCache.php
new file mode 100644
index 0000000..ca3bec2
--- /dev/null
+++ b/tests/Stubs/DnfLoggerCache.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfLoggerCache implements DnfLogger, DnfCache {}
diff --git a/tests/Stubs/DnfMultipleNamedConsumer.php b/tests/Stubs/DnfMultipleNamedConsumer.php
new file mode 100644
index 0000000..d615c56
--- /dev/null
+++ b/tests/Stubs/DnfMultipleNamedConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfMultipleNamedConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|DnfCache|DnfSerializable $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfNestedConsumer.php b/tests/Stubs/DnfNestedConsumer.php
new file mode 100644
index 0000000..bc2db67
--- /dev/null
+++ b/tests/Stubs/DnfNestedConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfNestedConsumer {
+
+ public function __construct (
+ public DnfFirstBranchConsumer $consumer
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfNoCandidateConsumer.php b/tests/Stubs/DnfNoCandidateConsumer.php
new file mode 100644
index 0000000..eee3bb8
--- /dev/null
+++ b/tests/Stubs/DnfNoCandidateConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfNoCandidateConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|(DnfCache&DnfSerializable) $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfRequiredNullableConsumer.php b/tests/Stubs/DnfRequiredNullableConsumer.php
new file mode 100644
index 0000000..339f89a
--- /dev/null
+++ b/tests/Stubs/DnfRequiredNullableConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfRequiredNullableConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|DnfCache|null $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfResettable.php b/tests/Stubs/DnfResettable.php
new file mode 100644
index 0000000..1572ae7
--- /dev/null
+++ b/tests/Stubs/DnfResettable.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface DnfResettable {}
diff --git a/tests/Stubs/DnfScopedConsumer.php b/tests/Stubs/DnfScopedConsumer.php
new file mode 100644
index 0000000..4792138
--- /dev/null
+++ b/tests/Stubs/DnfScopedConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfScopedConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|DnfCache $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfSecondBranchConsumer.php b/tests/Stubs/DnfSecondBranchConsumer.php
new file mode 100644
index 0000000..1fd0fde
--- /dev/null
+++ b/tests/Stubs/DnfSecondBranchConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfSecondBranchConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|DnfCache $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfSerializable.php b/tests/Stubs/DnfSerializable.php
new file mode 100644
index 0000000..e9afb37
--- /dev/null
+++ b/tests/Stubs/DnfSerializable.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface DnfSerializable {}
diff --git a/tests/Stubs/DnfSerializableCache.php b/tests/Stubs/DnfSerializableCache.php
new file mode 100644
index 0000000..c725e42
--- /dev/null
+++ b/tests/Stubs/DnfSerializableCache.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfSerializableCache implements DnfCache, DnfSerializable {}
diff --git a/tests/Stubs/DnfSingletonConsumer.php b/tests/Stubs/DnfSingletonConsumer.php
new file mode 100644
index 0000000..2094968
--- /dev/null
+++ b/tests/Stubs/DnfSingletonConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfSingletonConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|DnfCache $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfTwoIntersectionsConsumer.php b/tests/Stubs/DnfTwoIntersectionsConsumer.php
new file mode 100644
index 0000000..4163f9a
--- /dev/null
+++ b/tests/Stubs/DnfTwoIntersectionsConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfTwoIntersectionsConsumer {
+
+ public function __construct (
+ public (DnfLogger&DnfResettable)|(DnfCache&DnfSerializable) $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/DnfUniversalService.php b/tests/Stubs/DnfUniversalService.php
new file mode 100644
index 0000000..a244202
--- /dev/null
+++ b/tests/Stubs/DnfUniversalService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class DnfUniversalService implements DnfLogger, DnfResettable, DnfCache, DnfSerializable {}
diff --git a/tests/Stubs/IntersectionConsumer.php b/tests/Stubs/IntersectionConsumer.php
new file mode 100644
index 0000000..b5c5730
--- /dev/null
+++ b/tests/Stubs/IntersectionConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class IntersectionConsumer {
+
+ public function __construct (
+ public TestLogger&TestResettable $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/Logger.php b/tests/Stubs/Logger.php
new file mode 100644
index 0000000..3e3b3c1
--- /dev/null
+++ b/tests/Stubs/Logger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class Logger {}
diff --git a/tests/Stubs/LoggerInterface.php b/tests/Stubs/LoggerInterface.php
new file mode 100644
index 0000000..f4ebba0
--- /dev/null
+++ b/tests/Stubs/LoggerInterface.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface LoggerInterface {}
diff --git a/tests/Stubs/MixedDefault.php b/tests/Stubs/MixedDefault.php
new file mode 100644
index 0000000..e159e30
--- /dev/null
+++ b/tests/Stubs/MixedDefault.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class MixedDefault {
+
+ public function __construct (
+ public mixed $value = null
+ ) {}
+
+}
diff --git a/tests/Stubs/MixedRequired.php b/tests/Stubs/MixedRequired.php
new file mode 100644
index 0000000..721b6ae
--- /dev/null
+++ b/tests/Stubs/MixedRequired.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class MixedRequired {
+
+ public function __construct (
+ public mixed $value
+ ) {}
+
+}
diff --git a/tests/Stubs/NullableConcrete.php b/tests/Stubs/NullableConcrete.php
new file mode 100644
index 0000000..f815dd3
--- /dev/null
+++ b/tests/Stubs/NullableConcrete.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class NullableConcrete {
+
+ public function __construct (
+ public ?Logger $logger = null
+ ) {}
+
+}
diff --git a/tests/Stubs/NullableDefault.php b/tests/Stubs/NullableDefault.php
new file mode 100644
index 0000000..7c34ccc
--- /dev/null
+++ b/tests/Stubs/NullableDefault.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class NullableDefault {
+
+ public function __construct (
+ public ?LoggerInterface $logger = null
+ ) {}
+
+}
diff --git a/tests/Stubs/NullableRequired.php b/tests/Stubs/NullableRequired.php
new file mode 100644
index 0000000..6aba9ea
--- /dev/null
+++ b/tests/Stubs/NullableRequired.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class NullableRequired {
+
+ public function __construct (
+ public ?LoggerInterface $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/Repository.php b/tests/Stubs/Repository.php
new file mode 100644
index 0000000..1bc9873
--- /dev/null
+++ b/tests/Stubs/Repository.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class Repository {
+
+ public function __construct (
+ public Logger $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/ScalarDefault.php b/tests/Stubs/ScalarDefault.php
new file mode 100644
index 0000000..7e054f1
--- /dev/null
+++ b/tests/Stubs/ScalarDefault.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ScalarDefault {
+
+ public function __construct (
+ public int $port = 8080,
+ public string $host = 'localhost'
+ ) {}
+
+}
diff --git a/tests/Stubs/ScalarRequired.php b/tests/Stubs/ScalarRequired.php
new file mode 100644
index 0000000..5008e23
--- /dev/null
+++ b/tests/Stubs/ScalarRequired.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ScalarRequired {
+
+ public function __construct (
+ public int $port
+ ) {}
+
+}
diff --git a/tests/Stubs/ScopedConsumer.php b/tests/Stubs/ScopedConsumer.php
new file mode 100644
index 0000000..00867fa
--- /dev/null
+++ b/tests/Stubs/ScopedConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ScopedConsumer {
+
+ public function __construct (
+ public ScopedDependency $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/ScopedDependency.php b/tests/Stubs/ScopedDependency.php
new file mode 100644
index 0000000..def3efe
--- /dev/null
+++ b/tests/Stubs/ScopedDependency.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class ScopedDependency {}
diff --git a/tests/Stubs/SingletonConsumer.php b/tests/Stubs/SingletonConsumer.php
new file mode 100644
index 0000000..ca030a9
--- /dev/null
+++ b/tests/Stubs/SingletonConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class SingletonConsumer {
+
+ public function __construct (
+ public Logger $logger
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedConsoleLogger.php b/tests/Stubs/TaggedConsoleLogger.php
new file mode 100644
index 0000000..e244de2
--- /dev/null
+++ b/tests/Stubs/TaggedConsoleLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedConsoleLogger implements TaggedLogger {}
diff --git a/tests/Stubs/TaggedDatabaseLogger.php b/tests/Stubs/TaggedDatabaseLogger.php
new file mode 100644
index 0000000..42a70a0
--- /dev/null
+++ b/tests/Stubs/TaggedDatabaseLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedDatabaseLogger implements TaggedLogger {}
diff --git a/tests/Stubs/TaggedFactoryService.php b/tests/Stubs/TaggedFactoryService.php
new file mode 100644
index 0000000..20d3a66
--- /dev/null
+++ b/tests/Stubs/TaggedFactoryService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedFactoryService {}
diff --git a/tests/Stubs/TaggedFileLogger.php b/tests/Stubs/TaggedFileLogger.php
new file mode 100644
index 0000000..0714453
--- /dev/null
+++ b/tests/Stubs/TaggedFileLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedFileLogger implements TaggedLogger {}
diff --git a/tests/Stubs/TaggedInjectionCircularA.php b/tests/Stubs/TaggedInjectionCircularA.php
new file mode 100644
index 0000000..0576045
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionCircularA.php
@@ -0,0 +1,28 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\Tagged;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionCircularA {
+
+ public function __construct (
+ #[Tagged('circular')]
+ public array $dependencies
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionCircularB.php b/tests/Stubs/TaggedInjectionCircularB.php
new file mode 100644
index 0000000..1148b0b
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionCircularB.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionCircularB {
+
+ public function __construct (
+ public TaggedInjectionCircularA $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionConsumer.php b/tests/Stubs/TaggedInjectionConsumer.php
new file mode 100644
index 0000000..9a157b0
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionConsumer.php
@@ -0,0 +1,31 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\Tagged;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionConsumer {
+
+ /**
+ * @param list $loggers
+ */
+ public function __construct (
+ #[Tagged('logging')]
+ public array $loggers
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionDefaultConsumer.php b/tests/Stubs/TaggedInjectionDefaultConsumer.php
new file mode 100644
index 0000000..8ad0490
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionDefaultConsumer.php
@@ -0,0 +1,28 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\Tagged;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionDefaultConsumer {
+
+ public function __construct (
+ #[Tagged('logging')]
+ public array $loggers = []
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionEmptyTagConsumer.php b/tests/Stubs/TaggedInjectionEmptyTagConsumer.php
new file mode 100644
index 0000000..0c15f43
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionEmptyTagConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionEmptyTagConsumer {
+
+ public function __construct (
+ #[Tagged('')]
+ public array $services
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionNestedConsumer.php b/tests/Stubs/TaggedInjectionNestedConsumer.php
new file mode 100644
index 0000000..9fd2312
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionNestedConsumer.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionNestedConsumer {
+
+ public function __construct (
+ public TaggedInjectionConsumer $consumer
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionNullableConsumer.php b/tests/Stubs/TaggedInjectionNullableConsumer.php
new file mode 100644
index 0000000..881022a
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionNullableConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionNullableConsumer {
+
+ public function __construct (
+ #[Tagged('logging')]
+ public ?array $loggers
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionScalarConsumer.php b/tests/Stubs/TaggedInjectionScalarConsumer.php
new file mode 100644
index 0000000..03d83a7
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionScalarConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionScalarConsumer {
+
+ public function __construct (
+ #[Tagged('logging')]
+ public string $loggers
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionUnknownConsumer.php b/tests/Stubs/TaggedInjectionUnknownConsumer.php
new file mode 100644
index 0000000..cbd872f
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionUnknownConsumer.php
@@ -0,0 +1,28 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+use FireHub\Container\Attribute\Tagged;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionUnknownConsumer {
+
+ public function __construct (
+ #[Tagged('unknown')]
+ public array $services
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionUntypedConsumer.php b/tests/Stubs/TaggedInjectionUntypedConsumer.php
new file mode 100644
index 0000000..8011df9
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionUntypedConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final class TaggedInjectionUntypedConsumer {
+
+ public function __construct (
+ #[Tagged('logging')]
+ public $loggers
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedInjectionWhitespaceTagConsumer.php b/tests/Stubs/TaggedInjectionWhitespaceTagConsumer.php
new file mode 100644
index 0000000..a89230f
--- /dev/null
+++ b/tests/Stubs/TaggedInjectionWhitespaceTagConsumer.php
@@ -0,0 +1,26 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedInjectionWhitespaceTagConsumer {
+
+ public function __construct (
+ #[Tagged(' ')]
+ public array $services
+ ) {}
+
+}
diff --git a/tests/Stubs/TaggedLogger.php b/tests/Stubs/TaggedLogger.php
new file mode 100644
index 0000000..cd22d5d
--- /dev/null
+++ b/tests/Stubs/TaggedLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface TaggedLogger {}
diff --git a/tests/Stubs/TaggedScopedService.php b/tests/Stubs/TaggedScopedService.php
new file mode 100644
index 0000000..1535fe7
--- /dev/null
+++ b/tests/Stubs/TaggedScopedService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedScopedService {}
diff --git a/tests/Stubs/TaggedSingletonService.php b/tests/Stubs/TaggedSingletonService.php
new file mode 100644
index 0000000..1097014
--- /dev/null
+++ b/tests/Stubs/TaggedSingletonService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedSingletonService {}
diff --git a/tests/Stubs/TaggedTransientService.php b/tests/Stubs/TaggedTransientService.php
new file mode 100644
index 0000000..f6997f4
--- /dev/null
+++ b/tests/Stubs/TaggedTransientService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedTransientService {}
diff --git a/tests/Stubs/TaggedUnregisteredService.php b/tests/Stubs/TaggedUnregisteredService.php
new file mode 100644
index 0000000..e01d3a5
--- /dev/null
+++ b/tests/Stubs/TaggedUnregisteredService.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TaggedUnregisteredService {}
diff --git a/tests/Stubs/TestConsoleLogger.php b/tests/Stubs/TestConsoleLogger.php
new file mode 100644
index 0000000..2706f59
--- /dev/null
+++ b/tests/Stubs/TestConsoleLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TestConsoleLogger implements TestLogger {}
diff --git a/tests/Stubs/TestFileLogger.php b/tests/Stubs/TestFileLogger.php
new file mode 100644
index 0000000..27cf9c6
--- /dev/null
+++ b/tests/Stubs/TestFileLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class TestFileLogger implements TestLogger, TestResettable {}
diff --git a/tests/Stubs/TestLogger.php b/tests/Stubs/TestLogger.php
new file mode 100644
index 0000000..334fe70
--- /dev/null
+++ b/tests/Stubs/TestLogger.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface TestLogger {}
diff --git a/tests/Stubs/TestResettable.php b/tests/Stubs/TestResettable.php
new file mode 100644
index 0000000..ac70612
--- /dev/null
+++ b/tests/Stubs/TestResettable.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface TestResettable {}
diff --git a/tests/Stubs/UnionA.php b/tests/Stubs/UnionA.php
new file mode 100644
index 0000000..418483a
--- /dev/null
+++ b/tests/Stubs/UnionA.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class UnionA {}
diff --git a/tests/Stubs/UnionAmbiguous.php b/tests/Stubs/UnionAmbiguous.php
new file mode 100644
index 0000000..4309043
--- /dev/null
+++ b/tests/Stubs/UnionAmbiguous.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class UnionAmbiguous {
+
+ public function __construct (
+ public UnionA|UnionB $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/UnionB.php b/tests/Stubs/UnionB.php
new file mode 100644
index 0000000..d2cd3da
--- /dev/null
+++ b/tests/Stubs/UnionB.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class UnionB {}
diff --git a/tests/Stubs/UnionInterface.php b/tests/Stubs/UnionInterface.php
new file mode 100644
index 0000000..ab0adb6
--- /dev/null
+++ b/tests/Stubs/UnionInterface.php
@@ -0,0 +1,19 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.0
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+interface UnionInterface {}
diff --git a/tests/Stubs/UnionNullable.php b/tests/Stubs/UnionNullable.php
new file mode 100644
index 0000000..5cbc812
--- /dev/null
+++ b/tests/Stubs/UnionNullable.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class UnionNullable {
+
+ public function __construct (
+ public UnionInterface|UnionB|null $dependency = null
+ ) {}
+
+}
diff --git a/tests/Stubs/UnionOnlyA.php b/tests/Stubs/UnionOnlyA.php
new file mode 100644
index 0000000..a8c0e29
--- /dev/null
+++ b/tests/Stubs/UnionOnlyA.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class UnionOnlyA {
+
+ public function __construct (
+ public UnionA|UnionInterface $dependency
+ ) {}
+
+}
diff --git a/tests/Stubs/UserService.php b/tests/Stubs/UserService.php
new file mode 100644
index 0000000..2727e51
--- /dev/null
+++ b/tests/Stubs/UserService.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class UserService {
+
+ public function __construct (
+ public Repository $repository
+ ) {}
+
+}
diff --git a/tests/Stubs/VariadicService.php b/tests/Stubs/VariadicService.php
new file mode 100644
index 0000000..26b5067
--- /dev/null
+++ b/tests/Stubs/VariadicService.php
@@ -0,0 +1,30 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.2
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Stubs;
+
+/**
+ * @since 1.0.0
+ */
+final readonly class VariadicService {
+
+ /** @var list */
+ public array $loggers;
+
+ public function __construct (Logger ...$loggers) {
+
+ $this->loggers = $loggers;
+
+ }
+
+}
diff --git a/tests/Support/ExceptionAssertions.php b/tests/Support/ExceptionAssertions.php
new file mode 100644
index 0000000..45fd3bf
--- /dev/null
+++ b/tests/Support/ExceptionAssertions.php
@@ -0,0 +1,53 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=7.4
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Support;
+
+use Closure, Throwable;
+
+/**
+ * ### Assertions for exceptions inside nested callbacks
+ *
+ * Allows tests to validate expected failures and continue asserting state afterward.
+ * @since 1.0.0
+ */
+trait ExceptionAssertions {
+
+ /**
+ * ### Asserts that a callback throws an exception of the expected type
+ * @since 1.0.0
+ *
+ * @param class-string $expected Expected exception class.
+ * @param Closure():mixed $callback Callback expected to throw.
+ *
+ * @return void
+ */
+ private function assertThrows (string $expected, Closure $callback):void {
+
+ try {
+
+ $callback();
+
+ } catch (Throwable $exception) {
+
+ self::assertInstanceOf($expected, $exception);
+
+ return;
+
+ }
+
+ self::fail("Expected exception {$expected} was not thrown.");
+
+ }
+
+}
diff --git a/tests/Unit/AutowiringTest.php b/tests/Unit/AutowiringTest.php
new file mode 100644
index 0000000..70d8214
--- /dev/null
+++ b/tests/Unit/AutowiringTest.php
@@ -0,0 +1,580 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.5
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Unit;
+
+use FireHub\Testing\FireHubTestCase;
+use FireHub\Container\Container;
+use FireHub\Container\Exception\ {
+ AmbiguousDependencyException, CircularDependencyException, ServiceInstantiationException,
+ ServiceNotFoundException, ScopeRequiredException
+};
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use FireHub\Tests\Container\Stubs\ {
+ AbstractLogger, AnotherTestLogger, CircularA, IntersectionConsumer, Logger, LoggerInterface, MixedDefault,
+ MixedRequired, NullableConcrete, NullableDefault, NullableRequired, Repository, ScalarDefault, ScalarRequired,
+ ScopedConsumer, ScopedDependency, SingletonConsumer, TestFileLogger, TestLogger, UnionA, UnionAmbiguous, UnionB,
+ UnionInterface, UnionNullable, UnionOnlyA, UserService, VariadicService
+};
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests constructor dependency autowiring
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[CoversClass(Container::class)]
+final class AutowiringTest extends FireHubTestCase {
+
+ use ExceptionAssertions;
+
+ /**
+ * ### Implicit autowiring resolves nested dependencies
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testImplicitAutowiringResolvesNestedDependencies ():void {
+
+ $container = new Container();
+
+ $service = $container->get(UserService::class);
+
+ self::assertInstanceOf(UserService::class, $service);
+ self::assertInstanceOf(Repository::class, $service->repository);
+ self::assertInstanceOf(Logger::class, $service->repository->logger);
+
+ }
+
+ /**
+ * ### Implicit services are transient
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testImplicitServicesAreTransient ():void {
+
+ $container = new Container();
+
+ $first = $container->get(UserService::class);
+ $second = $container->get(UserService::class);
+
+ self::assertNotSame($first, $second);
+ self::assertNotSame($first->repository, $second->repository);
+ self::assertNotSame($first->repository->logger, $second->repository->logger);
+
+ }
+
+ /**
+ * ### Implicit resolution does not register services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testImplicitResolutionDoesNotRegisterServices ():void {
+
+ $container = new Container();
+
+ self::assertNotTrue($container->has(UserService::class));
+
+ $container->get(UserService::class);
+
+ self::assertNotTrue($container->has(UserService::class));
+
+ }
+
+ /**
+ * ### Unregistered interfaces cannot be instantiated
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testUnregisteredInterfacesCannotBeInstantiated ():void {
+
+ $this->assertThrows(ServiceNotFoundException::class, function ():void {
+
+ new Container()->get(LoggerInterface::class);
+
+ });
+
+ }
+
+ /**
+ * ### Abstract classes cannot be instantiated
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAbstractClassesCannotBeInstantiated ():void {
+
+ $this->assertThrows(ServiceNotFoundException::class, function ():void {
+
+ new Container()->get(AbstractLogger::class);
+
+ });
+
+ }
+
+ /**
+ * ### Required scalar parameters fail
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testRequiredScalarParametersFail ():void {
+
+ $this->assertThrows(ServiceInstantiationException::class, function ():void {
+
+ new Container()->get(ScalarRequired::class);
+
+ });
+
+ }
+
+ /**
+ * ### Scalar parameters use default values
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScalarParametersUseDefaultValues ():void {
+
+ $service = new Container()->get(ScalarDefault::class);
+
+ self::assertSame(8080, $service->port);
+ self::assertSame('localhost', $service->host);
+
+ }
+
+ /**
+ * ### Nullable dependencies without defaults remain required
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testNullableDependenciesWithoutDefaultsRemainRequired ():void {
+
+ $this->assertThrows(ServiceInstantiationException::class, function ():void {
+
+ new Container()->get(NullableRequired::class);
+
+ });
+
+ }
+
+ /**
+ * ### Nullable dependencies use null defaults
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testNullableDependenciesUseNullDefaults ():void {
+
+ $service = new Container()->get(NullableDefault::class);
+
+ self::assertNull($service->logger);
+
+ }
+
+ /**
+ * ### Nullable concrete dependencies are autowired
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testNullableConcreteDependenciesAreAutowired ():void {
+
+ $service = new Container()->get(NullableConcrete::class);
+
+ self::assertInstanceOf(Logger::class, $service->logger);
+
+ }
+
+ /**
+ * ### Variadic parameters accept zero dependencies
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testVariadicParametersAcceptZeroDependencies ():void {
+
+ $service = new Container()->get(VariadicService::class);
+
+ self::assertSame($service->loggers, []);
+
+ }
+
+ /**
+ * ### Required mixed parameters fail
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testRequiredMixedParametersFail ():void {
+
+ $this->assertThrows(ServiceInstantiationException::class, function ():void {
+
+ new Container()->get(MixedRequired::class);
+
+ });
+
+ }
+
+ /**
+ * ### Mixed parameters use defaults
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testMixedParametersUseDefaults ():void {
+
+ $service = new Container()->get(MixedDefault::class);
+
+ self::assertNull($service->value);
+
+ }
+
+ /**
+ * ### Circular dependencies are detected
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testCircularDependenciesAreDetected ():void {
+
+ $this->assertThrows(CircularDependencyException::class, function ():void {
+
+ new Container()->get(CircularA::class);
+
+ });
+
+ }
+
+ /**
+ * ### Explicit singletons are reused inside implicit graphs
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitSingletonsAreReusedInsideImplicitGraphs ():void {
+
+ $container = new Container();
+
+ $container->singleton(Logger::class);
+
+ $first = $container->get(UserService::class);
+ $second = $container->get(UserService::class);
+
+ self::assertNotSame($first, $second);
+ self::assertSame($first->repository->logger, $second->repository->logger);
+
+ }
+
+ /**
+ * ### Explicit singleton consumers retain transient dependencies
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitSingletonConsumersRetainTransientDependencies ():void {
+
+ $container = new Container();
+
+ $container->singleton(SingletonConsumer::class);
+
+ $first = $container->get(SingletonConsumer::class);
+ $second = $container->get(SingletonConsumer::class);
+
+ self::assertSame($first, $second);
+ self::assertSame($first->logger, $second->logger);
+
+ }
+
+ /**
+ * ### Scoped dependencies require an active scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopedDependenciesRequireAnActiveScope ():void {
+
+ $container = new Container();
+
+ $container->scoped(ScopedDependency::class);
+
+ $this->assertThrows(ScopeRequiredException::class, function () use ($container):void {
+
+ $container->get(ScopedConsumer::class);
+
+ });
+
+ }
+
+ /**
+ * ### Scoped dependencies resolve within a scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopedDependenciesResolveWithinAScope ():void {
+
+ $container = new Container();
+
+ $container->scoped(ScopedDependency::class);
+
+ $scope = $container->createScope();
+
+ $first = $scope->get(ScopedConsumer::class);
+ $second = $scope->get(ScopedConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertSame($first->dependency, $second->dependency);
+
+ $scope->close();
+
+ }
+
+ /**
+ * ### Implicit autowiring works after freezing the container
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testImplicitAutowiringWorksAfterFreezingTheContainer ():void {
+
+ $container = new Container();
+
+ $container->freeze();
+
+ $service = $container->get(UserService::class);
+
+ self::assertInstanceOf(UserService::class, $service);
+ self::assertTrue($container->isFrozen());
+
+ }
+
+ /**
+ * ### Union resolves a single implicit candidate
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testUnionResolvesASingleImplicitCandidate ():void {
+
+ $service = new Container()->get(UnionOnlyA::class);
+
+ self::assertInstanceOf(UnionA::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Union rejects multiple implicit candidates
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testUnionRejectsMultipleImplicitCandidates ():void {
+
+ $this->assertThrows(AmbiguousDependencyException::class, function ():void {
+
+ new Container()->get(UnionAmbiguous::class);
+
+ });
+
+ }
+
+ /**
+ * ### Union prefers an explicit registration
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testUnionPrefersAnExplicitRegistration ():void {
+
+ $container = new Container();
+
+ $container->singleton(UnionB::class);
+
+ $service = $container->get(UnionAmbiguous::class);
+
+ self::assertInstanceOf(UnionB::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Union rejects multiple explicit registrations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testUnionRejectsMultipleExplicitRegistrations ():void {
+
+ $container = new Container();
+
+ $container->singleton(UnionA::class);
+ $container->singleton(UnionB::class);
+
+ $this->assertThrows(AmbiguousDependencyException::class, function () use ($container):void {
+
+ $container->get(UnionAmbiguous::class);
+
+ });
+
+ }
+
+ /**
+ * ### Union resolves an implicit candidate before using null default
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testUnionResolvesAnImplicitCandidateBeforeUsingNullDefault ():void {
+
+ $service = new Container()->get(UnionNullable::class);
+
+ self::assertInstanceOf(UnionB::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Intersection resolves a unique registered implementation
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testIntersectionResolvesAUniqueRegisteredImplementation ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ TestLogger::class,
+ TestFileLogger::class
+ );
+
+ $service = $container->get(IntersectionConsumer::class);
+
+ self::assertInstanceOf(TestFileLogger::class, $service->logger);
+
+ }
+
+ /**
+ * ### Intersection fails without matching implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testIntersectionFailsWithoutMatchingImplementations ():void {
+
+ $this->assertThrows(ServiceInstantiationException::class, function ():void {
+
+ new Container()->get(IntersectionConsumer::class);
+
+ });
+
+ }
+
+ /**
+ * ### Intersection rejects multiple matching implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testIntersectionRejectsMultipleMatchingImplementations ():void {
+
+ $container = new Container();
+
+ $container->bind(TestFileLogger::class, TestFileLogger::class);
+ $container->bind(AnotherTestLogger::class, AnotherTestLogger::class);
+
+ $this->assertThrows(AmbiguousDependencyException::class, function () use ($container):void {
+
+ $container->get(IntersectionConsumer::class);
+
+ });
+
+ }
+
+ /**
+ * ### Intersection does not execute factories during candidate discovery
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testIntersectionDoesNotExecuteFactoriesDuringCandidateDiscovery ():void {
+
+ $container = new Container();
+
+ $executed = false;
+
+ $container->factory(
+ TestLogger::class,
+ function ($resolver) use (&$executed):object {
+
+ $executed = true;
+
+ return new TestFileLogger();
+
+ }
+ );
+
+ $this->assertThrows(ServiceInstantiationException::class, function () use ($container):void {
+
+ $container->get(IntersectionConsumer::class);
+
+ });
+
+ self::assertFalse($executed);
+
+ }
+
+ /**
+ * ### Metadata caching preserves transient dependencies
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testMetadataCachingPreservesTransientDependencies ():void {
+
+ $container = new Container();
+
+ $first = $container->get(UserService::class);
+ $second = $container->get(UserService::class);
+
+ self::assertNotSame($first, $second);
+ self::assertNotSame($first->repository, $second->repository);
+ self::assertNotSame($first->repository->logger, $second->repository->logger);
+
+ }
+
+ /**
+ * ### Metadata caching respects new registrations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testMetadataCachingRespectsNewRegistrations ():void {
+
+ $container = new Container();
+
+ $first = $container->get(UnionOnlyA::class);
+
+ self::assertInstanceOf(UnionA::class, $first->dependency);
+
+ $container->singleton(UnionInterface::class, UnionA::class);
+
+ }
+
+}
diff --git a/tests/Unit/CollectionInjectionTest.php b/tests/Unit/CollectionInjectionTest.php
new file mode 100644
index 0000000..a8198fb
--- /dev/null
+++ b/tests/Unit/CollectionInjectionTest.php
@@ -0,0 +1,521 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.5
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Unit;
+
+use FireHub\Testing\FireHubTestCase;
+use FireHub\Container\Container;
+use FireHub\Container\Exception\ {
+ CircularDependencyException, ScopeRequiredException, ServiceInstantiationException};
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use FireHub\Tests\Container\Stubs\ {
+ CollectionConsoleLogger, CollectionFileLogger, CollectionInjectionCircularA, CollectionInjectionCircularB,
+ CollectionInjectionConflictingAttributesConsumer, CollectionInjectionConsumer, CollectionInjectionDefaultConsumer,
+ CollectionInjectionEmptyTypeConsumer, CollectionInjectionMultipleAttributesConsumer,
+ CollectionInjectionNestedConsumer, CollectionInjectionNullableConsumer, CollectionInjectionScalarConsumer,
+ CollectionInjectionUnknownTypeConsumer, CollectionInjectionUntypedConsumer, CollectionUnrelatedService
+};
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests collection constructor injection
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[CoversClass(Container::class)]
+final class CollectionInjectionTest extends FireHubTestCase {
+
+ /**
+ * @since 1.0.0
+ */
+ use ExceptionAssertions;
+
+ /**
+ * ### Collection attribute injects services in registration order
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectsRegisteredImplementationsInRegistrationOrder ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionConsoleLogger::class);
+ $container->singleton(CollectionUnrelatedService::class);
+ $container->singleton(CollectionFileLogger::class);
+
+ $consumer = $container->get(CollectionInjectionConsumer::class);
+
+ self::assertCount(2, $consumer->loggers);
+ self::assertInstanceOf(CollectionConsoleLogger::class, $consumer->loggers[0]);
+ self::assertInstanceOf(CollectionFileLogger::class, $consumer->loggers[1]);
+
+ }
+
+ /**
+ * ### Collection attribute injects an empty array for no registrations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testNoRegistrationsInjectEmptyArray ():void {
+
+ $consumer = (new Container)->get(CollectionInjectionConsumer::class);
+
+ self::assertSame([], $consumer->loggers);
+
+ }
+
+ /**
+ * ### Collection attribute ignores unregistered implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testUnregisteredConstructibleImplementationIsNotDiscovered ():void {
+
+ $container = new Container;
+
+ $consumer = $container->get(CollectionInjectionConsumer::class);
+
+ self::assertSame([], $consumer->loggers);
+
+ }
+
+ /**
+ * ### Collection attribute overrides constructor default
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionOverridesConstructorDefault ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionFileLogger::class);
+
+ $consumer = $container->get(CollectionInjectionDefaultConsumer::class);
+
+ self::assertCount(1, $consumer->loggers);
+
+ }
+
+ /**
+ * ### Collection attribute overrides parameter binding
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitParameterBindingOverridesInjection ():void {
+
+ $container = new Container;
+
+ $override = [new CollectionConsoleLogger];
+
+ $container->bindParameters(
+ CollectionInjectionConsumer::class,
+ ['loggers' => $override]
+ );
+
+ $consumer = $container->get(CollectionInjectionConsumer::class);
+
+ self::assertSame($override, $consumer->loggers);
+
+ }
+
+ /**
+ * ### Collection attribute works in nested dependency graphs
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testNestedGraphUsesCollectionInjection ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionFileLogger::class);
+
+ $nested = $container->get(CollectionInjectionNestedConsumer::class);
+
+ self::assertInstanceOf(
+ CollectionFileLogger::class,
+ $nested->consumer->loggers[0]
+ );
+
+ }
+
+ /**
+ * ### Collection attribute uses singleton instances
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionUsesSingletonInstances ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionFileLogger::class);
+
+ $first = $container->get(CollectionInjectionConsumer::class);
+ $second = $container->get(CollectionInjectionConsumer::class);
+
+ self::assertSame($first->loggers[0], $second->loggers[0]);
+ self::assertSame(
+ $first->loggers[0],
+ $container->get(CollectionFileLogger::class)
+ );
+
+ }
+
+ /**
+ * ### Collection attribute creates transient instances
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionCreatesTransientInstances ():void {
+
+ $container = new Container;
+
+ $container->transient(CollectionFileLogger::class);
+
+ $first = $container->get(CollectionInjectionConsumer::class);
+ $second = $container->get(CollectionInjectionConsumer::class);
+
+ self::assertNotSame($first->loggers[0], $second->loggers[0]);
+
+ }
+
+ /**
+ * ### Collection attribute requires a scope for scoped services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionRequiresScopeForScopedServices ():void {
+
+ $container = new Container;
+
+ $container->scoped(CollectionFileLogger::class);
+
+ $this->assertThrows(
+ ScopeRequiredException::class,
+ static function () use ($container):void {
+
+ $container->get(CollectionInjectionConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Collection attribute reuses scoped instances
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionReusesScopedInstances ():void {
+
+ $container = new Container;
+
+ $container->scoped(CollectionFileLogger::class);
+
+ $scope = $container->createScope();
+
+ try {
+
+ $first = $scope->get(CollectionInjectionConsumer::class);
+ $second = $scope->get(CollectionInjectionConsumer::class);
+
+ self::assertSame($first->loggers[0], $second->loggers[0]);
+ self::assertSame(
+ $first->loggers[0],
+ $scope->get(CollectionFileLogger::class)
+ );
+
+ } finally {
+
+ $scope->close();
+
+ }
+
+ }
+
+ /**
+ * ### Collection attribute isolates different scopes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionIsolatesDifferentScopes ():void {
+
+ $container = new Container;
+
+ $container->scoped(CollectionFileLogger::class);
+
+ $firstScope = $container->createScope();
+ $secondScope = $container->createScope();
+
+ try {
+
+ $first = $firstScope->get(CollectionInjectionConsumer::class);
+ $second = $secondScope->get(CollectionInjectionConsumer::class);
+
+ self::assertNotSame($first->loggers[0], $second->loggers[0]);
+
+ } finally {
+
+ $firstScope->close();
+ $secondScope->close();
+
+ }
+
+ }
+
+ /**
+ * ### Collection attribute supports factories
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionSupportsFactories ():void {
+
+ $container = new Container;
+
+ $executions = 0;
+
+ $container->factory(
+ CollectionFileLogger::class,
+ static function ($resolver) use (&$executions):object {
+
+ $executions++;
+
+ return new CollectionFileLogger;
+
+ }
+ );
+
+ self::assertSame(0, $executions);
+
+ $consumer = $container->get(CollectionInjectionConsumer::class);
+
+ self::assertInstanceOf(CollectionFileLogger::class, $consumer->loggers[0]);
+ self::assertSame(1, $executions);
+
+ }
+
+ /**
+ * ### Collection attribute sees registrations added after first resolution
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionSeesRegistrationsAddedAfterFirstResolution ():void {
+
+ $container = new Container;
+
+ self::assertSame(
+ [],
+ $container->get(CollectionInjectionConsumer::class)->loggers
+ );
+
+ $container->singleton(CollectionFileLogger::class);
+
+ self::assertCount(
+ 1,
+ $container->get(CollectionInjectionConsumer::class)->loggers
+ );
+
+ }
+
+ /**
+ * ### Collection attribute works after container freezing
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionWorksAfterFreeze ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionFileLogger::class);
+ $container->freeze();
+
+ self::assertCount(
+ 1,
+ $container->get(CollectionInjectionConsumer::class)->loggers
+ );
+
+ }
+
+ /**
+ * ### Collection attribute preserves circular dependency detection
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInjectionPreservesCircularDependencyDetection ():void {
+
+ $container = new Container;
+
+ $container->transient(CollectionInjectionCircularB::class);
+
+ $this->assertThrows(
+ CircularDependencyException::class,
+ static function () use ($container):void {
+
+ $container->get(CollectionInjectionCircularA::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Collection attribute rejects nullable array
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testRejectsNullableArray ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(CollectionInjectionNullableConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Collection attribute rejects scalar types
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testRejectsScalar ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(CollectionInjectionScalarConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Collection attribute rejects untyped elements
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testRejectsUntyped ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(CollectionInjectionUntypedConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Collection attribute rejects unknown element types
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testRejectsUnknownElementType ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(CollectionInjectionUnknownTypeConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Collection attribute rejects empty element types
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testRejectsEmptyElementType ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(CollectionInjectionEmptyTypeConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Collection attribute rejects duplicate attributes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testRejectsDuplicateAttributes ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(CollectionInjectionMultipleAttributesConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Collection attribute rejects conflicting tagged and inject all
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testRejectsConflictingTaggedAndInjectAll ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(CollectionInjectionConflictingAttributesConsumer::class);
+
+ }
+ );
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/Unit/CollectionServicesTest.php b/tests/Unit/CollectionServicesTest.php
new file mode 100644
index 0000000..e4c2768
--- /dev/null
+++ b/tests/Unit/CollectionServicesTest.php
@@ -0,0 +1,532 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.5
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Unit;
+
+use FireHub\Testing\FireHubTestCase;
+use FireHub\Container\Container;
+use FireHub\Container\Exception\ {
+ CircularDependencyException, ScopeClosedException, ScopeRequiredException, ServiceTypeMismatchException
+};
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use FireHub\Tests\Container\Stubs\ {
+ CollectionAbstractLogger, CollectionAlternateImplementation, CollectionConsoleLogger,
+ CollectionConcreteAbstractLogger, CollectionDatabaseLogger, CollectionFileLogger, CollectionLogger,
+ CollectionOtherService, CollectionUnrelatedService
+};
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests collection service resolution
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[CoversClass(Container::class)]
+final class CollectionServicesTest extends FireHubTestCase {
+
+ /**
+ * @since 1.0.0
+ */
+ use ExceptionAssertions;
+
+ /**
+ * ### All returns empty for unknown implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllReturnsEmptyForUnknownImplementations ():void {
+
+ $container = new Container;
+
+ self::assertSame([], $container->all(CollectionLogger::class));
+
+ $scope = $container->createScope();
+
+ try {
+
+ self::assertSame([], $scope->all(CollectionLogger::class));
+
+ } finally {
+
+ $scope->close();
+
+ }
+
+ }
+
+ /**
+ * ### All preserves registration order and filters types
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllPreservesRegistrationOrderAndFiltersTypes ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionDatabaseLogger::class);
+ $container->singleton(CollectionUnrelatedService::class);
+ $container->singleton(CollectionFileLogger::class);
+
+ $services = $container->all(CollectionLogger::class);
+
+ self::assertCount(2, $services);
+ self::assertInstanceOf(CollectionDatabaseLogger::class, $services[0]);
+ self::assertInstanceOf(CollectionFileLogger::class, $services[1]);
+
+ }
+
+ /**
+ * ### All does not discover unregistered classes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllDoesNotDiscoverUnregisteredClasses ():void {
+
+ self::assertSame(
+ [],
+ (new Container)->all(CollectionLogger::class)
+ );
+
+ }
+
+ /**
+ * ### All supports concrete types
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllSupportsConcreteType ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionFileLogger::class);
+
+ self::assertCount(
+ 1,
+ $container->all(CollectionFileLogger::class)
+ );
+
+ }
+
+ /**
+ * ### All supports abstract types
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllSupportsAbstractType ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionConcreteAbstractLogger::class);
+
+ $services = $container->all(CollectionAbstractLogger::class);
+
+ self::assertCount(1, $services);
+ self::assertInstanceOf(
+ CollectionConcreteAbstractLogger::class,
+ $services[0]
+ );
+
+ }
+
+ /**
+ * ### All supports inherited interfaces
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllSupportsInheritedInterfaces ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionAlternateImplementation::class);
+
+ self::assertCount(
+ 1,
+ $container->all(CollectionLogger::class)
+ );
+
+ }
+
+ /**
+ * ### All reuses singletons
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllReusesSingletons ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionFileLogger::class);
+
+ self::assertSame(
+ $container->all(CollectionLogger::class)[0],
+ $container->all(CollectionLogger::class)[0]
+ );
+
+ }
+
+ /**
+ * ### All recreates transients
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllRecreatesTransients ():void {
+
+ $container = new Container;
+
+ $container->transient(CollectionFileLogger::class);
+
+ self::assertNotSame(
+ $container->all(CollectionLogger::class)[0],
+ $container->all(CollectionLogger::class)[0]
+ );
+
+ }
+
+ /**
+ * ### All requires scope for scoped services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllRequiresScopeForScopedServices ():void {
+
+ $container = new Container;
+
+ $container->scoped(CollectionFileLogger::class);
+
+ $this->assertThrows(
+ ScopeRequiredException::class,
+ static function () use ($container):void {
+
+ $container->all(CollectionLogger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Scope::all preserves mixed lifetimes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopeAllPreservesMixedLifetimes ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionFileLogger::class);
+ $container->scoped(CollectionConsoleLogger::class);
+ $container->transient(CollectionDatabaseLogger::class);
+
+ $firstScope = $container->createScope();
+ $secondScope = $container->createScope();
+
+ try {
+
+ $first = $firstScope->all(CollectionLogger::class);
+ $second = $firstScope->all(CollectionLogger::class);
+ $third = $secondScope->all(CollectionLogger::class);
+
+ self::assertCount(3, $first);
+
+ // Singleton instances are shared across scopes.
+ self::assertSame($first[0], $second[0]);
+ self::assertSame($first[0], $third[0]);
+
+ // Scoped instances are shared within the same scope.
+ self::assertSame($first[1], $second[1]);
+ self::assertNotSame($first[1], $third[1]);
+
+ // Transient instances are recreated.
+ self::assertNotSame($first[2], $second[2]);
+ self::assertNotSame($first[2], $third[2]);
+
+ } finally {
+
+ $firstScope->close();
+ $secondScope->close();
+
+ }
+
+ }
+
+ /**
+ * ### Scope::all rejects closed scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testScopeAllRejectsClosedScope ():void {
+
+ $scope = (new Container)->createScope();
+
+ $scope->close();
+
+ $this->assertThrows(
+ ScopeClosedException::class,
+ static function () use ($scope):void {
+
+ $scope->all(CollectionLogger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### All deduplicates alias after target
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllDeduplicatesAliasAfterTarget ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionFileLogger::class);
+ $container->alias(CollectionLogger::class, CollectionFileLogger::class);
+
+ $services = $container->all(CollectionLogger::class);
+
+ self::assertCount(1, $services);
+ self::assertSame(
+ $container->get(CollectionFileLogger::class),
+ $services[0]
+ );
+
+ }
+
+ /**
+ * ### All deduplicates alias before target
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllDeduplicatesAliasBeforeTarget ():void {
+
+ $container = new Container;
+
+ $container->alias(CollectionLogger::class, CollectionFileLogger::class);
+ $container->singleton(CollectionFileLogger::class);
+
+ $services = $container->all(CollectionLogger::class);
+
+ self::assertCount(1, $services);
+ self::assertSame(
+ $container->get(CollectionFileLogger::class),
+ $services[0]
+ );
+
+ }
+
+ /**
+ * ### All resolves factory without eager discovery
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllResolvesFactoryWithoutEagerDiscovery ():void {
+
+ $container = new Container;
+
+ $calls = 0;
+
+ $container->factory(
+ CollectionFileLogger::class,
+ static function ($resolver) use (&$calls):object {
+
+ $calls++;
+
+ return new CollectionFileLogger;
+
+ }
+ );
+
+ self::assertSame(0, $calls);
+
+ $services = $container->all(CollectionLogger::class);
+
+ self::assertCount(1, $services);
+ self::assertSame(1, $calls);
+
+ }
+
+ /**
+ * ### All rejects factory returning incompatible type
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllRejectsFactoryReturningIncompatibleType ():void {
+
+ $container = new Container;
+
+ $container->factory(
+ CollectionFileLogger::class,
+ static fn ($resolver):object => new CollectionOtherService
+ );
+
+ $this->assertThrows(
+ ServiceTypeMismatchException::class,
+ static function () use ($container):void {
+
+ $container->all(CollectionLogger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### All works after a freeze
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllWorksAfterFreeze ():void {
+
+ $container = new Container;
+
+ $container->singleton(CollectionFileLogger::class);
+ $container->freeze();
+
+ self::assertCount(
+ 1,
+ $container->all(CollectionLogger::class)
+ );
+
+ }
+
+ /**
+ * ### All detects self-referential alias
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllDetectsSelfReferentialAlias ():void {
+
+ $container = new Container;
+
+ $container->alias(
+ CollectionLogger::class,
+ CollectionLogger::class
+ );
+
+ $this->assertThrows(
+ CircularDependencyException::class,
+ static function () use ($container):void {
+
+ $container->all(CollectionLogger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### All detects multi-step alias cycle
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllDetectsMultiStepAliasCycle ():void {
+
+ $container = new Container;
+
+ $container->alias(
+ CollectionLogger::class,
+ CollectionFileLogger::class
+ );
+
+ $container->alias(
+ CollectionFileLogger::class,
+ CollectionConsoleLogger::class
+ );
+
+ $container->alias(
+ CollectionConsoleLogger::class,
+ CollectionLogger::class
+ );
+
+ $this->assertThrows(
+ CircularDependencyException::class,
+ static function () use ($container):void {
+
+ $container->all(CollectionLogger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### All matches instance definitions
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllMatchesInstanceDefinitions ():void {
+
+ $container = new Container;
+
+ $instance = new CollectionFileLogger;
+
+ $container->instance(
+ CollectionFileLogger::class,
+ $instance
+ );
+
+ self::assertSame(
+ $instance,
+ $container->all(CollectionLogger::class)[0]
+ );
+
+ }
+
+ /**
+ * ### All reflects registrations after earlier lookup
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testAllReflectsRegistrationsAfterEarlierLookup ():void {
+
+ $container = new Container;
+
+ self::assertSame(
+ [],
+ $container->all(CollectionLogger::class)
+ );
+
+ $container->singleton(CollectionFileLogger::class);
+
+ self::assertCount(
+ 1,
+ $container->all(CollectionLogger::class)
+ );
+
+ }
+
+}
\ No newline at end of file
diff --git a/tests/Unit/ContextualBindingTest.php b/tests/Unit/ContextualBindingTest.php
new file mode 100644
index 0000000..24a4834
--- /dev/null
+++ b/tests/Unit/ContextualBindingTest.php
@@ -0,0 +1,1018 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.5
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Unit;
+
+use FireHub\Testing\FireHubTestCase;
+use FireHub\Container\Container;
+use FireHub\Container\Exception\ {
+ AmbiguousDependencyException, CircularDependencyException, ServiceInstantiationException, ScopeRequiredException
+};
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use FireHub\Tests\Container\Stubs\ {
+ ContextualCache, ContextualCacheService, ContextualCircularA, ContextualCircularB, ContextualConsoleLogger,
+ ContextualDefaultConsumer, ContextualDnfConsumer, ContextualFileLogger, ContextualIntersectionConsumer,
+ ContextualLogger, ContextualNamedConsumer, ContextualNestedConsumer, ContextualOtherConsumer, ContextualResettable,
+ ContextualScopedConsumer, ContextualSerializableCache, ContextualSingletonConsumer,
+ ContextualTwoIntersectionsConsumer, ContextualUnionConsumer, ContextualUniversalService
+};
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests contextual dependency bindings
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[CoversClass(Container::class)]
+final class ContextualBindingTest extends FireHubTestCase {
+
+ use ExceptionAssertions;
+
+ /**
+ * ### Contextual binding resolves a named dependency
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingResolvesANamedDependency ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $service = $container->get(ContextualNamedConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $service->logger);
+
+ }
+
+ /**
+ * ### Contextual bindings are isolated by consumer class
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingsAreIsolatedByConsumerClass ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualOtherConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Different consumers resolve different contextual implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDifferentConsumersResolveDifferentContextualImplementations ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $container->bindContextual(
+ ContextualOtherConsumer::class,
+ ContextualLogger::class,
+ ContextualConsoleLogger::class
+ );
+
+ $first = $container->get(ContextualNamedConsumer::class);
+ $second = $container->get(ContextualOtherConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $first->logger);
+ self::assertInstanceOf(ContextualConsoleLogger::class, $second->logger);
+
+ }
+
+ /**
+ * ### Contextual binding overrides global dependency registration
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingOverridesGlobalDependencyRegistration ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ ContextualLogger::class,
+ ContextualConsoleLogger::class
+ );
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $service = $container->get(ContextualNamedConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $service->logger);
+
+ }
+
+ /**
+ * ### Explicit parameter binding overrides contextual binding
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitParameterBindingOverridesContextualBinding ():void {
+
+ $container = new Container();
+
+ $logger = new ContextualConsoleLogger();
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $container->bindParameters(
+ ContextualNamedConsumer::class,
+ [
+ 'logger' => $logger
+ ]
+ );
+
+ $service = $container->get(ContextualNamedConsumer::class);
+
+ self::assertSame($service->logger, $logger);
+
+ }
+
+ /**
+ * ### Contextual binding resolves inside nested dependency graphs
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingResolvesInsideNestedDependencyGraphs ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $service = $container->get(ContextualNestedConsumer::class);
+
+ self::assertInstanceOf(ContextualNamedConsumer::class, $service->consumer);
+ self::assertInstanceOf(ContextualFileLogger::class, $service->consumer->logger);
+
+ }
+
+ /**
+ * ### Contextual union resolves the first alternative
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualUnionResolvesTheFirstAlternative ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualUnionConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $service = $container->get(ContextualUnionConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual union resolves the second alternative
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualUnionResolvesTheSecondAlternative ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualUnionConsumer::class,
+ ContextualCache::class,
+ ContextualCacheService::class
+ );
+
+ $service = $container->get(ContextualUnionConsumer::class);
+
+ self::assertInstanceOf(ContextualCacheService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual union rejects different implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualUnionRejectsDifferentImplementations ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualUnionConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $container->bindContextual(
+ ContextualUnionConsumer::class,
+ ContextualCache::class,
+ ContextualCacheService::class
+ );
+
+ $this->assertThrows(
+ AmbiguousDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualUnionConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Contextual union deduplicates identical implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualUnionDeduplicatesIdenticalImplementations ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualUnionConsumer::class,
+ ContextualLogger::class,
+ ContextualUniversalService::class
+ );
+
+ $container->bindContextual(
+ ContextualUnionConsumer::class,
+ ContextualCache::class,
+ ContextualUniversalService::class
+ );
+
+ $service = $container->get(ContextualUnionConsumer::class);
+
+ self::assertInstanceOf(ContextualUniversalService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual union takes precedence over standard candidates
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualUnionTakesPrecedenceOverStandardCandidates ():void {
+
+ $container = new Container();
+
+ $container->singleton(ContextualCacheService::class);
+
+ $container->bindContextual(
+ ContextualUnionConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $service = $container->get(ContextualUnionConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual intersection resolves a compatible implementation
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualIntersectionResolvesACompatibleImplementation ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualIntersectionConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $service = $container->get(ContextualIntersectionConsumer::class);
+
+ self::assertInstanceOf(ContextualLogger::class, $service->dependency);
+ self::assertInstanceOf(ContextualResettable::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual intersection deduplicates identical implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualIntersectionDeduplicatesIdenticalImplementations ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualIntersectionConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $container->bindContextual(
+ ContextualIntersectionConsumer::class,
+ ContextualResettable::class,
+ ContextualFileLogger::class
+ );
+
+ $service = $container->get(ContextualIntersectionConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual intersection rejects different implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualIntersectionRejectsDifferentImplementations ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualIntersectionConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $container->bindContextual(
+ ContextualIntersectionConsumer::class,
+ ContextualResettable::class,
+ ContextualUniversalService::class
+ );
+
+ $this->assertThrows(
+ AmbiguousDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualIntersectionConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Contextual intersection rejects incompatible implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualIntersectionRejectsIncompatibleImplementations ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualIntersectionConsumer::class,
+ ContextualLogger::class,
+ ContextualConsoleLogger::class
+ );
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualIntersectionConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Invalid contextual intersection does not fall back to global candidates
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testInvalidContextualIntersectionDoesNotFallBackToGlobalCandidates ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ ContextualFileLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $container->bindContextual(
+ ContextualIntersectionConsumer::class,
+ ContextualLogger::class,
+ ContextualConsoleLogger::class
+ );
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualIntersectionConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Contextual DNF resolves an intersection branch
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualDnfResolvesAnIntersectionBranch ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualDnfConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $service = $container->get(ContextualDnfConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual DNF resolves a named branch
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualDnfResolvesANamedBranch ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualDnfConsumer::class,
+ ContextualCache::class,
+ ContextualCacheService::class
+ );
+
+ $service = $container->get(ContextualDnfConsumer::class);
+
+ self::assertInstanceOf(ContextualCacheService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual DNF resolves the second intersection branch
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualDnfResolvesTheSecondIntersectionBranch ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualTwoIntersectionsConsumer::class,
+ ContextualCache::class,
+ ContextualSerializableCache::class
+ );
+
+ $service = $container->get(ContextualTwoIntersectionsConsumer::class);
+
+ self::assertInstanceOf(ContextualSerializableCache::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual DNF rejects multiple distinct implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualDnfRejectsMultipleDistinctImplementations ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualDnfConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $container->bindContextual(
+ ContextualDnfConsumer::class,
+ ContextualCache::class,
+ ContextualCacheService::class
+ );
+
+ $this->assertThrows(
+ AmbiguousDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualDnfConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Contextual DNF deduplicates a universal implementation
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualDnfDeduplicatesAUniversalImplementation ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualTwoIntersectionsConsumer::class,
+ ContextualLogger::class,
+ ContextualUniversalService::class
+ );
+
+ $container->bindContextual(
+ ContextualTwoIntersectionsConsumer::class,
+ ContextualCache::class,
+ ContextualUniversalService::class
+ );
+
+ $service = $container->get(ContextualTwoIntersectionsConsumer::class);
+
+ self::assertInstanceOf(ContextualUniversalService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### Contextual DNF rejects implementations incompatible with all branches
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualDnfRejectsImplementationsIncompatibleWithAllBranches ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualTwoIntersectionsConsumer::class,
+ ContextualLogger::class,
+ ContextualConsoleLogger::class
+ );
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualTwoIntersectionsConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Contextual dependencies are transient by default
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualDependenciesAreTransientByDefault ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $first = $container->get(ContextualNamedConsumer::class);
+ $second = $container->get(ContextualNamedConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertNotSame($first->logger, $second->logger);
+
+ }
+
+ /**
+ * ### Contextual bindings preserve singleton lifetimes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingsPreserveSingletonLifetimes ():void {
+
+ $container = new Container();
+
+ $container->singleton(ContextualFileLogger::class);
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $first = $container->get(ContextualNamedConsumer::class);
+ $second = $container->get(ContextualNamedConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertSame($first->logger, $second->logger);
+
+ }
+
+ /**
+ * ### Contextual scoped dependencies require an active scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualScopedDependenciesRequireAnActiveScope ():void {
+
+ $container = new Container();
+
+ $container->scoped(ContextualFileLogger::class);
+
+ $container->bindContextual(
+ ContextualScopedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $this->assertThrows(
+ ScopeRequiredException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualScopedConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Contextual scoped dependencies are reused within a scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualScopedDependenciesAreReusedWithinAScope ():void {
+
+ $container = new Container();
+
+ $container->scoped(ContextualFileLogger::class);
+
+ $container->bindContextual(
+ ContextualScopedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $scope = $container->createScope();
+
+ $first = $scope->get(ContextualScopedConsumer::class);
+ $second = $scope->get(ContextualScopedConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertSame($first->logger, $second->logger);
+
+ $scope->close();
+
+ }
+
+ /**
+ * ### Contextual scoped dependencies are isolated between scopes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualScopedDependenciesAreIsolatedBetweenScopes ():void {
+
+ $container = new Container();
+
+ $container->scoped(ContextualFileLogger::class);
+
+ $container->bindContextual(
+ ContextualScopedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $firstScope = $container->createScope();
+ $secondScope = $container->createScope();
+
+ $first = $firstScope->get(ContextualScopedConsumer::class);
+ $second = $secondScope->get(ContextualScopedConsumer::class);
+
+ self::assertNotSame($first->logger, $second->logger);
+
+ $firstScope->close();
+ $secondScope->close();
+
+ }
+
+ /**
+ * ### Contextual singleton consumers retain their dependencies
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualSingletonConsumersRetainTheirDependencies ():void {
+
+ $container = new Container();
+
+ $container->singleton(ContextualSingletonConsumer::class);
+
+ $container->bindContextual(
+ ContextualSingletonConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $first = $container->get(ContextualSingletonConsumer::class);
+ $second = $container->get(ContextualSingletonConsumer::class);
+
+ self::assertSame($first, $second);
+ self::assertSame($first->logger, $second->logger);
+
+ }
+
+ /**
+ * ### Contextual binding overrides a nullable default
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingOverridesANullableDefault ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualDefaultConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $service = $container->get(ContextualDefaultConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $service->logger);
+
+ }
+
+ /**
+ * ### Contextual bindings added after resolution are recognized
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingsAddedAfterResolutionAreRecognized ():void {
+
+ $container = new Container();
+
+ $first = $container->get(ContextualDefaultConsumer::class);
+
+ self::assertTrue($first->logger === null);
+
+ $container->bindContextual(
+ ContextualDefaultConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $second = $container->get(ContextualDefaultConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $second->logger);
+
+ }
+
+ /**
+ * ### Contextual union detects ambiguity after new registrations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualUnionDetectsAmbiguityAfterNewRegistrations ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualUnionConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $first = $container->get(ContextualUnionConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $first->dependency);
+
+ $container->bindContextual(
+ ContextualUnionConsumer::class,
+ ContextualCache::class,
+ ContextualCacheService::class
+ );
+
+ $this->assertThrows(
+ AmbiguousDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualUnionConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Duplicate contextual bindings are rejected
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDuplicateContextualBindingsAreRejected ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $this->assertThrows(
+ \FireHub\Container\Exception\DuplicateContextualBindingException::class,
+ function () use ($container):void {
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualConsoleLogger::class
+ );
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Contextual bindings cannot be registered after freezing
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingsCannotBeRegisteredAfterFreezing ():void {
+
+ $container = new Container();
+
+ $container->freeze();
+
+ $this->assertThrows(
+ \FireHub\Container\Exception\ContainerFrozenException::class,
+ function () use ($container):void {
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Contextual bindings resolve after freezing
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualBindingsResolveAfterFreezing ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualFileLogger::class
+ );
+
+ $container->freeze();
+
+ $service = $container->get(ContextualNamedConsumer::class);
+
+ self::assertInstanceOf(ContextualFileLogger::class, $service->logger);
+ self::assertTrue($container->isFrozen());
+
+ }
+
+ /**
+ * ### Contextual resolution preserves circular dependency detection
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualResolutionPreservesCircularDependencyDetection ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualCircularA::class,
+ ContextualCircularB::class,
+ ContextualCircularB::class
+ );
+
+ $this->assertThrows(
+ CircularDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualCircularA::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Contextual named binding rejects incompatible implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testContextualNamedBindingRejectsIncompatibleImplementations ():void {
+
+ $container = new Container();
+
+ $container->bindContextual(
+ ContextualNamedConsumer::class,
+ ContextualLogger::class,
+ ContextualCacheService::class
+ );
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function () use ($container):void {
+
+ $container->get(ContextualNamedConsumer::class);
+
+ }
+ );
+
+ }
+
+}
diff --git a/tests/Unit/DnfResolutionTest.php b/tests/Unit/DnfResolutionTest.php
new file mode 100644
index 0000000..eb74cdb
--- /dev/null
+++ b/tests/Unit/DnfResolutionTest.php
@@ -0,0 +1,777 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.5
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Unit;
+
+use FireHub\Testing\FireHubTestCase;
+use FireHub\Container\Container;
+use FireHub\Container\Exception\ {
+ AmbiguousDependencyException, ServiceInstantiationException, ScopeRequiredException, ServiceTypeMismatchException
+};
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use FireHub\Tests\Container\Stubs\ {
+ DnfCache, DnfCacheService, DnfConsoleLogger, DnfDefaultConsumer, DnfFactoryConsumer, DnfFileLogger,
+ DnfFirstBranchConsumer, DnfLogger, DnfMultipleNamedConsumer, DnfNestedConsumer, DnfNoCandidateConsumer,
+ DnfRequiredNullableConsumer, DnfResettable, DnfScopedConsumer, DnfSecondBranchConsumer, DnfSingletonConsumer,
+ DnfSerializableCache, DnfTwoIntersectionsConsumer, DnfUniversalService
+};
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests DNF and composite dependency resolution
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[CoversClass(Container::class)]
+final class DnfResolutionTest extends FireHubTestCase {
+
+ use ExceptionAssertions;
+
+ /**
+ * ### DNF resolves the first intersection branch
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesTheFirstIntersectionBranch ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $service = $container->get(DnfFirstBranchConsumer::class);
+
+ self::assertInstanceOf(DnfFileLogger::class, $service->dependency);
+ self::assertInstanceOf(DnfLogger::class, $service->dependency);
+ self::assertInstanceOf(DnfResettable::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF resolves the second named branch
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesTheSecondNamedBranch ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfCacheService::class,
+ DnfCacheService::class
+ );
+
+ $service = $container->get(DnfSecondBranchConsumer::class);
+
+ self::assertInstanceOf(DnfCacheService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF resolves the first of two intersection branches
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesTheFirstOfTwoIntersectionBranches ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $service = $container->get(DnfTwoIntersectionsConsumer::class);
+
+ self::assertInstanceOf(DnfFileLogger::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF resolves the second of two intersection branches
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesTheSecondOfTwoIntersectionBranches ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfSerializableCache::class,
+ DnfSerializableCache::class
+ );
+
+ $service = $container->get(DnfTwoIntersectionsConsumer::class);
+
+ self::assertInstanceOf(DnfSerializableCache::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF resolves a service satisfying multiple branches
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesAServiceSatisfyingMultipleBranches ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfUniversalService::class,
+ DnfUniversalService::class
+ );
+
+ $service = $container->get(DnfTwoIntersectionsConsumer::class);
+
+ self::assertInstanceOf(DnfUniversalService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF resolves a named alternative among multiple branches
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesANamedAlternativeAmongMultipleBranches ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfCacheService::class,
+ DnfCacheService::class
+ );
+
+ $service = $container->get(DnfMultipleNamedConsumer::class);
+
+ self::assertInstanceOf(DnfCacheService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF rejects multiple matching implementations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfRejectsMultipleMatchingImplementations ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $container->bind(
+ DnfCacheService::class,
+ DnfCacheService::class
+ );
+
+ $this->assertThrows(
+ AmbiguousDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(DnfFirstBranchConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### DNF rejects multiple implementations satisfying the same branch
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfRejectsMultipleImplementationsSatisfyingTheSameBranch ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $container->bind(
+ DnfUniversalService::class,
+ DnfUniversalService::class
+ );
+
+ $this->assertThrows(
+ AmbiguousDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(DnfFirstBranchConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### DNF rejects multiple implementations across intersection branches
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfRejectsMultipleImplementationsAcrossIntersectionBranches ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $container->bind(
+ DnfSerializableCache::class,
+ DnfSerializableCache::class
+ );
+
+ $this->assertThrows(
+ AmbiguousDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(DnfTwoIntersectionsConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### DNF ignores implementations satisfying only part of an intersection
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfIgnoresImplementationsSatisfyingOnlyPartOfAnIntersection ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfConsoleLogger::class,
+ DnfConsoleLogger::class
+ );
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function () use ($container):void {
+
+ $container->get(DnfFirstBranchConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### DNF ignores implementations satisfying neither branch
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfIgnoresImplementationsSatisfyingNeitherBranch ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfConsoleLogger::class,
+ DnfConsoleLogger::class
+ );
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function () use ($container):void {
+
+ $container->get(DnfNoCandidateConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### DNF resolves a single candidate despite matching multiple branches
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesASingleCandidateDespiteMatchingMultipleBranches ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfUniversalService::class,
+ DnfUniversalService::class
+ );
+
+ $service = $container->get(DnfMultipleNamedConsumer::class);
+
+ self::assertInstanceOf(DnfUniversalService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF uses null default when no candidate exists
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfUsesNullDefaultWhenNoCandidateExists ():void {
+
+ $service = new Container()->get(DnfDefaultConsumer::class);
+
+ self::assertSame($service->dependency, null);
+
+ }
+
+ /**
+ * ### DNF resolves a candidate before using null default
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesACandidateBeforeUsingNullDefault ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $service = $container->get(DnfDefaultConsumer::class);
+
+ self::assertInstanceOf(DnfFileLogger::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF nullable dependency without default remains required
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfNullableDependencyWithoutDefaultRemainsRequired ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function ():void {
+
+ new Container()->get(DnfRequiredNullableConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### DNF nullable dependency resolves when candidate exists
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfNullableDependencyResolvesWhenCandidateExists ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfCacheService::class,
+ DnfCacheService::class
+ );
+
+ $service = $container->get(DnfRequiredNullableConsumer::class);
+
+ self::assertInstanceOf(DnfCacheService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF resolves dependencies inside nested graphs
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesDependenciesInsideNestedGraphs ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $service = $container->get(DnfNestedConsumer::class);
+
+ self::assertInstanceOf(DnfFirstBranchConsumer::class, $service->consumer);
+ self::assertInstanceOf(DnfFileLogger::class, $service->consumer->dependency);
+
+ }
+
+ /**
+ * ### DNF dependencies remain transient by default
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfDependenciesRemainTransientByDefault ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $first = $container->get(DnfFirstBranchConsumer::class);
+ $second = $container->get(DnfFirstBranchConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertNotSame($first->dependency, $second->dependency);
+
+ }
+
+ /**
+ * ### DNF reuses singleton dependencies
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfReusesSingletonDependencies ():void {
+
+ $container = new Container();
+
+ $container->singleton(DnfFileLogger::class);
+
+ $first = $container->get(DnfFirstBranchConsumer::class);
+ $second = $container->get(DnfFirstBranchConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertSame($first->dependency, $second->dependency);
+
+ }
+
+ /**
+ * ### DNF singleton consumers retain their dependencies
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfSingletonConsumersRetainTheirDependencies ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $container->singleton(DnfSingletonConsumer::class);
+
+ $first = $container->get(DnfSingletonConsumer::class);
+ $second = $container->get(DnfSingletonConsumer::class);
+
+ self::assertSame($first, $second);
+ self::assertSame($first->dependency, $second->dependency);
+
+ }
+
+ /**
+ * ### DNF scoped dependencies require an active scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfScopedDependenciesRequireAnActiveScope ():void {
+
+ $container = new Container();
+
+ $container->scoped(DnfFileLogger::class);
+
+ $this->assertThrows(
+ ScopeRequiredException::class,
+ function () use ($container):void {
+
+ $container->get(DnfScopedConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### DNF scoped dependencies are reused within the same scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfScopedDependenciesAreReusedWithinTheSameScope ():void {
+
+ $container = new Container();
+
+ $container->scoped(DnfFileLogger::class);
+
+ $scope = $container->createScope();
+
+ $first = $scope->get(DnfScopedConsumer::class);
+ $second = $scope->get(DnfScopedConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertSame($first->dependency, $second->dependency);
+
+ $scope->close();
+
+ }
+
+ /**
+ * ### DNF scoped dependencies are isolated between scopes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfScopedDependenciesAreIsolatedBetweenScopes ():void {
+
+ $container = new Container();
+
+ $container->scoped(DnfFileLogger::class);
+
+ $firstScope = $container->createScope();
+ $secondScope = $container->createScope();
+
+ $first = $firstScope->get(DnfScopedConsumer::class);
+ $second = $secondScope->get(DnfScopedConsumer::class);
+
+ self::assertNotSame($first->dependency, $second->dependency);
+
+ $firstScope->close();
+ $secondScope->close();
+
+ }
+
+ /**
+ * ### DNF resolves a factory registered under a named alternative
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfResolvesAFactoryRegisteredUnderANamedAlternative ():void {
+
+ $container = new Container();
+
+ $executed = false;
+
+ $container->factory(
+ DnfCache::class,
+ function ($resolver) use (&$executed):object {
+
+ $executed = true;
+
+ return new DnfCacheService();
+
+ }
+ );
+
+ $service = $container->get(DnfFactoryConsumer::class);
+
+ self::assertTrue($executed);
+ self::assertInstanceOf(DnfCacheService::class, $service->dependency);
+
+ }
+
+ /**
+ * ### DNF does not execute factories during intersection candidate discovery
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfDoesNotExecuteFactoriesDuringIntersectionCandidateDiscovery ():void {
+
+ $container = new Container();
+
+ $executed = false;
+
+ $container->factory(
+ DnfLogger::class,
+ function ($resolver) use (&$executed):object {
+
+ $executed = true;
+
+ return new DnfFileLogger();
+
+ }
+ );
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function () use ($container):void {
+
+ $container->get(DnfFactoryConsumer::class);
+
+ }
+ );
+
+ self::assertSame($executed, false);
+
+ }
+
+ /**
+ * ### DNF rejects an incompatible factory result
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfRejectsAnIncompatibleFactoryResult ():void {
+
+ $container = new Container();
+
+ $container->factory(
+ DnfCache::class,
+ function ($resolver):object {
+
+ return new DnfConsoleLogger();
+
+ }
+ );
+
+ $this->assertThrows(
+ ServiceTypeMismatchException::class,
+ function () use ($container):void {
+
+ $container->get(DnfFactoryConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### DNF dependency plans preserve transient semantics
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfDependencyPlansPreserveTransientSemantics ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $first = $container->get(DnfFirstBranchConsumer::class);
+ $second = $container->get(DnfFirstBranchConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertNotSame($first->dependency, $second->dependency);
+
+ }
+
+ /**
+ * ### DNF dependency plans respect registrations added after first resolution
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfDependencyPlansRespectRegistrationsAddedAfterFirstResolution ():void {
+
+ $container = new Container();
+
+ $first = $container->get(DnfDefaultConsumer::class);
+
+ self::assertNull($first->dependency);
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $second = $container->get(DnfDefaultConsumer::class);
+
+ self::assertInstanceOf(DnfFileLogger::class, $second->dependency);
+
+ }
+
+ /**
+ * ### DNF dependency plans detect new ambiguities after registration
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfDependencyPlansDetectNewAmbiguitiesAfterRegistration ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $first = $container->get(DnfFirstBranchConsumer::class);
+
+ self::assertInstanceOf(DnfFileLogger::class, $first->dependency);
+
+ $container->bind(
+ DnfCacheService::class,
+ DnfCacheService::class
+ );
+
+ $this->assertThrows(
+ AmbiguousDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(DnfFirstBranchConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### DNF autowiring works after freezing the container
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDnfAutowiringWorksAfterFreezingTheContainer ():void {
+
+ $container = new Container();
+
+ $container->bind(
+ DnfFileLogger::class,
+ DnfFileLogger::class
+ );
+
+ $container->freeze();
+
+ $service = $container->get(DnfFirstBranchConsumer::class);
+
+ self::assertInstanceOf(DnfFileLogger::class, $service->dependency);
+ self::assertTrue($container->isFrozen());
+
+ }
+
+}
diff --git a/tests/Unit/ParameterBindingTest.php b/tests/Unit/ParameterBindingTest.php
new file mode 100644
index 0000000..d71a5a5
--- /dev/null
+++ b/tests/Unit/ParameterBindingTest.php
@@ -0,0 +1,272 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.5
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Unit;
+
+use FireHub\Testing\FireHubTestCase;
+use FireHub\Container\Container;
+use FireHub\Container\Exception\ServiceInstantiationException;
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use FireHub\Tests\Container\Stubs\ {
+ BoundConnection, BoundDefaultService, BoundMixedService, BoundNullableService, BoundObjectService,
+ BoundOtherConnection, Logger
+};
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests explicit parameter bindings
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[CoversClass(Container::class)]
+final class ParameterBindingTest extends FireHubTestCase {
+
+ use ExceptionAssertions;
+
+ /**
+ * ### Explicit parameter bindings resolve scalar dependencies
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitParameterBindingsResolveScalarDependencies ():void {
+
+ $container = new Container();
+
+ $container->bindParameters(
+ BoundConnection::class,
+ [
+ 'host' => 'localhost',
+ 'port' => 3306,
+ 'database' => 'firehub'
+ ]
+ );
+
+ $service = $container->get(BoundConnection::class);
+
+ self::assertSame('localhost', $service->host);
+ self::assertSame(3306, $service->port);
+ self::assertSame('firehub', $service->database);
+
+ }
+
+ /**
+ * ### Explicit bindings combine with automatic dependency resolution
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitBindingsCombineWithAutomaticDependencyResolution ():void {
+
+ $container = new Container();
+
+ $container->bindParameters(
+ BoundMixedService::class,
+ [
+ 'host' => 'localhost'
+ ]
+ );
+
+ $service = $container->get(BoundMixedService::class);
+
+ self::assertInstanceOf(Logger::class, $service->logger);
+ self::assertSame('localhost', $service->host);
+ self::assertSame(3306, $service->port);
+
+ }
+
+ /**
+ * ### Explicit bindings override constructor defaults
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitBindingsOverrideConstructorDefaults ():void {
+
+ $container = new Container();
+
+ $container->bindParameters(
+ BoundDefaultService::class,
+ [
+ 'port' => 9000
+ ]
+ );
+
+ $service = $container->get(BoundDefaultService::class);
+
+ self::assertSame(9000, $service->port);
+
+ }
+
+ /**
+ * ### Explicit bindings support null values
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitBindingsSupportNullValues ():void {
+
+ $container = new Container();
+
+ $container->bindParameters(
+ BoundNullableService::class,
+ [
+ 'logger' => null
+ ]
+ );
+
+ $service = $container->get(BoundNullableService::class);
+
+ self::assertNull($service->logger);
+
+ }
+
+ /**
+ * ### Explicit bindings support object instances
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitBindingsSupportObjectInstances ():void {
+
+ $container = new Container();
+
+ $logger = new Logger();
+
+ $container->bindParameters(
+ BoundObjectService::class,
+ [
+ 'logger' => $logger
+ ]
+ );
+
+ $service = $container->get(BoundObjectService::class);
+
+ self::assertSame($service->logger, $logger);
+
+ }
+
+ /**
+ * ### Explicit bindings are isolated by implementation class
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitBindingsAreIsolatedByImplementationClass ():void {
+
+ $container = new Container();
+
+ $container->bindParameters(
+ BoundConnection::class,
+ [
+ 'host' => 'localhost',
+ 'port' => 3306,
+ 'database' => 'firehub'
+ ]
+ );
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ function () use ($container):void {
+
+ $container->get(BoundOtherConnection::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Explicit bindings preserve transient lifetimes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitBindingsPreserveTransientLifetimes ():void {
+
+ $container = new Container();
+
+ $container->bindParameters(
+ BoundDefaultService::class,
+ [
+ 'port' => 9000
+ ]
+ );
+
+ $first = $container->get(BoundDefaultService::class);
+ $second = $container->get(BoundDefaultService::class);
+
+ self::assertNotSame($first, $second);
+ self::assertSame($first->port, $second->port);
+
+ }
+
+ /**
+ * ### Explicit bindings work with singleton services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitBindingsWorkWithSingletonServices ():void {
+
+ $container = new Container();
+
+ $container->singleton(BoundDefaultService::class);
+
+ $container->bindParameters(
+ BoundDefaultService::class,
+ [
+ 'port' => 9000
+ ]
+ );
+
+ $first = $container->get(BoundDefaultService::class);
+ $second = $container->get(BoundDefaultService::class);
+
+ self::assertSame($first, $second);
+ self::assertSame(9000, $first->port);
+
+ }
+
+ /**
+ * ### Explicit bindings respect container freezing
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitBindingsRespectContainerFreezing ():void {
+
+ $container = new Container();
+
+ $container->freeze();
+
+ $this->assertThrows(
+ \FireHub\Container\Exception\ContainerFrozenException::class,
+ function () use ($container):void {
+
+ $container->bindParameters(
+ BoundDefaultService::class,
+ [
+ 'port' => 9000
+ ]
+ );
+
+ }
+ );
+
+ }
+
+}
diff --git a/tests/Unit/TaggedInjectionTest.php b/tests/Unit/TaggedInjectionTest.php
new file mode 100644
index 0000000..c08a879
--- /dev/null
+++ b/tests/Unit/TaggedInjectionTest.php
@@ -0,0 +1,524 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.5
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Unit;
+
+use FireHub\Testing\FireHubTestCase;
+use FireHub\Container\Container;
+use FireHub\Container\Exception\ {
+ CircularDependencyException, ServiceInstantiationException, ScopeRequiredException
+};
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use FireHub\Tests\Container\Stubs\ {
+ TaggedConsoleLogger, TaggedFileLogger, TaggedInjectionCircularA, TaggedInjectionCircularB, TaggedInjectionConsumer,
+ TaggedInjectionDefaultConsumer, TaggedInjectionEmptyTagConsumer, TaggedInjectionNestedConsumer,
+ TaggedInjectionNullableConsumer, TaggedInjectionScalarConsumer, TaggedInjectionUnknownConsumer,
+ TaggedInjectionUntypedConsumer, TaggedInjectionWhitespaceTagConsumer, TaggedLogger
+};
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests tagged constructor injection
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[CoversClass(Container::class)]
+final class TaggedInjectionTest extends FireHubTestCase {
+
+ use ExceptionAssertions;
+
+ /**
+ * ### Tagged attribute injects services in registration order
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedAttributeInjectsServicesInRegistrationOrder ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->singleton(TaggedConsoleLogger::class);
+
+ $container->tag(
+ 'logging',
+ TaggedConsoleLogger::class,
+ TaggedFileLogger::class
+ );
+
+ $consumer = $container->get(TaggedInjectionConsumer::class);
+
+ self::assertCount(2, $consumer->loggers);
+ self::assertInstanceOf(TaggedConsoleLogger::class, $consumer->loggers[0]);
+ self::assertInstanceOf(TaggedFileLogger::class, $consumer->loggers[1]);
+
+ }
+
+ /**
+ * ### Tagged attribute injects an empty array for unknown tags
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedAttributeInjectsAnEmptyArrayForUnknownTags ():void {
+
+ $consumer = (new Container)->get(TaggedInjectionUnknownConsumer::class);
+
+ self::assertSame([], $consumer->services);
+
+ }
+
+ /**
+ * ### Tagged attribute overrides an array constructor default
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedAttributeOverridesAnArrayConstructorDefault ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $consumer = $container->get(TaggedInjectionDefaultConsumer::class);
+
+ self::assertCount(1, $consumer->loggers);
+ self::assertInstanceOf(TaggedFileLogger::class, $consumer->loggers[0]);
+
+ }
+
+ /**
+ * ### Tagged attribute works in nested dependency graphs
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedAttributeWorksInNestedDependencyGraphs ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $nested = $container->get(TaggedInjectionNestedConsumer::class);
+
+ self::assertInstanceOf(TaggedInjectionConsumer::class, $nested->consumer);
+ self::assertInstanceOf(TaggedFileLogger::class, $nested->consumer->loggers[0]);
+
+ }
+
+ /**
+ * ### Explicit parameter bindings override tagged injection
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testExplicitParameterBindingsOverrideTaggedInjection ():void {
+
+ $container = new Container;
+
+ $override = [new TaggedConsoleLogger];
+
+ $container->bindParameters(
+ TaggedInjectionConsumer::class,
+ ['loggers' => $override]
+ );
+
+ $consumer = $container->get(TaggedInjectionConsumer::class);
+
+ self::assertSame($consumer->loggers, $override);
+
+ }
+
+ /**
+ * ### Tagged injection uses singleton instances
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionUsesSingletonInstances ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $first = $container->get(TaggedInjectionConsumer::class);
+ $second = $container->get(TaggedInjectionConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertSame($first->loggers[0], $second->loggers[0]);
+ self::assertSame($first->loggers[0], $container->get(TaggedFileLogger::class));
+
+ }
+
+ /**
+ * ### Tagged injection creates new transient instances
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionCreatesNewTransientInstances ():void {
+
+ $container = new Container;
+
+ $container->transient(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $first = $container->get(TaggedInjectionConsumer::class);
+ $second = $container->get(TaggedInjectionConsumer::class);
+
+ self::assertNotSame($first->loggers[0], $second->loggers[0]);
+
+ }
+
+ /**
+ * ### Tagged injection requires a scope for scoped services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionRequiresAScopeForScopedServices ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $this->assertThrows(
+ ScopeRequiredException::class,
+ function () use ($container):void {
+
+ $container->get(TaggedInjectionConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged injection reuses scoped services within a scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionReusesScopedServicesWithinAScope ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $scope = $container->createScope();
+
+ try {
+
+ $first = $scope->get(TaggedInjectionConsumer::class);
+ $second = $scope->get(TaggedInjectionConsumer::class);
+
+ self::assertNotSame($first, $second);
+ self::assertSame($first->loggers[0], $second->loggers[0]);
+ self::assertSame($first->loggers[0], $scope->get(TaggedFileLogger::class));
+
+ } finally {
+
+ $scope->close();
+
+ }
+
+ }
+
+ /**
+ * ### Tagged injection isolates scoped services between scopes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionIsolatesScopedServicesBetweenScopes ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $firstScope = $container->createScope();
+ $secondScope = $container->createScope();
+
+ try {
+
+ $first = $firstScope->get(TaggedInjectionConsumer::class);
+ $second = $secondScope->get(TaggedInjectionConsumer::class);
+
+ self::assertTrue($first->loggers[0] !== $second->loggers[0]);
+
+ } finally {
+
+ $firstScope->close();
+ $secondScope->close();
+
+ }
+
+ }
+
+ /**
+ * ### Tagged injection uses the active scope in nested graphs
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionUsesTheActiveScopeInNestedGraphs ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $scope = $container->createScope();
+
+ try {
+
+ $nested = $scope->get(TaggedInjectionNestedConsumer::class);
+
+ self::assertSame($nested->consumer->loggers[0], $scope->get(TaggedFileLogger::class));
+
+ } finally {
+
+ $scope->close();
+
+ }
+
+ }
+
+ /**
+ * ### Tagged injection resolves aliases
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionResolvesAliases ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->alias(TaggedLogger::class, TaggedFileLogger::class);
+ $container->tag('logging', TaggedLogger::class);
+
+ $consumer = $container->get(TaggedInjectionConsumer::class);
+
+ self::assertCount(1, $consumer->loggers);
+ self::assertSame($consumer->loggers[0], $container->get(TaggedFileLogger::class));
+
+ }
+
+ /**
+ * ### Tagged injection resolves factories
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionResolvesFactories ():void {
+
+ $container = new Container;
+
+ $executions = 0;
+
+ $container->factory(
+ TaggedFileLogger::class,
+ static function ($resolver) use (&$executions):object {
+
+ $executions++;
+
+ return new TaggedFileLogger;
+
+ }
+ );
+
+ $container->tag('logging', TaggedFileLogger::class);
+
+ self::assertSame($executions, 0);
+
+ $consumer = $container->get(TaggedInjectionConsumer::class);
+
+ self::assertInstanceOf(TaggedFileLogger::class, $consumer->loggers[0]);
+ self::assertSame($executions, 1);
+
+ }
+
+ /**
+ * ### Tagged injection respects dynamically added tag associations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionRespectsDynamicallyAddedTagAssociations ():void {
+
+ $container = new Container;
+
+ $first = $container->get(TaggedInjectionConsumer::class);
+
+ self::assertSame($first->loggers, []);
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $second = $container->get(TaggedInjectionConsumer::class);
+
+ self::assertCount(1, $second->loggers);
+ self::assertInstanceOf(TaggedFileLogger::class, $second->loggers[0]);
+
+ }
+
+ /**
+ * ### Tagged injection works after container freezing
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionWorksAfterContainerFreezing ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->tag('logging', TaggedFileLogger::class);
+ $container->freeze();
+
+ $consumer = $container->get(TaggedInjectionConsumer::class);
+
+ self::assertInstanceOf(TaggedFileLogger::class, $consumer->loggers[0]);
+
+ }
+
+ /**
+ * ### Tagged injection rejects nullable array parameters
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionRejectsNullableArrayParameters ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(TaggedInjectionNullableConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged injection rejects scalar parameters
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionRejectsScalarParameters ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(TaggedInjectionScalarConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged injection rejects untyped parameters
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionRejectsUntypedParameters ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(TaggedInjectionUntypedConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged injection rejects empty tag names
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionRejectsEmptyTagNames ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(TaggedInjectionEmptyTagConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged injection rejects whitespace-only tag names
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionRejectsWhitespaceOnlyTagNames ():void {
+
+ $this->assertThrows(
+ ServiceInstantiationException::class,
+ static function ():void {
+
+ (new Container)->get(TaggedInjectionWhitespaceTagConsumer::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged injection preserves circular dependency detection
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedInjectionPreservesCircularDependencyDetection ():void {
+
+ $container = new Container;
+
+ $container->transient(TaggedInjectionCircularB::class);
+ $container->tag('circular', TaggedInjectionCircularB::class);
+
+ $this->assertThrows(
+ CircularDependencyException::class,
+ function () use ($container):void {
+
+ $container->get(TaggedInjectionCircularA::class);
+
+ }
+ );
+
+ }
+
+}
diff --git a/tests/Unit/TaggedServicesTest.php b/tests/Unit/TaggedServicesTest.php
new file mode 100644
index 0000000..bd23a80
--- /dev/null
+++ b/tests/Unit/TaggedServicesTest.php
@@ -0,0 +1,831 @@
+
+ * @copyright 2026-present The FireHub Project - All rights reserved
+ * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0
+ *
+ * @php-version >=8.5
+ * @package Container\Tests
+ */
+
+namespace FireHub\Tests\Container\Unit;
+
+use FireHub\Testing\FireHubTestCase;
+use FireHub\Container\Container;
+use FireHub\Container\Exception\ {
+ ServiceNotFoundException, ScopeRequiredException
+};
+use FireHub\Tests\Container\Support\ExceptionAssertions;
+use FireHub\Tests\Container\Stubs\ {
+ TaggedConsoleLogger, TaggedDatabaseLogger, TaggedFactoryService, TaggedFileLogger, TaggedLogger, TaggedScopedService,
+ TaggedSingletonService, TaggedTransientService, TaggedUnregisteredService
+};
+use PHPUnit\Framework\Attributes\ {
+ CoversClass, Group, Small
+};
+
+/**
+ * ### Tests tagged services
+ * @since 1.0.0
+ */
+#[Small]
+#[Group('container')]
+#[CoversClass(Container::class)]
+final class TaggedServicesTest extends FireHubTestCase {
+
+ use ExceptionAssertions;
+
+ /**
+ * ### Tags can be registered for explicit services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTagsCanBeRegisteredForExplicitServices ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->singleton(TaggedConsoleLogger::class);
+
+ $container->tag(
+ 'logging',
+ TaggedFileLogger::class,
+ TaggedConsoleLogger::class
+ );
+
+ self::assertTrue($container->hasTag('logging'));
+
+ self::assertSame(
+ [
+ TaggedFileLogger::class,
+ TaggedConsoleLogger::class
+ ], $container->taggedIds('logging')
+ );
+
+ }
+
+ /**
+ * ### Unknown tags return empty collections
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testUnknownTagsReturnEmptyCollections ():void {
+
+ $container = new Container;
+
+ self::assertNotTrue($container->hasTag('unknown'));
+ self::assertSame([], $container->taggedIds('unknown'));
+ self::assertSame([], $container->tagged('unknown'));
+
+ $scope = $container->createScope();
+
+ self::assertSame([], $scope->tagged('unknown'));
+
+ $scope->close();
+
+ }
+
+ /**
+ * ### Tagged services preserve association order
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedServicesPreserveAssociationOrder ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->singleton(TaggedConsoleLogger::class);
+ $container->singleton(TaggedDatabaseLogger::class);
+
+ $container->tag(
+ 'logging',
+ TaggedDatabaseLogger::class,
+ TaggedFileLogger::class,
+ TaggedConsoleLogger::class
+ );
+
+ self::assertSame(
+ [
+ TaggedDatabaseLogger::class,
+ TaggedFileLogger::class,
+ TaggedConsoleLogger::class
+ ], $container->taggedIds('logging')
+ );
+
+ $services = $container->tagged('logging');
+
+ self::assertCount(3, $services);
+ self::assertInstanceOf(TaggedDatabaseLogger::class, $services[0]);
+ self::assertInstanceOf(TaggedFileLogger::class, $services[1]);
+ self::assertInstanceOf(TaggedConsoleLogger::class, $services[2]);
+
+ }
+
+ /**
+ * ### Tags support multiple registration calls
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTagsSupportMultipleRegistrationCalls ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->singleton(TaggedConsoleLogger::class);
+
+ $container->tag('logging', TaggedFileLogger::class);
+ $container->tag('logging', TaggedConsoleLogger::class);
+
+ self::assertSame(
+ [
+ TaggedFileLogger::class,
+ TaggedConsoleLogger::class
+ ], $container->taggedIds('logging')
+ );
+
+ }
+
+ /**
+ * ### Duplicate tagged services are ignored
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testDuplicateTaggedServicesAreIgnored ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+
+ $container->tag(
+ 'logging',
+ TaggedFileLogger::class,
+ TaggedFileLogger::class
+ );
+
+ $container->tag('logging', TaggedFileLogger::class);
+
+ self::assertSame(
+ [
+ TaggedFileLogger::class
+ ], $container->taggedIds('logging')
+ );
+
+ self::assertCount(1, $container->tagged('logging'));
+
+ }
+
+ /**
+ * ### Services can belong to multiple tags
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testServicesCanBelongToMultipleTags ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+
+ $container->tag('logging', TaggedFileLogger::class);
+ $container->tag('monitoring', TaggedFileLogger::class);
+
+ self::assertTrue($container->hasTag('logging'));
+ self::assertTrue($container->hasTag('monitoring'));
+
+ $logging = $container->tagged('logging');
+ $monitoring = $container->tagged('monitoring');
+
+ self::assertSame($logging[0], $monitoring[0]);
+
+ }
+
+ /**
+ * ### Tag registration does not instantiate services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTagRegistrationDoesNotInstantiateServices ():void {
+
+ $container = new Container;
+
+ $executed = false;
+
+ $container->factory(
+ TaggedFactoryService::class,
+ function ($resolver) use (&$executed):object {
+
+ $executed = true;
+
+ return new TaggedFactoryService;
+
+ }
+ );
+
+ $container->tag('services', TaggedFactoryService::class);
+
+ self::assertFalse($executed);
+
+ $container->hasTag('services');
+ $container->taggedIds('services');
+
+ self::assertFalse($executed);
+
+ $container->tagged('services');
+
+ self::assertTrue($executed);
+
+ }
+
+ /**
+ * ### Tag registration requires explicitly registered services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTagRegistrationRequiresExplicitlyRegisteredServices ():void {
+
+ $container = new Container;
+
+ $this->assertThrows(
+ ServiceNotFoundException::class,
+ function () use ($container):void {
+
+ $container->tag(
+ 'services',
+ TaggedUnregisteredService::class
+ );
+
+ }
+ );
+
+ self::assertNotTrue($container->hasTag('services'));
+
+ }
+
+ /**
+ * ### Tag registration is atomic
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTagRegistrationIsAtomic ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+
+ $this->assertThrows(
+ ServiceNotFoundException::class,
+ function () use ($container):void {
+
+ $container->tag(
+ 'logging',
+ TaggedFileLogger::class,
+ TaggedUnregisteredService::class
+ );
+
+ }
+ );
+
+ self::assertNotTrue($container->hasTag('logging'));
+ self::assertSame([], $container->taggedIds('logging'));
+
+ }
+
+ /**
+ * ### Failed tag registration preserves existing associations
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testFailedTagRegistrationPreservesExistingAssociations ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+ $container->singleton(TaggedConsoleLogger::class);
+
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $this->assertThrows(
+ ServiceNotFoundException::class,
+ function () use ($container):void {
+
+ $container->tag(
+ 'logging',
+ TaggedConsoleLogger::class,
+ TaggedUnregisteredService::class
+ );
+
+ }
+ );
+
+ self::assertSame(
+ [
+ TaggedFileLogger::class
+ ], $container->taggedIds('logging')
+ );
+
+ }
+
+ /**
+ * ### Empty tag names are rejected
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testEmptyTagNamesAreRejected ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+
+ $this->assertThrows(
+ \FireHub\Container\Exception\InvalidTagNameException::class,
+ function () use ($container):void {
+
+ $container->tag('', TaggedFileLogger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Whitespace-only tag names are rejected
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testWhitespaceOnlyTagNamesAreRejected ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+
+ $this->assertThrows(
+ \FireHub\Container\Exception\InvalidTagNameException::class,
+ function () use ($container):void {
+
+ $container->tag(' ', TaggedFileLogger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged singleton services are reused
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedSingletonServicesAreReused ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedSingletonService::class);
+
+ $container->tag('services', TaggedSingletonService::class);
+
+ $first = $container->tagged('services');
+ $second = $container->tagged('services');
+
+ self::assertCount(1, $first);
+ self::assertSame($first[0], $second[0]);
+ self::assertSame($first[0], $container->get(TaggedSingletonService::class));
+
+ }
+
+ /**
+ * ### Tagged transient services are recreated
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedTransientServicesAreRecreated ():void {
+
+ $container = new Container;
+
+ $container->transient(TaggedTransientService::class);
+
+ $container->tag('services', TaggedTransientService::class);
+
+ $first = $container->tagged('services');
+ $second = $container->tagged('services');
+
+ self::assertInstanceOf(TaggedTransientService::class, $first[0]);
+ self::assertInstanceOf(TaggedTransientService::class, $second[0]);
+ self::assertNotSame($first[0], $second[0]);
+
+ }
+
+ /**
+ * ### Tagged scoped services require an active scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedScopedServicesRequireAnActiveScope ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedScopedService::class);
+
+ $container->tag('services', TaggedScopedService::class);
+
+ $this->assertThrows(
+ ScopeRequiredException::class,
+ function () use ($container):void {
+
+ $container->tagged('services');
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged scoped services are reused within the same scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedScopedServicesAreReusedWithinTheSameScope ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedScopedService::class);
+
+ $container->tag('services', TaggedScopedService::class);
+
+ $scope = $container->createScope();
+
+ $first = $scope->tagged('services');
+ $second = $scope->tagged('services');
+
+ self::assertInstanceOf(TaggedScopedService::class, $first[0]);
+ self::assertSame($first[0], $second[0]);
+ self::assertSame($first[0], $scope->get(TaggedScopedService::class));
+
+ $scope->close();
+
+ }
+
+ /**
+ * ### Tagged scoped services are isolated between scopes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedScopedServicesAreIsolatedBetweenScopes ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedScopedService::class);
+
+ $container->tag('services', TaggedScopedService::class);
+
+ $firstScope = $container->createScope();
+ $secondScope = $container->createScope();
+
+ $first = $firstScope->tagged('services');
+ $second = $secondScope->tagged('services');
+
+ self::assertNotSame($first[0], $second[0]);
+
+ $firstScope->close();
+ $secondScope->close();
+
+ }
+
+ /**
+ * ### Tagged services preserve mixed lifetimes
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedServicesPreserveMixedLifetimes ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedSingletonService::class);
+ $container->scoped(TaggedScopedService::class);
+ $container->transient(TaggedTransientService::class);
+
+ $container->tag(
+ 'services',
+ TaggedSingletonService::class,
+ TaggedScopedService::class,
+ TaggedTransientService::class
+ );
+
+ $firstScope = $container->createScope();
+ $secondScope = $container->createScope();
+
+ $first = $firstScope->tagged('services');
+ $second = $firstScope->tagged('services');
+ $third = $secondScope->tagged('services');
+
+ self::assertCount(3, $first);
+
+ // Singleton instances are shared across scopes.
+ self::assertSame($first[0], $second[0]);
+ self::assertSame($first[0], $third[0]);
+
+ // Scoped instances are shared only within their scope.
+ self::assertSame($first[1], $second[1]);
+ self::assertNotSame($first[1], $third[1]);
+
+ // Transient instances are always recreated.
+ self::assertNotSame($first[2], $second[2]);
+ self::assertNotSame($first[2], $third[2]);
+
+ $firstScope->close();
+ $secondScope->close();
+
+ }
+
+ /**
+ * ### Tagged singleton instances are shared between container and scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedSingletonInstancesAreSharedBetweenContainerAndScope ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedSingletonService::class);
+
+ $container->tag('services', TaggedSingletonService::class);
+
+ $scope = $container->createScope();
+
+ $fromContainer = $container->tagged('services');
+ $fromScope = $scope->tagged('services');
+
+ self::assertSame($fromContainer[0], $fromScope[0]);
+
+ $scope->close();
+
+ }
+
+ /**
+ * ### Tagged aliases resolve their target services
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedAliasesResolveTheirTargetServices ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+
+ $container->alias(
+ TaggedLogger::class,
+ TaggedFileLogger::class
+ );
+
+ $container->tag('logging', TaggedLogger::class);
+
+ self::assertSame(
+ [
+ TaggedLogger::class
+ ], $container->taggedIds('logging')
+ );
+
+ $services = $container->tagged('logging');
+
+ self::assertCount(1, $services);
+ self::assertInstanceOf(TaggedFileLogger::class, $services[0]);
+ self::assertSame($services[0], $container->get(TaggedFileLogger::class));
+
+ }
+
+ /**
+ * ### Tagged factory services are resolved
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedFactoryServicesAreResolved ():void {
+
+ $container = new Container;
+
+ $container->factory(
+ TaggedFactoryService::class,
+ static fn ($resolver):object => new TaggedFactoryService
+ );
+
+ $container->tag('services', TaggedFactoryService::class);
+
+ $first = $container->tagged('services');
+ $second = $container->tagged('services');
+
+ self::assertInstanceOf(TaggedFactoryService::class, $first[0]);
+ self::assertNotSame($first[0], $second[0]);
+
+ }
+
+ /**
+ * ### Tagged factory services preserve singleton lifetime
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedFactoryServicesPreserveSingletonLifetime ():void {
+
+ $container = new Container;
+
+ $container->factory(
+ TaggedFactoryService::class,
+ static fn ($resolver):object => new TaggedFactoryService,
+ \FireHub\Core\Meta\Enum\Container\Lifetime::SINGLETON
+ );
+
+ $container->tag('services', TaggedFactoryService::class);
+
+ $first = $container->tagged('services');
+ $second = $container->tagged('services');
+
+ self::assertSame($first[0], $second[0]);
+
+ }
+
+ /**
+ * ### Tags cannot be registered after freezing
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTagsCannotBeRegisteredAfterFreezing ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+
+ $container->freeze();
+
+ $this->assertThrows(
+ \FireHub\Container\Exception\ContainerFrozenException::class,
+ function () use ($container):void {
+
+ $container->tag('logging', TaggedFileLogger::class);
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged services remain resolvable after freezing
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedServicesRemainResolvableAfterFreezing ():void {
+
+ $container = new Container;
+
+ $container->singleton(TaggedFileLogger::class);
+
+ $container->tag('logging', TaggedFileLogger::class);
+
+ $container->freeze();
+
+ self::assertTrue($container->hasTag('logging'));
+
+ self::assertSame(
+ [
+ TaggedFileLogger::class
+ ], $container->taggedIds('logging')
+ );
+
+ $services = $container->tagged('logging');
+
+ self::assertInstanceOf(TaggedFileLogger::class, $services[0]);
+
+ }
+
+ /**
+ * ### Tagged scoped services remain resolvable after freezing
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedScopedServicesRemainResolvableAfterFreezing ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedScopedService::class);
+
+ $container->tag('services', TaggedScopedService::class);
+
+ $container->freeze();
+
+ $scope = $container->createScope();
+
+ $services = $scope->tagged('services');
+
+ self::assertInstanceOf(TaggedScopedService::class, $services[0]);
+
+ $scope->close();
+
+ }
+
+ /**
+ * ### Tagged resolution fails after closing the scope
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedResolutionFailsAfterClosingTheScope ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedScopedService::class);
+
+ $container->tag('services', TaggedScopedService::class);
+
+ $scope = $container->createScope();
+
+ $scope->close();
+
+ $this->assertThrows(
+ \FireHub\Container\Exception\ScopeClosedException::class,
+ function () use ($scope):void {
+
+ $scope->tagged('services');
+
+ }
+ );
+
+ }
+
+ /**
+ * ### Tagged resolution works inside scope run
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTaggedResolutionWorksInsideScopeRun ():void {
+
+ $container = new Container;
+
+ $container->scoped(TaggedScopedService::class);
+
+ $container->tag('services', TaggedScopedService::class);
+
+ $scope = $container->createScope();
+
+ $scope->run(
+ function ($resolver) use ($scope):void {
+
+ $services = $scope->tagged('services');
+
+ self::assertCount(1, $services);
+ self::assertInstanceOf(TaggedScopedService::class, $services[0]);
+
+ }
+ );
+
+ self::assertTrue($scope->isClosed());
+
+ }
+
+ /**
+ * ### Tags are isolated between containers
+ * @since 1.0.0
+ *
+ * @return void
+ */
+ public function testTagsAreIsolatedBetweenContainers ():void {
+
+ $firstContainer = new Container;
+ $secondContainer = new Container;
+
+ $firstContainer->singleton(TaggedFileLogger::class);
+ $secondContainer->singleton(TaggedFileLogger::class);
+
+ $firstContainer->tag('logging', TaggedFileLogger::class);
+
+ self::assertTrue($firstContainer->hasTag('logging'));
+ self::assertNotTrue($secondContainer->hasTag('logging'));
+
+ self::assertSame([], $secondContainer->taggedIds('logging'));
+
+ }
+
+}