Преглед на файлове

Release v1.0.0 – Initial Release

michelphp преди 2 дни
ревизия
eb73ef04c7

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+/vendor/
+/.vscode/
+/.idea/
+composer.lock

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 MICHEL
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 339 - 0
README.md

@@ -0,0 +1,339 @@
+# PHP Options Resolver
+
+**Strict, Fluent, and Type-Safe Option Validation for PHP.**
+
+Stop guessing what's in your `$options` array. This library provides a robust, fluent API to define, validate, and resolve options with strict type enforcement and custom validation logic. Designed for developers who value clarity and code quality.
+
+## Installation
+
+To install this library, use [Composer](https://getcomposer.org/)
+
+```bash
+composer require michel/options-resolver
+```
+
+## Requirements
+
+* PHP version 7.4 or higher
+
+---
+
+## Documentation (English)
+
+### Basic Usage
+
+Define the options for your class using `OptionsResolver` with the expected options. You can use static factory methods on the `Option` class to define types easily.
+
+```php
+<?php
+
+use Michel\Resolver\Option;
+use Michel\Resolver\OptionsResolver;
+
+class Database
+{
+    private array $options;
+
+    public function __construct(array $options = [])
+    {
+        $resolver = new OptionsResolver([
+            Option::string('host')->setOptional('localhost'),
+            Option::string('username')->required(),
+            Option::string('password')->required(),
+            Option::string('dbname')->required(),
+            Option::int('port')->setOptional(3306),
+        ]);
+
+        $this->options = $resolver->resolve($options);
+    }
+}
+
+// Example usage:
+try {
+    $database = new Database([
+        'username' => 'root',
+        'password' => 'secret',
+        'dbname' => 'app_db',
+    ]);
+    // 'host' will be 'localhost' and 'port' will be 3306
+} catch (InvalidArgumentException $e) {
+    echo "Error: " . $e->getMessage();
+}
+```
+
+### Available Types
+
+
+
+The `Option` class provides several static factory methods to enforce types automatically. Here are examples for each type:
+
+#### String
+```php
+Option::string('host')->setOptional('localhost');
+```
+
+#### Integer
+```php
+Option::int('port')->setOptional(3306);
+```
+
+#### Float
+```php
+Option::float('timeout')->setOptional(2.5);
+```
+
+#### Boolean
+```php
+Option::bool('active')->setOptional(true);
+```
+
+#### Array
+```php
+Option::array('tags')->setOptional(['php', 'library']);
+```
+
+#### Iterable
+```php
+Option::iterable('items')->required();
+```
+
+#### Mixed (No type enforcement)
+```php
+Option::mixed('metadata')->setOptional(null);
+```
+
+### Required vs Optional
+
+*   **Required**: Use `required()` to enforce that an option must be passed. If missing, an exception is thrown.
+*   **Optional**: Use `setOptional($defaultValue)` to define a default value if the option is not provided.
+
+```php
+Option::string('apiKey')->required(); // Must be provided
+Option::bool('debug')->setOptional(false); // Defaults to false if missing
+```
+
+### Custom Validation
+
+You can add custom validation logic using the `validator()` method. The closure must return a `bool`.
+
+```php
+Option::string('driver')
+    ->setOptional('mysql')
+    ->validator(function ($value) {
+        return in_array($value, ['mysql', 'pgsql', 'sqlite']);
+    });
+```
+
+### Handling Errors
+
+The `resolve()` method throws an `InvalidArgumentException` if:
+*   A required option is missing.
+*   An undefined option is provided.
+*   An option value is invalid (wrong type or failed custom validation).
+
+### Conditional Requirements
+
+You can make an option required only if another option has a specific value using `addRequiredIf`.
+
+```php
+$resolver = new OptionsResolver([
+    Option::bool('has_database')->setOptional(false),
+    Option::string('db_host')->setOptional(null),
+]);
+
+// 'db_host' becomes required only if 'has_database' is true
+$resolver->addRequiredIf('db_host', 'has_database', true);
+```
+
+### Deprecating Options
+
+You can mark an option as deprecated. A `E_USER_DEPRECATED` error will be triggered if the option is used.
+
+```php
+Option::string('old_option')->deprecate('Use "new_option" instead.');
+```
+
+### Additional Constraints
+
+The library provides helpers for common constraints like `min` and `max`. These work for strings (length), numbers (value), and arrays (count).
+
+```php
+Option::string('username')->min(3)->max(20);
+Option::int('age')->min(18);
+Option::array('tags')->max(5);
+```
+
+### Multiple Validators
+
+You can chain multiple validators. All of them must pass.
+
+```php
+Option::string('code')
+    ->validator(fn($v) => str_starts_with($v, 'A'))
+    ->validator(fn($v) => str_ends_with($v, 'Z'));
+```
+
+### License
+
+This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
+
+---
+
+## Documentation (Français)
+
+### Usage de base
+
+Définissez les options attendues pour votre classe en utilisant `OptionsResolver`. Vous pouvez utiliser les méthodes statiques de la classe `Option` pour définir les types facilement.
+
+```php
+<?php
+
+use Michel\Resolver\Option;
+use Michel\Resolver\OptionsResolver;
+
+class Database
+{
+    private array $options;
+
+    public function __construct(array $options = [])
+    {
+        $resolver = new OptionsResolver([
+            Option::string('host')->setOptional('localhost'),
+            Option::string('username')->required(),
+            Option::string('password')->required(),
+            Option::string('dbname')->required(),
+            Option::int('port')->setOptional(3306),
+        ]);
+
+        $this->options = $resolver->resolve($options);
+    }
+}
+
+// Exemple d'utilisation :
+try {
+    $database = new Database([
+        'username' => 'root',
+        'password' => 'secret',
+        'dbname' => 'app_db',
+    ]);
+    // 'host' vaudra 'localhost' et 'port' vaudra 3306
+} catch (InvalidArgumentException $e) {
+    echo "Erreur : " . $e->getMessage();
+}
+```
+
+### Types Disponibles
+
+
+
+La classe `Option` fournit plusieurs méthodes statiques pour forcer les types automatiquement. Voici des exemples pour chaque type :
+
+#### Chaîne de caractères (String)
+```php
+Option::string('host')->setOptional('localhost');
+```
+
+#### Entier (Integer)
+```php
+Option::int('port')->setOptional(3306);
+```
+
+#### Flottant (Float)
+```php
+Option::float('timeout')->setOptional(2.5);
+```
+
+#### Booléen (Boolean)
+```php
+Option::bool('active')->setOptional(true);
+```
+
+#### Tableau (Array)
+```php
+Option::array('tags')->setOptional(['php', 'library']);
+```
+
+#### Itérable (Iterable)
+```php
+Option::iterable('items')->required();
+```
+
+#### Mixte (Mixed - Pas de vérification de type)
+```php
+Option::mixed('metadata')->setOptional(null);
+```
+
+### Requis vs Optionnel
+
+*   **Requis** : Utilisez `required()` pour obliger l'utilisateur à fournir une option. Si elle est manquante, une exception est levée.
+*   **Optionnel** : Utilisez `setOptional($defaultValue)` pour définir une valeur par défaut si l'option n'est pas fournie.
+
+```php
+Option::string('apiKey')->required(); // Doit être fourni
+Option::bool('debug')->setOptional(false); // Vaut false par défaut si absent
+```
+
+### Validation Personnalisée
+
+Vous pouvez ajouter une logique de validation personnalisée via la méthode `validator()`. La closure doit retourner un `bool`.
+
+```php
+Option::string('driver')
+    ->setOptional('mysql')
+    ->validator(function ($value) {
+        return in_array($value, ['mysql', 'pgsql', 'sqlite']);
+    });
+```
+
+### Gestion des Erreurs
+
+La méthode `resolve()` lance une `InvalidArgumentException` si :
+*   Une option requise est manquante.
+*   Une option non définie est fournie.
+*   Une valeur d'option est invalide (mauvais type ou échec de validation personnalisée).
+
+### Prérequis Conditionnels
+
+Vous pouvez rendre une option obligatoire uniquement si une autre option a une valeur spécifique en utilisant `addRequiredIf`.
+
+```php
+$resolver = new OptionsResolver([
+    Option::bool('has_database')->setOptional(false),
+    Option::string('db_host')->setOptional(null),
+]);
+
+// 'db_host' devient requis uniquement si 'has_database' est true
+$resolver->addRequiredIf('db_host', 'has_database', true);
+```
+
+### Obsolescence (Deprecation)
+
+Vous pouvez marquer une option comme obsolète. Une erreur `E_USER_DEPRECATED` sera déclenchée si l'option est utilisée.
+
+```php
+Option::string('old_option')->deprecate('Utilisez "new_option" à la place.');
+```
+
+### Contraintes Supplémentaires
+
+La bibliothèque fournit des aides pour des contraintes courantes comme `min` et `max`. Elles fonctionnent pour les chaînes (longueur), les nombres (valeur) et les tableaux (nombre d'éléments).
+
+```php
+Option::string('username')->min(3)->max(20);
+Option::int('age')->min(18);
+Option::array('tags')->max(5);
+```
+
+### Validateurs Multiples
+
+Vous pouvez enchaîner plusieurs validateurs. Tous doivent être valides.
+
+```php
+Option::string('code')
+    ->validator(fn($v) => str_starts_with($v, 'A'))
+    ->validator(fn($v) => str_ends_with($v, 'Z'));
+```
+
+### Licence
+
+Ce projet est sous licence MIT. Voir le fichier [LICENSE](LICENSE) pour plus de détails.

+ 24 - 0
composer.json

@@ -0,0 +1,24 @@
+{
+    "name": "michel/options-resolver",
+    "description": "Strict, Fluent, and Type-Safe Option Validation for PHP.",
+    "type": "library",
+    "license": "MIT",
+    "authors": [
+        {
+            "name": "F. Michel"
+        }
+    ],
+    "autoload": {
+        "psr-4": {
+            "Michel\\Resolver\\": "src",
+            "Test\\Michel\\Resolver\\": "tests"
+        }
+    },
+    "require": {
+        "php": ">=7.4",
+        "ext-mbstring": "*"
+    },
+    "require-dev": {
+        "michel/unitester": "^1.0.0"
+    }
+}

+ 265 - 0
src/Option.php

@@ -0,0 +1,265 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Michel\Resolver;
+
+final class Option
+{
+    /**
+     * @var string
+     */
+    private string $name;
+
+    /**
+     * @var mixed
+     */
+    private $defaultValue;
+
+    /**
+     * @var bool
+     */
+    private bool $hasDefaultValue = false;
+
+    /**
+     * @var \Closure[]
+     */
+    private array $validators = [];
+
+    /**
+     * @var bool
+     */
+    private bool $deprecated = false;
+
+    /**
+     * @var string|null
+     */
+    private ?string $deprecationMessage = null;
+
+    /**
+     * Option constructor.
+     * @param string $name
+     */
+    public function __construct(string $name)
+    {
+        $this->name = $name;
+    }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    /**
+     * @return mixed
+     */
+    public function getDefaultValue()
+    {
+        return $this->defaultValue;
+    }
+
+    /**
+     * Check if the option has a default value.
+     *
+     * @return bool True if the option has a default value, false otherwise.
+     */
+    public function hasDefaultValue(): bool
+    {
+        return $this->hasDefaultValue;
+    }
+
+    /**
+     * Set the option as required (no default value).
+     *
+     * @return self
+     */
+    public function required(): self
+    {
+        $this->hasDefaultValue = false;
+        $this->defaultValue = null;
+        return $this;
+    }
+
+    /**
+     * Set the option as optional with an optional default value.
+     *
+     * @param mixed $defaultValue The default value to set.
+     * @return self
+     */
+    public function setOptional($defaultValue = null): self
+    {
+        $this->hasDefaultValue = true;
+        $this->defaultValue = $defaultValue;
+        return $this;
+    }
+
+    /**
+     * Add a validator function for the option.
+     *
+     * @param \Closure $closure The closure to use as a validator.
+     * @return Option
+     */
+    public function validator(\Closure $closure): self
+    {
+        $this->validators[] = $closure;
+        return $this;
+    }
+
+    /**
+     * Check if a value is valid based on all validator functions.
+     *
+     * @param mixed $value The value to validate.
+     * @return bool True if the value is valid, false otherwise.
+     */
+    public function isValid($value): bool
+    {
+        foreach ($this->validators as $validator) {
+            $result = $validator($value);
+            if (!\is_bool($result)) {
+                throw new \InvalidArgumentException(
+                    \sprintf("Validator for option '%s' must return a boolean. Received: %s", $this->name, \gettype($result))
+                );
+            }
+            if ($result === false) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * @param string|null $message
+     * @return $this
+     */
+    public function deprecate(?string $message = null): self
+    {
+        $this->deprecated = true;
+        $this->deprecationMessage = $message;
+        return $this;
+    }
+
+    public function isDeprecated(): bool
+    {
+        return $this->deprecated;
+    }
+
+    public function getDeprecationMessage(): ?string
+    {
+        return $this->deprecationMessage;
+    }
+
+    public function min(int $limit): self
+    {
+        $this->validator(static function ($value) use ($limit): bool {
+            if (is_string($value)) {
+                return mb_strlen($value) >= $limit;
+            }
+            if (is_int($value) || is_float($value)) {
+                return $value >= $limit;
+            }
+            if (is_array($value)) {
+                return count($value) >= $limit;
+            }
+            return false;
+        });
+        return $this;
+    }
+
+    public function max(int $limit): self
+    {
+        $this->validator(static function ($value) use ($limit): bool {
+            if (is_string($value)) {
+                return mb_strlen($value) <= $limit;
+            }
+            if (is_int($value) || is_float($value)) {
+                return $value <= $limit;
+            }
+            if (is_array($value)) {
+                return count($value) <= $limit;
+            }
+            return false;
+        });
+        return $this;
+    }
+
+    public static function string(string $name, ?string $default = null): self
+    {
+        $option = new self($name);
+        if ($default !== null) {
+            $option->setOptional($default);
+        }
+        $option->validator(function ($value) {
+            return is_string($value);
+        });
+        return $option;
+    }
+
+    public static function int(string $name, ?int $default = null): self
+    {
+        $option = new self($name);
+        if ($default !== null) {
+            $option->setOptional($default);
+        }
+        $option->validator(function ($value) {
+            return is_int($value);
+        });
+        return $option;
+    }
+
+    public static function float(string $name, ?float $default = null): self
+    {
+        $option = new self($name);
+        if ($default !== null) {
+            $option->setOptional($default);
+        }
+        $option->validator(function ($value) {
+            return is_float($value);
+        });
+        return $option;
+    }
+
+    public static function bool(string $name, ?bool $default = null): self
+    {
+        $option = new self($name);
+        if ($default !== null) {
+            $option->setOptional($default);
+        }
+        $option->validator(function ($value) {
+            return is_bool($value);
+        });
+        return $option;
+    }
+
+    public static function array(string $name, ?array $default = null): self
+    {
+        $option = new self($name);
+        if ($default !== null) {
+            $option->setOptional($default);
+        }
+        $option->validator(function ($value) {
+            return is_array($value);
+        });
+        return $option;
+    }
+
+    public static function iterable(string $name, ?iterable $default = null): self
+    {
+        $option = new self($name);
+        if ($default !== null) {
+            $option->setOptional($default);
+        }
+        $option->validator(function ($value) {
+            return is_iterable($value);
+        });
+        return $option;
+    }
+
+    public static function mixed(string $name, $default = null): self
+    {
+        $option = new self($name);
+        if ($default !== null) {
+            $option->setOptional($default);
+        }
+        return $option;
+    }
+}

+ 128 - 0
src/OptionsResolver.php

@@ -0,0 +1,128 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Michel\Resolver;
+
+final class OptionsResolver
+{
+    private \ArrayObject $options;
+    private array $dependencies = [];
+
+    public function __construct(array $options)
+    {
+        $this->options = new \ArrayObject();
+        foreach ($options as $option) {
+            $this->add($option);
+        }
+    }
+
+    /**
+     * @param string $dependentOption
+     * @param string $mainOption
+     * @param mixed $expectedValue
+     * @return $this
+     */
+    public function addRequiredIf(string $dependentOption, string $mainOption, $expectedValue): self
+    {
+        $this->dependencies[$dependentOption] = [
+            'main' => $mainOption,
+            'value' => $expectedValue,
+        ];
+        return $this;
+    }
+
+    /**
+     * @param array $options
+     * @return array
+     */
+    public function resolve(array $options): array
+    {
+        $this->checkDiff($options);
+
+        /**
+         * @var Option $option
+         */
+        $optionsResolved = [];
+        foreach ($this->options as $option) {
+            $optionName = $option->getName();
+            if (\array_key_exists($optionName, $options)) {
+                if ($option->isDeprecated()) {
+                    $message = $option->getDeprecationMessage() ?? \sprintf('Option "%s" is deprecated.', $optionName);
+                    @\trigger_error($message, \E_USER_DEPRECATED);
+                }
+                $value = $options[$optionName];
+                if ($option->isValid($value) === false) {
+                    throw new \InvalidArgumentException(\sprintf('The option "%s" with value %s is invalid.', $optionName, self::formatValue($value)));
+                }
+                $optionsResolved[$optionName] = $value;
+                continue;
+            }
+
+            if ($option->hasDefaultValue()) {
+                $optionsResolved[$optionName] = $option->getDefaultValue();
+                continue;
+            }
+
+            // Check for conditional requirements
+            if (isset($this->dependencies[$optionName])) {
+                $rule = $this->dependencies[$optionName];
+                // If the main option is present and has the expected value, then this option is required.
+                if (isset($options[$rule['main']]) && $options[$rule['main']] === $rule['value']) {
+                     throw new \InvalidArgumentException(\sprintf('The required option "%s" is missing.', $optionName));
+                }
+                // Otherwise, it's not required, so we can skip it.
+                continue;
+            }
+
+            throw new \InvalidArgumentException(
+                \sprintf(
+                    'The required option "%s" is missing.',
+                    $optionName
+                )
+            );
+        }
+        return $optionsResolved;
+    }
+
+    private function add(Option $option): void
+    {
+        $this->options->offsetSet($option->getName(), $option);
+    }
+
+    private function checkDiff(array $options): void
+    {
+        $defined = $this->options->getArrayCopy();
+        $diff = \array_diff_key($options, $defined);
+        if (\count($diff) > 0) {
+            throw new \InvalidArgumentException(
+                \sprintf(
+                    'The option(s) "%s" do(es) not exist. Defined options are: "%s".',
+                    \implode(', ', \array_keys($diff)),
+                    \implode('", "', \array_keys($defined))
+                )
+            );
+        }
+    }
+
+    private static function formatValue($value): string
+    {
+        if (\is_object($value)) {
+            return \get_class($value);
+        }
+
+        if (\is_string($value)) {
+            return '"' . $value . '"';
+        }
+
+        if (false === $value) {
+            return 'false';
+        }
+
+        if (true === $value) {
+            return 'true';
+        }
+
+        return \gettype($value);
+    }
+}

+ 35 - 0
tests/DefaultOptionsTest.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace Test\Michel\Resolver;
+
+use Michel\Resolver\Option;
+use Michel\Resolver\OptionsResolver;
+use Michel\UniTester\TestCase;
+
+class DefaultOptionsTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $resolver = new OptionsResolver([
+            Option::mixed('action'),
+            Option::mixed('method')->setOptional('POST'),
+            Option::mixed('id')->setOptional('form-01'),
+        ]);
+
+        $options = $resolver->resolve([
+            'action' => 'https://www.depohub.org',
+            'id' => 'form-payment'
+        ]);
+        $this->assertStrictEquals($options, ['action' => 'https://www.depohub.org', 'method' => 'POST', 'id' => 'form-payment']);
+    }
+}

+ 35 - 0
tests/DefinedOptionsTest.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace Test\Michel\Resolver;
+
+use Michel\Resolver\Option;
+use Michel\Resolver\OptionsResolver;
+use Michel\UniTester\TestCase;
+
+class DefinedOptionsTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $resolver = new OptionsResolver([
+            Option::mixed('action'),
+            Option::mixed('method'),
+        ]);
+
+        $this->expectException(\InvalidArgumentException::class, function () use ($resolver) {
+            $resolver->resolve([
+                'actions' => 'https://www.depohub.org',
+                'method' => 'GET'
+            ]);
+        });
+    }
+}

