Initial commit after upgrading to php 8.3 and splitting to seperated tool chain

This commit is contained in:
René Halberstadt
2026-07-18 14:10:59 +02:00
commit df77e19b8d
24 changed files with 6422 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: PHPStan
run: composer phpstan
- name: Code style check
run: composer cs-check
- name: Tests
run: composer test
+3
View File
@@ -0,0 +1,3 @@
/vendor/
/.php-cs-fixer.cache
/.phpunit.result.cache
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use PhpCsFixer\Config;
use PhpCsFixer\Finder;
$finder = Finder::create()
->in([
__DIR__ . '/src',
__DIR__ . '/tests',
])
->name('*.php')
->notName('*.blade.php')
->ignoreDotFiles(true)
->ignoreVCS(true);
return (new Config())
->setRiskyAllowed(true)
->setRules([
'@PER-CS2.0' => true,
'@PHP83Migration' => true,
'ordered_imports' => ['sort_algorithm' => 'alpha'],
'no_unused_imports' => true,
'global_namespace_import' => ['import_classes' => false, 'import_constants' => false, 'import_functions' => false],
'single_quote' => true,
'explicit_string_variable' => true,
'array_syntax' => ['syntax' => 'short'],
'trim_array_spaces' => true,
'no_whitespace_before_comma_in_array' => true,
'whitespace_after_comma_in_array' => ['ensure_single_space' => true],
'declare_strict_types' => true,
'strict_param' => true,
'strict_comparison' => true,
'phpdoc_align' => ['align' => 'left'],
'phpdoc_order' => true,
'phpdoc_trim' => true,
'phpdoc_scalar' => true,
'no_superfluous_phpdoc_tags' => ['remove_inheritdoc' => true],
'yoda_style' => false,
'no_useless_else' => true,
'no_useless_return' => true,
'concat_space' => ['spacing' => 'one'],
'trailing_comma_in_multiline' => ['elements' => ['arrays', 'arguments', 'parameters']],
'blank_line_before_statement' => ['statements' => ['return', 'throw', 'try', 'if', 'foreach', 'for', 'while']],
])
->setFinder($finder);
+7
View File
@@ -0,0 +1,7 @@
FROM php:8.3-cli-alpine
RUN apk add --no-cache git unzip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
+32
View File
@@ -0,0 +1,32 @@
PHP = docker compose run --rm php
.PHONY: install test phpstan cs-fix cs-check audit shell build
build:
docker compose build
install:
$(PHP) composer install
vendor:
$(MAKE) install
test: vendor
$(PHP) vendor/bin/phpunit --testsuite Unit --colors=always
phpstan: vendor
$(PHP) vendor/bin/phpstan analyse --no-progress --ansi --memory-limit=512M
cs-fix: vendor
$(PHP) vendor/bin/php-cs-fixer fix --ansi
cs-check: vendor
$(PHP) vendor/bin/php-cs-fixer fix --dry-run --diff --ansi
audit: vendor
$(PHP) composer audit
ci: audit phpstan cs-check test
shell:
docker compose run --rm php sh
+32
View File
@@ -0,0 +1,32 @@
{
"name": "tez/utils-enum",
"type": "library",
"description": "Enum traits and utilities",
"license": "MIT",
"version": "1.0.0",
"autoload": {
"psr-4": {
"Tez\\Utils\\Enum\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tez\\Utils\\Tests\\Enum\\": "tests/"
}
},
"require": {
"php": ">=8.3"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"jetbrains/phpstorm-attributes": "^1.0",
"phpstan/phpstan": "^2.0",
"friendsofphp/php-cs-fixer": "^3.0"
},
"scripts": {
"cs-fix": "php-cs-fixer fix",
"cs-check": "php-cs-fixer fix --dry-run --diff",
"phpstan": "phpstan analyse",
"test": "phpunit --testsuite Unit --colors=always"
}
}
Generated
+4566
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
services:
php:
build:
context: .
dockerfile: Dockerfile
volumes:
- .:/app
- composer-cache:/tmp/composer
user: "${UID:-1000}:${GID:-1000}"
environment:
COMPOSER_HOME: /tmp/composer
volumes:
composer-cache:
+8
View File
@@ -0,0 +1,8 @@
parameters:
level: 9
paths:
- src
- tests
treatPhpDocTypesAsCertain: false
parallel:
maximumNumberOfProcesses: 1
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>
+127
View File
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Enum;
/**
* Extends EnumTrait for string- and int-backed enums.
* Overrides is() and toArray() to use backing values, and adds
* values(), hasValue(), fromName(), tryFromName(), and toValueArray().
*
* @phpstan-require-implements \BackedEnum
*/
trait BackedEnumTrait
{
use EnumTrait;
/**
* Returns true if this case matches the given value by identity or backing value.
* Passing a UnitEnum instance only matches by identity.
*/
public function is(int|string|\UnitEnum|null $value): bool
{
if ($value === null) {
return false;
}
if ($value === $this) {
return true;
}
if (!($value instanceof \UnitEnum)) {
return $this === static::tryFrom($value);
}
return false;
}
/** @return array<int|string, string> */
public static function toArray(): array
{
return array_reduce(
static::cases(),
static fn($carry, $case) => $carry + [$case->value => $case->name],
[],
);
}
/**
* Finds a case by backing value or name, optionally case-insensitive.
* Returns null when no match is found or when $value is null.
*/
public static function find(\UnitEnum|string|int|null $value, bool $ignoreCase = false): ?static
{
if ($value === null) {
return null;
}
if ($value instanceof static) {
return $value;
}
foreach (static::cases() as $case) {
if (!($value instanceof \UnitEnum)) {
$caseVal = $ignoreCase && is_string($case->value) ? strtoupper($case->value) : $case->value;
$searchVal = $ignoreCase && is_string($value) ? strtoupper($value) : $value;
if ($caseVal === $searchVal) {
return $case;
}
}
if (is_string($value)) {
$caseName = $ignoreCase ? strtoupper($case->name) : $case->name;
$searchName = $ignoreCase ? strtoupper($value) : $value;
if ($caseName === $searchName) {
return $case;
}
}
}
return null;
}
/**
* Returns all backing values of the enum.
*
* @return list<int|string>
*/
public static function values(): array
{
return array_column(static::cases(), 'value');
}
/** Returns true if the given backing value belongs to any case of this enum. */
public static function hasValue(int|string $value): bool
{
return in_array($value, static::values(), true);
}
/**
* Returns the case with the given name, or throws ValueError if not found.
* Equivalent to a name-based from() for backed enums.
*/
public static function fromName(string $name): static
{
return static::find($name)
?? throw new \ValueError("{$name} is not a valid name for " . static::class);
}
/** Returns the case with the given name, or null if not found. */
public static function tryFromName(string $name): ?static
{
return static::find($name);
}
/** @return array<string, int|string> */
public static function toValueArray(): array
{
return array_reduce(
static::cases(),
static fn($carry, $case) => $carry + [$case->name => $case->value],
[],
);
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Enum;
/**
* Bitmask helpers for int-backed enums used as flag sets.
*
* Case values must be powers of 2 (1, 2, 4, 8, …) for correct behaviour.
* This constraint is not enforced at runtime.
*
* @phpstan-require-implements \BackedEnum
*
* @method static list<static> cases()
*/
trait BitFlagTrait
{
/**
* Combines the given cases into a single bitmask by OR-ing their values.
* Returns 0 when called with no arguments.
*/
public static function combine(self ...$cases): int
{
$mask = 0;
foreach ($cases as $case) {
/** @var int $value */
$value = $case->value;
$mask |= $value;
}
return $mask;
}
/**
* Returns all cases whose bit is set in the given mask.
* Returns an empty array for a mask of 0.
*
* @return list<static>
*/
public static function fromBitmask(int $mask): array
{
$result = [];
foreach (static::cases() as $case) {
/** @var int $value */
$value = $case->value;
if ($value !== 0 && ($mask & $value) === $value) {
$result[] = $case;
}
}
return $result;
}
/** Returns true if this case's bit is set in the given mask. */
public function isSetIn(int $mask): bool
{
/** @var int $value */
$value = $this->value;
return ($mask & $value) === $value;
}
/** Returns the mask with this case's bit added (set). */
public function addTo(int $mask): int
{
/** @var int $value */
$value = $this->value;
return $mask | $value;
}
/** Returns the mask with this case's bit removed (cleared). */
public function removeFrom(int $mask): int
{
/** @var int $value */
$value = $this->value;
return $mask & ~$value;
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Enum;
/**
* Comparison helpers for int-backed enums.
*
* @phpstan-require-implements \BackedEnum
*/
trait ComparableTrait
{
/** Returns true if this case's integer value is strictly greater than the other's. */
public function isGreaterThan(self $other): bool
{
/** @var int $a */
$a = $this->value;
/** @var int $b */
$b = $other->value;
return $a > $b;
}
/** Returns true if this case's integer value is greater than or equal to the other's. */
public function isGreaterThanOrEqual(self $other): bool
{
return $this === $other || $this->isGreaterThan($other);
}
/** Returns true if this case's integer value is strictly less than the other's. */
public function isLessThan(self $other): bool
{
/** @var int $a */
$a = $this->value;
/** @var int $b */
$b = $other->value;
return $a < $b;
}
/** Returns true if this case's integer value is less than or equal to the other's. */
public function isLessThanOrEqual(self $other): bool
{
return $this === $other || $this->isLessThan($other);
}
/**
* Returns true when this case is between $min and $max (inclusive).
*/
public function between(self $min, self $max): bool
{
return $this->isGreaterThanOrEqual($min) && $this->isLessThanOrEqual($max);
}
/**
* Returns this case clamped to [$min, $max].
* If this value is below $min, $min is returned.
* If this value is above $max, $max is returned.
*/
public function clamp(self $min, self $max): static
{
if ($this->isLessThan($min)) {
return $min;
}
if ($this->isGreaterThan($max)) {
return $max;
}
return $this;
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Enum;
/**
* Add-on trait providing collection-style selection helpers for any enum.
* Can be combined with EnumTrait or BackedEnumTrait.
*
* @phpstan-require-implements \UnitEnum
*
* @method static list<static> cases()
*/
trait EnumCollectionTrait
{
/** Returns a random case. */
public static function random(): static
{
$cases = static::cases();
return $cases[array_rand($cases)];
}
/**
* Returns all cases except the given ones.
*
* @return list<static>
*/
public static function except(self ...$excluded): array
{
return array_values(
array_filter(
static::cases(),
static fn(self $case): bool => !in_array($case, $excluded, true),
),
);
}
/**
* Returns only the given cases (preserves order of the enum definition).
*
* @return list<static>
*/
public static function only(self ...$included): array
{
return array_values(
array_filter(
static::cases(),
static fn(self $case): bool => in_array($case, $included, true),
),
);
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Enum;
/**
* Base trait for pure (unit) enums.
* Provides identity checks, lookup, filtering, and ordering helpers.
* Use BackedEnumTrait instead when your enum has backing values.
*
* @phpstan-require-implements \UnitEnum
*
* @method static list<static> cases()
*/
trait EnumTrait
{
/**
* Returns true if this case matches the given value by identity or name.
* Null always returns false.
*/
public function is(int|string|\UnitEnum|null $value): bool
{
if ($value === null) {
return false;
}
if ($value === $this) {
return true;
}
if (is_string($value)) {
return $this->name === $value;
}
return false;
}
/** @return array<string, string> */
public static function toArray(): array
{
return array_reduce(
static::cases(),
static fn($carry, $case) => $carry + [$case->name => $case->name],
[],
);
}
/**
* Returns true if this case matches any value in the given list.
*
* @param int[]|string[]|\UnitEnum[]|null $values
*/
public function isOneOf(?array $values): bool
{
foreach ($values ?? [] as $value) {
if ($this->is($value)) {
return true;
}
}
return false;
}
/**
* Returns true if this case does not match any value in the given list.
*
* @param int[]|string[]|\UnitEnum[]|null $values
*/
public function isNoneOf(?array $values): bool
{
return !$this->isOneOf($values);
}
/**
* Finds a case by identity, name, or (for BackedEnums) backing value.
* Returns null when no match is found or when $value is null.
* Set $ignoreCase to true for case-insensitive name and string-value matching.
*/
public static function find(\UnitEnum|string|int|null $value, bool $ignoreCase = false): ?static
{
if ($value === null) {
return null;
}
if ($value instanceof static) {
return $value;
}
foreach (self::cases() as $case) {
if ($case instanceof \BackedEnum && !($value instanceof \UnitEnum)) {
$caseVal = $ignoreCase && is_string($case->value) ? strtoupper($case->value) : $case->value;
$searchVal = $ignoreCase && is_string($value) ? strtoupper($value) : $value;
if ($caseVal === $searchVal) {
return $case;
}
}
if (is_string($value)) {
$caseName = $ignoreCase ? strtoupper($case->name) : $case->name;
$searchName = $ignoreCase ? strtoupper($value) : $value;
if ($caseName === $searchName) {
return $case;
}
}
}
return null;
}
/**
* Returns all cases for which the callback returns true.
*
* @return list<static>
*/
public static function filter(callable $callback): array
{
return array_values(array_filter(static::cases(), $callback));
}
/** Returns the zero-based position of this case in the enum definition. */
public function ordinal(): int
{
$index = array_search($this, static::cases(), true);
assert($index !== false);
return $index;
}
/** Returns the first defined case. */
public static function first(): static
{
return static::cases()[0];
}
/** Returns the last defined case. */
public static function last(): static
{
$cases = static::cases();
return $cases[count($cases) - 1];
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Enum;
/**
* Navigation helpers for stepping to the adjacent case by definition order.
* Designed for state machines and ordered workflows.
* Compatible with both pure (UnitEnum) and backed enums.
*
* Order is determined by the enum definition order (ordinal), not by backing value.
* Returns null at the boundaries — no wrap-around.
*
* @phpstan-require-implements \UnitEnum
*
* @method static list<static> cases()
*/
trait StepTrait
{
/** Returns the next case by definition order, or null if already the last. */
public function next(): ?static
{
$cases = static::cases();
$index = array_search($this, $cases, true);
assert($index !== false);
return $cases[$index + 1] ?? null;
}
/** Returns the previous case by definition order, or null if already the first. */
public function prev(): ?static
{
$cases = static::cases();
$index = array_search($this, $cases, true);
assert($index !== false);
return $index > 0 ? $cases[$index - 1] : null;
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Enum;
/**
* Provides human-readable labels for enum cases without any framework dependency.
*
* Override {@see labels()} in the enum to provide custom label mappings.
* Falls back to the case name when no label is defined.
*
* @phpstan-require-implements \UnitEnum
*
* @method static list<static> cases()
*/
trait TranslatableTrait
{
/**
* Override this method in the enum to provide custom label mappings.
* Keys must be case names (e.g. self::Active->name).
*
* @return array<string, string>
*/
protected static function labels(): array
{
return [];
}
/**
* Returns the human-readable label for this case.
* Falls back to the case name if no label is configured.
*/
public function label(): string
{
return static::labels()[$this->name] ?? $this->name;
}
/**
* Returns all cases mapped to their labels.
*
* @return array<string, string> [caseName => label]
*/
public static function labelsArray(): array
{
$labels = [];
foreach (static::cases() as $case) {
$labels[$case->name] = $case->label();
}
return $labels;
}
}
+180
View File
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Enum;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Enum\BackedEnumTrait;
// String-backed enum
enum Color: string
{
use BackedEnumTrait;
case Red = 'red';
case Green = 'green';
case Blue = 'blue';
}
// Integer-backed enum
enum Priority: int
{
use BackedEnumTrait;
case Low = 1;
case Medium = 2;
case High = 3;
}
final class BackedEnumTraitTest extends TestCase
{
// -------------------------------------------------------------------------
// values()
// -------------------------------------------------------------------------
public function testValuesReturnsAllStringValues(): void
{
self::assertSame(['red', 'green', 'blue'], Color::values());
}
public function testValuesReturnsAllIntValues(): void
{
self::assertSame([1, 2, 3], Priority::values());
}
// -------------------------------------------------------------------------
// hasValue()
// -------------------------------------------------------------------------
public function testHasValueReturnsTrueForExistingValue(): void
{
self::assertTrue(Color::hasValue('green'));
self::assertTrue(Priority::hasValue(2));
}
public function testHasValueReturnsFalseForUnknownValue(): void
{
self::assertFalse(Color::hasValue('yellow'));
self::assertFalse(Priority::hasValue(99));
}
public function testHasValueIsStrictlyTyped(): void
{
// integer 1 must not match string '1'
self::assertFalse(Color::hasValue(1));
}
// -------------------------------------------------------------------------
// fromName()
// -------------------------------------------------------------------------
public function testFromNameReturnsCorrectCase(): void
{
self::assertSame(Color::Blue, Color::fromName('Blue'));
self::assertSame(Priority::High, Priority::fromName('High'));
}
public function testFromNameThrowsValueErrorForUnknownName(): void
{
$this->expectException(\ValueError::class);
Color::fromName('Purple');
}
// -------------------------------------------------------------------------
// tryFromName()
// -------------------------------------------------------------------------
public function testTryFromNameReturnsCorrectCase(): void
{
self::assertSame(Color::Red, Color::tryFromName('Red'));
}
public function testTryFromNameReturnsNullForUnknownName(): void
{
self::assertNull(Color::tryFromName('Magenta'));
}
// -------------------------------------------------------------------------
// toArray() — inherited via EnumTrait, but backed path tested here
// -------------------------------------------------------------------------
public function testToArrayReturnsValueToNameMapping(): void
{
self::assertSame(
['red' => 'Red', 'green' => 'Green', 'blue' => 'Blue'],
Color::toArray(),
);
}
public function testToArrayForIntBackedEnum(): void
{
self::assertSame(
[1 => 'Low', 2 => 'Medium', 3 => 'High'],
Priority::toArray(),
);
}
// -------------------------------------------------------------------------
// is() with scalar (backed enum path)
// -------------------------------------------------------------------------
public function testIsReturnsTrueForMatchingValue(): void
{
self::assertTrue(Color::Green->is('green'));
}
public function testIsReturnsFalseForOtherValue(): void
{
self::assertFalse(Color::Green->is('red'));
}
public function testIsReturnsTrueForIntValue(): void
{
self::assertTrue(Priority::Medium->is(2));
}
// -------------------------------------------------------------------------
// find() — backed enum paths (value + case-insensitive)
// -------------------------------------------------------------------------
public function testFindByValue(): void
{
self::assertSame(Color::Blue, Color::find('blue'));
}
public function testFindByValueCaseInsensitive(): void
{
self::assertSame(Color::Red, Color::find('RED', ignoreCase: true));
}
public function testFindByNameCaseInsensitive(): void
{
self::assertSame(Color::Green, Color::find('green', ignoreCase: true));
}
public function testFindReturnsNullForUnknown(): void
{
self::assertNull(Color::find('violet'));
}
// -------------------------------------------------------------------------
// toValueArray()
// -------------------------------------------------------------------------
public function testToValueArrayReturnsNameToValueMapping(): void
{
self::assertSame(
['Red' => 'red', 'Green' => 'green', 'Blue' => 'blue'],
Color::toValueArray(),
);
}
public function testToValueArrayForIntBackedEnum(): void
{
self::assertSame(
['Low' => 1, 'Medium' => 2, 'High' => 3],
Priority::toValueArray(),
);
}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Enum;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Enum\BackedEnumTrait;
use Tez\Utils\Enum\BitFlagTrait;
enum Permission: int
{
use BackedEnumTrait;
use BitFlagTrait;
case Read = 1;
case Write = 2;
case Delete = 4;
case Admin = 8;
}
final class BitFlagTraitTest extends TestCase
{
// -------------------------------------------------------------------------
// combine()
// -------------------------------------------------------------------------
public function testCombineWithNoArgumentsReturnsZero(): void
{
self::assertSame(0, Permission::combine());
}
public function testCombineWithSingleCase(): void
{
self::assertSame(1, Permission::combine(Permission::Read));
}
public function testCombineWithTwoCases(): void
{
self::assertSame(3, Permission::combine(Permission::Read, Permission::Write));
}
public function testCombineWithAllCases(): void
{
self::assertSame(15, Permission::combine(
Permission::Read,
Permission::Write,
Permission::Delete,
Permission::Admin,
));
}
public function testCombineIsDeduplicated(): void
{
self::assertSame(1, Permission::combine(Permission::Read, Permission::Read));
}
// -------------------------------------------------------------------------
// fromBitmask()
// -------------------------------------------------------------------------
public function testFromBitmaskWithZeroReturnsEmptyArray(): void
{
self::assertSame([], Permission::fromBitmask(0));
}
public function testFromBitmaskWithSingleBitReturnsSingleCase(): void
{
self::assertSame([Permission::Read], Permission::fromBitmask(1));
}
public function testFromBitmaskWithCombinedMaskReturnsMatchingCases(): void
{
$mask = Permission::combine(Permission::Read, Permission::Write);
self::assertSame([Permission::Read, Permission::Write], Permission::fromBitmask($mask));
}
public function testFromBitmaskWithAllBitsReturnsAllCases(): void
{
$mask = Permission::combine(
Permission::Read,
Permission::Write,
Permission::Delete,
Permission::Admin,
);
self::assertSame(
[Permission::Read, Permission::Write, Permission::Delete, Permission::Admin],
Permission::fromBitmask($mask),
);
}
// -------------------------------------------------------------------------
// isSetIn()
// -------------------------------------------------------------------------
public function testIsSetInReturnsTrueWhenBitIsPresent(): void
{
$mask = Permission::combine(Permission::Read, Permission::Write);
self::assertTrue(Permission::Read->isSetIn($mask));
self::assertTrue(Permission::Write->isSetIn($mask));
}
public function testIsSetInReturnsFalseWhenBitIsAbsent(): void
{
$mask = Permission::combine(Permission::Read, Permission::Write);
self::assertFalse(Permission::Delete->isSetIn($mask));
self::assertFalse(Permission::Admin->isSetIn($mask));
}
public function testIsSetInReturnsFalseForZeroMask(): void
{
self::assertFalse(Permission::Read->isSetIn(0));
}
// -------------------------------------------------------------------------
// addTo()
// -------------------------------------------------------------------------
public function testAddToSetsBit(): void
{
$mask = Permission::combine(Permission::Read);
$mask = Permission::Write->addTo($mask);
self::assertSame(3, $mask);
self::assertTrue(Permission::Write->isSetIn($mask));
}
public function testAddToIsIdempotent(): void
{
$mask = Permission::combine(Permission::Read);
$mask = Permission::Read->addTo($mask);
self::assertSame(1, $mask);
}
public function testAddToOnEmptyMask(): void
{
self::assertSame(4, Permission::Delete->addTo(0));
}
// -------------------------------------------------------------------------
// removeFrom()
// -------------------------------------------------------------------------
public function testRemoveFromClearsBit(): void
{
$mask = Permission::combine(Permission::Read, Permission::Write);
$mask = Permission::Write->removeFrom($mask);
self::assertSame(1, $mask);
self::assertFalse(Permission::Write->isSetIn($mask));
}
public function testRemoveFromIsIdempotent(): void
{
$mask = Permission::combine(Permission::Read);
$mask = Permission::Write->removeFrom($mask);
self::assertSame(1, $mask);
}
public function testRemoveFromOnZeroMaskReturnsZero(): void
{
self::assertSame(0, Permission::Read->removeFrom(0));
}
// -------------------------------------------------------------------------
// Workflow: add then remove
// -------------------------------------------------------------------------
public function testAddThenRemoveRestoresOriginalMask(): void
{
$original = Permission::combine(Permission::Read, Permission::Delete);
$modified = Permission::Write->addTo($original);
$restored = Permission::Write->removeFrom($modified);
self::assertSame($original, $restored);
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Enum;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Enum\BackedEnumTrait;
use Tez\Utils\Enum\ComparableTrait;
enum Level: int
{
use BackedEnumTrait;
use ComparableTrait;
case Low = 1;
case Medium = 2;
case High = 3;
case Ultra = 4;
}
final class ComparableTraitTest extends TestCase
{
// -------------------------------------------------------------------------
// isGreaterThan()
// -------------------------------------------------------------------------
public function testIsGreaterThanReturnsTrueWhenHigher(): void
{
self::assertTrue(Level::High->isGreaterThan(Level::Low));
}
public function testIsGreaterThanReturnsFalseWhenEqual(): void
{
self::assertFalse(Level::Medium->isGreaterThan(Level::Medium));
}
public function testIsGreaterThanReturnsFalseWhenLower(): void
{
self::assertFalse(Level::Low->isGreaterThan(Level::High));
}
// -------------------------------------------------------------------------
// isGreaterThanOrEqual()
// -------------------------------------------------------------------------
public function testIsGreaterThanOrEqualReturnsTrueWhenHigher(): void
{
self::assertTrue(Level::High->isGreaterThanOrEqual(Level::Low));
}
public function testIsGreaterThanOrEqualReturnsTrueWhenEqual(): void
{
self::assertTrue(Level::Medium->isGreaterThanOrEqual(Level::Medium));
}
public function testIsGreaterThanOrEqualReturnsFalseWhenLower(): void
{
self::assertFalse(Level::Low->isGreaterThanOrEqual(Level::High));
}
// -------------------------------------------------------------------------
// isLessThan()
// -------------------------------------------------------------------------
public function testIsLessThanReturnsTrueWhenLower(): void
{
self::assertTrue(Level::Low->isLessThan(Level::High));
}
public function testIsLessThanReturnsFalseWhenEqual(): void
{
self::assertFalse(Level::Medium->isLessThan(Level::Medium));
}
public function testIsLessThanReturnsFalseWhenHigher(): void
{
self::assertFalse(Level::High->isLessThan(Level::Low));
}
// -------------------------------------------------------------------------
// isLessThanOrEqual()
// -------------------------------------------------------------------------
public function testIsLessThanOrEqualReturnsTrueWhenLower(): void
{
self::assertTrue(Level::Low->isLessThanOrEqual(Level::High));
}
public function testIsLessThanOrEqualReturnsTrueWhenEqual(): void
{
self::assertTrue(Level::Medium->isLessThanOrEqual(Level::Medium));
}
public function testIsLessThanOrEqualReturnsFalseWhenHigher(): void
{
self::assertFalse(Level::High->isLessThanOrEqual(Level::Low));
}
// -------------------------------------------------------------------------
// between()
// -------------------------------------------------------------------------
public function testBetweenReturnsTrueWhenWithinRange(): void
{
self::assertTrue(Level::Medium->between(Level::Low, Level::High));
}
public function testBetweenReturnsTrueAtLowerBound(): void
{
self::assertTrue(Level::Low->between(Level::Low, Level::High));
}
public function testBetweenReturnsTrueAtUpperBound(): void
{
self::assertTrue(Level::High->between(Level::Low, Level::High));
}
public function testBetweenReturnsFalseWhenBelowRange(): void
{
self::assertFalse(Level::Low->between(Level::Medium, Level::Ultra));
}
public function testBetweenReturnsFalseWhenAboveRange(): void
{
self::assertFalse(Level::Ultra->between(Level::Low, Level::High));
}
// -------------------------------------------------------------------------
// clamp()
// -------------------------------------------------------------------------
public function testClampReturnsValueWhenWithinRange(): void
{
self::assertSame(Level::Medium, Level::Medium->clamp(Level::Low, Level::High));
}
public function testClampReturnsMinWhenBelowRange(): void
{
self::assertSame(Level::Medium, Level::Low->clamp(Level::Medium, Level::Ultra));
}
public function testClampReturnsMaxWhenAboveRange(): void
{
self::assertSame(Level::High, Level::Ultra->clamp(Level::Low, Level::High));
}
public function testClampReturnsMinWhenEqualToMin(): void
{
self::assertSame(Level::Low, Level::Low->clamp(Level::Low, Level::High));
}
public function testClampReturnsMaxWhenEqualToMax(): void
{
self::assertSame(Level::High, Level::High->clamp(Level::Low, Level::High));
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Enum;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Enum\EnumCollectionTrait;
enum Suit: string
{
use EnumCollectionTrait;
case Hearts = 'H';
case Diamonds = 'D';
case Clubs = 'C';
case Spades = 'S';
}
enum Compass
{
use EnumCollectionTrait;
case North;
case East;
case South;
case West;
}
final class EnumCollectionTraitTest extends TestCase
{
// -------------------------------------------------------------------------
// random()
// -------------------------------------------------------------------------
public function testRandomReturnsACaseInstance(): void
{
$case = Suit::random();
self::assertInstanceOf(Suit::class, $case);
}
public function testRandomEventuallyReturnsAllCases(): void
{
$seen = [];
for ($i = 0; $i < 200; $i++) {
$seen[Suit::random()->name] = true;
}
self::assertCount(4, $seen, 'Expected all 4 suits to appear within 200 draws');
}
public function testRandomWorksForPureEnum(): void
{
$case = Compass::random();
self::assertInstanceOf(Compass::class, $case);
}
// -------------------------------------------------------------------------
// except()
// -------------------------------------------------------------------------
public function testExceptRemovesGivenCases(): void
{
$result = Suit::except(Suit::Hearts, Suit::Spades);
self::assertSame([Suit::Diamonds, Suit::Clubs], $result);
}
public function testExceptWithNoCasesReturnsAll(): void
{
self::assertSame(Suit::cases(), Suit::except());
}
public function testExceptWithAllCasesReturnsEmpty(): void
{
$result = Suit::except(...Suit::cases());
self::assertSame([], $result);
}
public function testExceptPreservesEnumOrder(): void
{
$result = Suit::except(Suit::Diamonds);
self::assertSame([Suit::Hearts, Suit::Clubs, Suit::Spades], $result);
}
public function testExceptWorksForPureEnum(): void
{
$result = Compass::except(Compass::North, Compass::South);
self::assertSame([Compass::East, Compass::West], $result);
}
// -------------------------------------------------------------------------
// only()
// -------------------------------------------------------------------------
public function testOnlyReturnsRequestedCases(): void
{
$result = Suit::only(Suit::Hearts, Suit::Spades);
self::assertSame([Suit::Hearts, Suit::Spades], $result);
}
public function testOnlyPreservesEnumDefinitionOrder(): void
{
// Pass in reverse order — result must follow enum definition order
$result = Suit::only(Suit::Spades, Suit::Hearts);
self::assertSame([Suit::Hearts, Suit::Spades], $result);
}
public function testOnlyWithNoCasesReturnsEmpty(): void
{
self::assertSame([], Suit::only());
}
public function testOnlyWithAllCasesReturnsAll(): void
{
$result = Suit::only(...Suit::cases());
self::assertSame(Suit::cases(), $result);
}
public function testOnlyWorksForPureEnum(): void
{
$result = Compass::only(Compass::East, Compass::West);
self::assertSame([Compass::East, Compass::West], $result);
}
}
+211
View File
@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Enum;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Enum\BackedEnumTrait;
use Tez\Utils\Enum\EnumTrait;
// Pure (unit) enum used across all EnumTrait tests
enum Season
{
use EnumTrait;
case Spring;
case Summer;
case Autumn;
case Winter;
}
// Backed enum to verify backed paths of EnumTrait (toArray, is, find)
enum Direction: string
{
use BackedEnumTrait;
case North = 'north';
case South = 'south';
case East = 'east';
case West = 'west';
}
final class EnumTraitTest extends TestCase
{
// -------------------------------------------------------------------------
// is()
// -------------------------------------------------------------------------
public function testIsReturnsTrueForSameInstance(): void
{
self::assertTrue(Season::Summer->is(Season::Summer));
}
public function testIsReturnsFalseForNull(): void
{
self::assertFalse(Season::Summer->is(null));
}
public function testIsReturnsFalseForDifferentCase(): void
{
self::assertFalse(Season::Summer->is(Season::Winter));
}
public function testIsReturnsTrueForMatchingStringName(): void
{
self::assertTrue(Season::Autumn->is('Autumn'));
}
public function testIsReturnsFalseForNonMatchingStringName(): void
{
self::assertFalse(Season::Autumn->is('Spring'));
}
public function testIsReturnsTrueForBackedEnumByValue(): void
{
self::assertTrue(Direction::North->is('north'));
}
public function testIsReturnsFalseForBackedEnumWithWrongValue(): void
{
self::assertFalse(Direction::North->is('south'));
}
// -------------------------------------------------------------------------
// isOneOf() / isNoneOf()
// -------------------------------------------------------------------------
public function testIsOneOfReturnsTrueWhenMatchFound(): void
{
self::assertTrue(Season::Winter->isOneOf([Season::Autumn, Season::Winter]));
}
public function testIsOneOfReturnsFalseWhenNoMatch(): void
{
self::assertFalse(Season::Spring->isOneOf([Season::Autumn, Season::Winter]));
}
public function testIsOneOfReturnsFalseForNull(): void
{
self::assertFalse(Season::Spring->isOneOf(null));
}
public function testIsNoneOfIsInverseOfIsOneOf(): void
{
self::assertTrue(Season::Spring->isNoneOf([Season::Autumn, Season::Winter]));
self::assertFalse(Season::Winter->isNoneOf([Season::Autumn, Season::Winter]));
}
// -------------------------------------------------------------------------
// toArray()
// -------------------------------------------------------------------------
public function testToArrayForPureEnumReturnsNameToNameMapping(): void
{
$expected = [
'Spring' => 'Spring',
'Summer' => 'Summer',
'Autumn' => 'Autumn',
'Winter' => 'Winter',
];
self::assertSame($expected, Season::toArray());
}
public function testToArrayForBackedEnumReturnsValueToNameMapping(): void
{
$expected = [
'north' => 'North',
'south' => 'South',
'east' => 'East',
'west' => 'West',
];
self::assertSame($expected, Direction::toArray());
}
// -------------------------------------------------------------------------
// find()
// -------------------------------------------------------------------------
public function testFindReturnsNullForNull(): void
{
self::assertNull(Season::find(null));
}
public function testFindReturnsEnumInstanceWhenPassedItself(): void
{
self::assertSame(Season::Spring, Season::find(Season::Spring));
}
public function testFindByNameForPureEnum(): void
{
self::assertSame(Season::Summer, Season::find('Summer'));
}
public function testFindReturnsNullForUnknownName(): void
{
self::assertNull(Season::find('Unknown'));
}
public function testFindIgnoreCasePureEnum(): void
{
self::assertSame(Season::Winter, Season::find('winter', ignoreCase: true));
}
public function testFindByValueForBackedEnum(): void
{
self::assertSame(Direction::East, Direction::find('east'));
}
public function testFindIgnoreCaseBackedEnum(): void
{
self::assertSame(Direction::West, Direction::find('WEST', ignoreCase: true));
}
// -------------------------------------------------------------------------
// filter()
// -------------------------------------------------------------------------
public function testFilterReturnsCasesMatchingCallback(): void
{
$result = Season::filter(fn(Season $c) => in_array($c, [Season::Spring, Season::Summer], true));
self::assertCount(2, $result);
self::assertContains(Season::Spring, $result);
self::assertContains(Season::Summer, $result);
}
public function testFilterReturnsEmptyArrayWhenNothingMatches(): void
{
$result = Season::filter(fn() => false);
self::assertSame([], $result);
}
// -------------------------------------------------------------------------
// ordinal()
// -------------------------------------------------------------------------
public function testOrdinalReturnsZeroBasedPosition(): void
{
self::assertSame(0, Season::Spring->ordinal());
self::assertSame(1, Season::Summer->ordinal());
self::assertSame(2, Season::Autumn->ordinal());
self::assertSame(3, Season::Winter->ordinal());
}
// -------------------------------------------------------------------------
// first() / last()
// -------------------------------------------------------------------------
public function testFirstReturnsFirstCase(): void
{
self::assertSame(Season::Spring, Season::first());
}
public function testLastReturnsLastCase(): void
{
self::assertSame(Season::Winter, Season::last());
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Enum;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Enum\EnumTrait;
use Tez\Utils\Enum\StepTrait;
enum OrderStatus
{
use EnumTrait;
use StepTrait;
case Draft;
case Pending;
case Shipped;
case Delivered;
case Cancelled;
}
final class StepTraitTest extends TestCase
{
// -------------------------------------------------------------------------
// next()
// -------------------------------------------------------------------------
public function testNextReturnsFollowingCase(): void
{
self::assertSame(OrderStatus::Shipped, OrderStatus::Pending->next());
}
public function testNextFromFirstReturnsSecond(): void
{
self::assertSame(OrderStatus::Pending, OrderStatus::Draft->next());
}
public function testNextFromLastReturnsNull(): void
{
self::assertNull(OrderStatus::Cancelled->next());
}
public function testNextFromSecondToLastReturnsLast(): void
{
self::assertSame(OrderStatus::Cancelled, OrderStatus::Delivered->next());
}
// -------------------------------------------------------------------------
// prev()
// -------------------------------------------------------------------------
public function testPrevReturnsPrecedingCase(): void
{
self::assertSame(OrderStatus::Draft, OrderStatus::Pending->prev());
}
public function testPrevFromLastReturnsPenultimate(): void
{
self::assertSame(OrderStatus::Delivered, OrderStatus::Cancelled->prev());
}
public function testPrevFromFirstReturnsNull(): void
{
self::assertNull(OrderStatus::Draft->prev());
}
public function testPrevFromSecondReturnsFirst(): void
{
self::assertSame(OrderStatus::Draft, OrderStatus::Pending->prev());
}
// -------------------------------------------------------------------------
// Chaining
// -------------------------------------------------------------------------
public function testNextThenPrevReturnsSameCase(): void
{
$case = OrderStatus::Pending;
self::assertSame($case, $case->next()?->prev());
}
public function testPrevThenNextReturnsSameCase(): void
{
$case = OrderStatus::Shipped;
self::assertSame($case, $case->prev()?->next());
}
// -------------------------------------------------------------------------
// Single-case enum edge case
// -------------------------------------------------------------------------
public function testNextAndPrevReturnNullForOnlyCase(): void
{
self::assertNull(Alone::Only->next());
self::assertNull(Alone::Only->prev());
}
}
enum Alone
{
use StepTrait;
case Only;
}
+122
View File
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Enum;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Enum\BackedEnumTrait;
use Tez\Utils\Enum\TranslatableTrait;
enum StatusWithLabels: string
{
use BackedEnumTrait;
use TranslatableTrait;
case Active = 'active';
case Pending = 'pending';
case Archived = 'archived';
/** @return array<string, string> */
protected static function labels(): array
{
return [
self::Active->name => 'Aktiv',
self::Pending->name => 'Ausstehend',
// Archived intentionally has no label → fallback to name
];
}
}
enum PureWithLabels
{
use TranslatableTrait;
case Open;
case Closed;
/** @return array<string, string> */
protected static function labels(): array
{
return [
self::Open->name => 'Offen',
self::Closed->name => 'Geschlossen',
];
}
}
enum NoLabels: int
{
use TranslatableTrait;
case One = 1;
case Two = 2;
}
final class TranslatableTraitTest extends TestCase
{
// -------------------------------------------------------------------------
// label()
// -------------------------------------------------------------------------
public function testLabelReturnsConfiguredString(): void
{
self::assertSame('Aktiv', StatusWithLabels::Active->label());
self::assertSame('Ausstehend', StatusWithLabels::Pending->label());
}
public function testLabelFallsBackToCaseNameWhenNotConfigured(): void
{
self::assertSame('Archived', StatusWithLabels::Archived->label());
}
public function testLabelWorksForPureEnum(): void
{
self::assertSame('Offen', PureWithLabels::Open->label());
self::assertSame('Geschlossen', PureWithLabels::Closed->label());
}
public function testLabelFallsBackWhenNoLabelsMethodOverridden(): void
{
self::assertSame('One', NoLabels::One->label());
self::assertSame('Two', NoLabels::Two->label());
}
// -------------------------------------------------------------------------
// labelsArray()
// -------------------------------------------------------------------------
public function testLabelsArrayReturnsAllCasesMappedToLabels(): void
{
self::assertSame(
[
'Active' => 'Aktiv',
'Pending' => 'Ausstehend',
'Archived' => 'Archived',
],
StatusWithLabels::labelsArray(),
);
}
public function testLabelsArrayWorksForPureEnum(): void
{
self::assertSame(
[
'Open' => 'Offen',
'Closed' => 'Geschlossen',
],
PureWithLabels::labelsArray(),
);
}
public function testLabelsArrayFallsBackToNamesWhenEmpty(): void
{
self::assertSame(
[
'One' => 'One',
'Two' => 'Two',
],
NoLabels::labelsArray(),
);
}
}