+ 69 - 0
tests/DependentOptionsTest.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace Test\Michel\Resolver;
+
+use Michel\Resolver\Option;
+use Michel\Resolver\OptionsResolver;
+use Michel\UniTester\TestCase;
+
+class DependentOptionsTest extends TestCase
+{
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testDependentOptionIsRequired();
+        $this->testDependentOptionIsNotRequired();
+        $this->testDependentOptionIsProvided();
+    }
+    public function testDependentOptionIsRequired(): void
+    {
+        $resolver = new OptionsResolver([
+            Option::string('auth_method'),
+            Option::string('password'),
+        ]);
+
+        $resolver->addRequiredIf('password', 'auth_method', 'credentials');
+
+        $this->expectException(\InvalidArgumentException::class, function () use ($resolver) {
+            $resolver->resolve(['auth_method' => 'credentials']);
+        });
+    }
+
+    public function testDependentOptionIsNotRequired(): void
+    {
+        $resolver = new OptionsResolver([
+            Option::string('auth_method'),
+            Option::string('password')->setOptional(),
+        ]);
+
+        $resolver->addRequiredIf('password', 'auth_method', 'credentials');
+
+        $options = $resolver->resolve(['auth_method' => 'token']);
+        $this->assertNull($options['password']);
+    }
+
+    public function testDependentOptionIsProvided(): void
+    {
+        $resolver = new OptionsResolver([
+            Option::string('auth_method'),
+            Option::string('password'),
+        ]);
+
+        $resolver->addRequiredIf('password', 'auth_method', 'credentials');
+
+        $options = $resolver->resolve(['auth_method' => 'credentials', 'password' => 'secret']);
+        $this->assertArrayHasKey('password', $options);
+        $this->assertStrictEquals('secret', $options['password']);
+    }
+
+}

+ 45 - 0
tests/DeprecatedOptionsTest.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace Test\Michel\Resolver;
+
+use Michel\Resolver\Option;
+use Michel\Resolver\OptionsResolver;
+use Michel\UniTester\TestCase;
+
+class DeprecatedOptionsTest extends TestCase
+{
+    public function testDeprecatedOption(): void
+    {
+        $resolver = new OptionsResolver([
+            Option::string('foo')->deprecate('The option "foo" is deprecated.'),
+        ]);
+
+        $deprecatedTriggered = false;
+        \set_error_handler(function (int $severity, string $message) use (&$deprecatedTriggered): bool {
+            if ($severity === \E_USER_DEPRECATED) {
+                $this->assertStrictEquals('The option "foo" is deprecated.', $message);
+                $deprecatedTriggered = true;
+            }
+            return true;
+        });
+
+        $resolver->resolve(['foo' => 'bar']);
+        $this->assertTrue($deprecatedTriggered, 'A E_USER_DEPRECATED error should have been triggered.');
+        \restore_error_handler();
+    }
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testDeprecatedOption();
+    }
+}

+ 90 - 0
tests/MinMaxOptionsTest.php

@@ -0,0 +1,90 @@
+<?php
+
+namespace Test\Michel\Resolver;
+
+use Michel\Resolver\Option;
+use Michel\Resolver\OptionsResolver;
+use Michel\UniTester\TestCase;
+
+class MinMaxOptionsTest extends TestCase
+{
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testStringLength();
+        $this->testIntValue();
+        $this->testArrayCount();
+    }
+    public function testStringLength(): void
+    {
+        $resolver = new OptionsResolver([
+            Option::string('username')->min(3)->max(10),
+        ]);
+
+        // Valid
+        $options = $resolver->resolve(['username' => 'johndoe']);
+        $this->assertStrictEquals('johndoe', $options['username']);
+
+        // Invalid (too short)
+        $this->expectException(\InvalidArgumentException::class, function () use ($resolver) {
+            $resolver->resolve(['username' => 'jo']);
+        });
+
+        // Invalid (too long)
+        $this->expectException(\InvalidArgumentException::class, function () use ($resolver) {
+            $resolver->resolve(['username' => 'johndoelongname']);
+        });
+    }
+
+    public function testIntValue(): void
+    {
+        $resolver = new OptionsResolver([
+            Option::int('port')->min(1024)->max(65535),
+        ]);
+
+        // Valid
+        $options = $resolver->resolve(['port' => 8080]);
+        $this->assertStrictEquals(8080, $options['port']);
+
+        // Invalid (too small)
+        $this->expectException(\InvalidArgumentException::class, function () use ($resolver) {
+            $resolver->resolve(['port' => 80]);
+        });
+
+        // Invalid (too large)
+        $this->expectException(\InvalidArgumentException::class, function () use ($resolver) {
+            $resolver->resolve(['port' => 70000]);
+        });
+    }
+
+    public function testArrayCount(): void
+    {
+        $resolver = new OptionsResolver([
+            Option::array('items')->min(1)->max(3),
+        ]);
+
+        // Valid
+        $options = $resolver->resolve(['items' => ['a', 'b']]);
+        $this->assertCount(2, $options['items']);
+
+        // Invalid (too few)
+        $this->expectException(\InvalidArgumentException::class, function () use ($resolver) {
+            $resolver->resolve(['items' => []]);
+        });
+
+        // Invalid (too many)
+        $this->expectException(\InvalidArgumentException::class, function () use ($resolver) {
+            $resolver->resolve(['items' => ['a', 'b', 'c', 'd']]);
+        });
+    }
+}

+ 64 - 0
tests/OptionsResolverTest.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace Test\Michel\Resolver;
+
+use Michel\Resolver\Option;
+use Michel\Resolver\OptionsResolver;
+use Michel\UniTester\TestCase;
+
+class OptionsResolverTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testResolveOptionsSuccessfully();
+        $this->testMissingRequiredOptions();
+        $this->testInvalidOptions();
+    }
+    public function testResolveOptionsSuccessfully()
+    {
+        $resolver = new OptionsResolver([
+            Option::mixed('option1'),
+            Option::mixed('option2')->setOptional('default'),
+        ]);
+
+        $options = $resolver->resolve([
+            'option1' => 'value1',
+        ]);
+
+        $this->assertStrictEquals($options, ['option1' => 'value1', 'option2' => 'default']);
+    }
+
+    public function testMissingRequiredOptions()
+    {
+        $resolver = new OptionsResolver([
+            Option::mixed('requiredOption'),
+        ]);
+
+        $this->expectException(\InvalidArgumentException::class, function () use ($resolver) {
+            $resolver->resolve([]);
+        });
+    }
+
+    public function testInvalidOptions()
+    {
+        $resolver = new OptionsResolver([
+            Option::mixed('validOption')->validator(static function ($value) {
+                return $value > 0;
+            }),
+        ]);
+
+        $this->expectException(\InvalidArgumentException::class, function () use ($resolver) {
+            $resolver->resolve(['validOption' => 0]);
+        });
+    }
+}

+ 32 - 0
tests/RequiredOptionsTest.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace Test\Michel\Resolver;
+
+use Michel\Resolver\Option;
+use Michel\Resolver\OptionsResolver;
+use Michel\UniTester\TestCase;
+
+class RequiredOptionsTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $resolver = new OptionsResolver([
+            Option::mixed('action'),
+            Option::mixed('method')
+        ]);
+
+        $this->expectException(\InvalidArgumentException::class, function () use ($resolver) {
+            $resolver->resolve(['method' => 'GET']);
+        });
+    }
+}

+ 58 - 0
tests/ValidateOptionsTest.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace Test\Michel\Resolver;
+
+use Michel\Resolver\Option;
+use Michel\Resolver\OptionsResolver;
+use Michel\UniTester\TestCase;
+
+class ValidateOptionsTest extends TestCase
+{
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testNotValid();
+        $this->testValid();
+    }
+
+    public function testNotValid(): void
+    {
+        $resolver = new OptionsResolver([
+            Option::mixed('action')->validator(static function ($value) {
+                return filter_var($value, FILTER_VALIDATE_URL) !== false;
+            }),
+            Option::mixed('method')->setOptional('POST'),
+        ]);
+
+        $this->expectException(\InvalidArgumentException::class, function () use ($resolver) {
+            $resolver->resolve([
+                'action' => null,
+            ]);
+        });
+    }
+
+    public function testValid(): void
+    {
+        $resolver = new OptionsResolver([
+            Option::mixed('action')->validator(static function ($value) {
+                return filter_var($value, FILTER_VALIDATE_URL) !== false;
+            }),
+            Option::mixed('method')->setOptional('POST'),
+        ]);
+
+        $options = $resolver->resolve([
+            'action' => 'https://www.depohub.org',
+        ]);
+        $this->assertStrictEquals($options, ['action' => 'https://www.depohub.org', 'method' => 'POST']);
+    }
+}