Inital commit after splitting from main project
PHP Composer / build (push) Successful in 30s

This commit is contained in:
René Halberstadt
2026-07-26 17:56:01 +02:00
commit fa6fbd77b0
15 changed files with 618 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
name: PHP Composer
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate composer.json and composer.lock
run: composer validate --strict
- name: Cache Composer packages
id: composer-cache
uses: actions/cache@v3
with:
path: vendor
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-php-
- 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
+4
View File
@@ -0,0 +1,4 @@
/vendor/
/composer.lock
/.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);
+9
View File
@@ -0,0 +1,9 @@
FROM php:8.3-cli-alpine
RUN apk add --no-cache git unzip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
RUN mkdir -p /tmp/composer && chown 1000:1000 /tmp/composer
WORKDIR /app
+29
View File
@@ -0,0 +1,29 @@
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
test:
$(PHP) vendor/bin/phpunit --testsuite Unit --colors=always
phpstan:
$(PHP) vendor/bin/phpstan analyse --no-progress --ansi --memory-limit=512M
cs-fix:
$(PHP) vendor/bin/php-cs-fixer fix --ansi
cs-check:
$(PHP) vendor/bin/php-cs-fixer fix --dry-run --diff --ansi
audit:
$(PHP) composer audit
ci: audit phpstan cs-check test
shell:
docker compose run --rm php sh
+133
View File
@@ -0,0 +1,133 @@
# tez-utils-clock
Testable clock abstractions and a high-resolution stopwatch for PHP — `ClockInterface`, `SystemClock`, `FrozenClock` for deterministic tests, and `Stopwatch` with lap support. PHP 8.3+, no dependencies.
## Installation
```bash
composer require tez/utils-clock
```
## Components
| Class | Purpose |
|-------|---------|
| `ClockInterface` | PSR-20 compatible clock contract |
| `SystemClock` | Returns the actual current time |
| `FrozenClock` | Returns a fixed time — for deterministic tests |
| `Stopwatch` | High-resolution elapsed time and lap measurement |
---
## ClockInterface
```php
interface ClockInterface
{
public function now(): \DateTimeImmutable;
}
```
PSR-20 compatible. Inject `ClockInterface` into services instead of calling `new \DateTimeImmutable()` directly — this makes time controllable in tests without mocking.
---
## SystemClock
Returns the actual current time, honoring the system timezone.
```php
use Tez\Utils\Clock\SystemClock;
$clock = new SystemClock();
$now = $clock->now(); // \DateTimeImmutable
```
---
## FrozenClock
Returns a fixed `DateTimeImmutable` — always the same instant until `set()` is called. Use it in tests to control time without relying on the system clock.
```php
use Tez\Utils\Clock\FrozenClock;
$clock = new FrozenClock(new \DateTimeImmutable('2026-01-01 12:00:00'));
$clock->now(); // 2026-01-01 12:00:00
$clock->now(); // 2026-01-01 12:00:00 — always the same
// Advance to a new instant
$clock->set(new \DateTimeImmutable('2026-06-15 08:30:00'));
$clock->now(); // 2026-06-15 08:30:00
```
### Testing example
```php
final class OrderServiceTest extends TestCase
{
public function testOrderExpiresAfterDeadline(): void
{
$clock = new FrozenClock(new \DateTimeImmutable('2026-01-01 12:00:00'));
$service = new OrderService($clock);
self::assertFalse($service->isExpired($order));
// Advance the clock past the deadline
$clock->set(new \DateTimeImmutable('2026-01-10 00:00:00'));
self::assertTrue($service->isExpired($order));
}
}
```
---
## Stopwatch
High-resolution elapsed time measurement using `hrtime()` (nanosecond precision). Not affected by NTP adjustments or system clock changes.
```php
use Tez\Utils\Clock\Stopwatch;
$sw = Stopwatch::start();
// ... do work ...
$sw->elapsed(); // total seconds since start (float)
$sw->elapsedMs(); // total milliseconds since start (float)
```
### Lap timing
`lap()` returns the elapsed seconds since the last `lap()` call (or since start if no lap yet), then moves the lap marker forward.
```php
$sw = Stopwatch::start();
// ... first stage ...
$stage1 = $sw->lap(); // seconds for stage 1
// ... second stage ...
$stage2 = $sw->lap(); // seconds for stage 2 only
$total = $sw->elapsed(); // total since start (unaffected by laps)
```
### Reset
```php
$sw->reset(); // resets both the total timer and the lap marker to now
```
---
## Requirements
- PHP 8.3+
- No runtime dependencies
## License
MIT
+31
View File
@@ -0,0 +1,31 @@
{
"name": "tez/utils-clock",
"type": "library",
"description": "Testable clock abstractions and a high-resolution stopwatch for PHP — ClockInterface, SystemClock, FrozenClock for deterministic tests, and Stopwatch with lap support. PHP 8.3+, no dependencies.",
"license": "MIT",
"autoload": {
"psr-4": {
"Tez\\Utils\\Clock\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tez\\Utils\\Tests\\Clock\\": "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"
}
}
+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>
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Clock;
/**
* PSR-20 compatible clock interface.
*
* Implementations must return the current time as a DateTimeImmutable.
* Compatible with psr/clock if that dependency is ever added.
*/
interface ClockInterface
{
public function now(): \DateTimeImmutable;
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Clock;
/**
* Deterministic clock for testing — always returns the same fixed time.
*
* Use set() to advance the clock between assertions.
*/
final class FrozenClock implements ClockInterface
{
public function __construct(private \DateTimeImmutable $fixedTime) {}
public function now(): \DateTimeImmutable
{
return $this->fixedTime;
}
/**
* Advance (or rewind) the clock to a new fixed instant.
*/
public function set(\DateTimeImmutable $time): void
{
$this->fixedTime = $time;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Clock;
/**
* High-resolution stopwatch using hrtime() for nanosecond precision.
*/
final class Stopwatch
{
private int $startNs;
private int $lapNs;
private function __construct()
{
$this->startNs = hrtime(true);
$this->lapNs = $this->startNs;
}
/**
* Start a new stopwatch and return it.
*/
public static function start(): self
{
return new self();
}
/**
* Return the total elapsed time in seconds since the stopwatch was started.
*/
public function elapsed(): float
{
return (hrtime(true) - $this->startNs) / 1_000_000_000;
}
/**
* Return the total elapsed time in milliseconds since the stopwatch was started.
*/
public function elapsedMs(): float
{
return (hrtime(true) - $this->startNs) / 1_000_000;
}
/**
* Reset the stopwatch (and the lap marker) to now.
*/
public function reset(): void
{
$this->startNs = hrtime(true);
$this->lapNs = $this->startNs;
}
/**
* Return elapsed seconds since the last lap() call (or start if no lap yet),
* and move the lap marker forward to now.
*/
public function lap(): float
{
$now = hrtime(true);
$lapElapsed = ($now - $this->lapNs) / 1_000_000_000;
$this->lapNs = $now;
return $lapElapsed;
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Clock;
/**
* System clock that returns the actual current time.
*
* Honours the system timezone via new \DateTimeImmutable().
*/
final class SystemClock implements ClockInterface
{
public function now(): \DateTimeImmutable
{
return new \DateTimeImmutable();
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Clock;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Clock\ClockInterface;
use Tez\Utils\Clock\FrozenClock;
use Tez\Utils\Clock\Stopwatch;
use Tez\Utils\Clock\SystemClock;
final class ClockTest extends TestCase
{
// -------------------------------------------------------------------------
// SystemClock
// -------------------------------------------------------------------------
public function testSystemClockImplementsInterface(): void
{
self::assertInstanceOf(ClockInterface::class, new SystemClock());
}
public function testSystemClockReturnsDateTimeImmutable(): void
{
$clock = new SystemClock();
self::assertInstanceOf(\DateTimeImmutable::class, $clock->now());
}
public function testSystemClockReturnsCurrentTime(): void
{
$before = new \DateTimeImmutable();
$now = (new SystemClock())->now();
$after = new \DateTimeImmutable();
self::assertGreaterThanOrEqual($before->getTimestamp(), $now->getTimestamp());
self::assertLessThanOrEqual($after->getTimestamp(), $now->getTimestamp());
}
// -------------------------------------------------------------------------
// FrozenClock
// -------------------------------------------------------------------------
public function testFrozenClockImplementsInterface(): void
{
$clock = new FrozenClock(new \DateTimeImmutable('2026-01-01 12:00:00'));
self::assertInstanceOf(ClockInterface::class, $clock);
}
public function testFrozenClockAlwaysReturnsSameTime(): void
{
$fixed = new \DateTimeImmutable('2026-01-01 12:00:00');
$clock = new FrozenClock($fixed);
self::assertSame($fixed, $clock->now());
self::assertSame($fixed, $clock->now());
}
public function testFrozenClockSetAdvancesTime(): void
{
$clock = new FrozenClock(new \DateTimeImmutable('2026-01-01 12:00:00'));
$later = new \DateTimeImmutable('2026-06-15 08:30:00');
$clock->set($later);
self::assertSame($later, $clock->now());
}
public function testFrozenClockIsUsableInTypedContext(): void
{
$clock = new FrozenClock(new \DateTimeImmutable('2026-01-01 00:00:00'));
$timestamp = $this->readTimestamp($clock);
self::assertSame('2026-01-01 00:00:00', $timestamp);
}
private function readTimestamp(ClockInterface $clock): string
{
return $clock->now()->format('Y-m-d H:i:s');
}
// -------------------------------------------------------------------------
// Stopwatch
// -------------------------------------------------------------------------
public function testStopwatchStartReturnsInstance(): void
{
self::assertInstanceOf(Stopwatch::class, Stopwatch::start());
}
public function testStopwatchElapsedIsNonNegative(): void
{
$sw = Stopwatch::start();
self::assertGreaterThanOrEqual(0.0, $sw->elapsed());
}
public function testStopwatchElapsedMsIsNonNegative(): void
{
$sw = Stopwatch::start();
self::assertGreaterThanOrEqual(0.0, $sw->elapsedMs());
}
public function testStopwatchElapsedMsIsLargerThanElapsedSeconds(): void
{
$sw = Stopwatch::start();
// A non-trivial amount of time: elapsed in ms should be > in seconds
// for any time >= 1ms (both should be close to zero but ms > s for small values)
$elapsedSec = $sw->elapsed();
$elapsedMs = $sw->elapsedMs();
// For any positive time: ms value >= s value * 1 (since 1ms = 0.001s)
// This holds as long as elapsed < 1 second (which it will be in a test)
self::assertGreaterThanOrEqual($elapsedSec, $elapsedMs);
}
public function testStopwatchResetMakesElapsedNearZero(): void
{
$sw = Stopwatch::start();
// Let some time pass conceptually (we can't sleep), then reset
$sw->reset();
self::assertLessThan(0.01, $sw->elapsed()); // under 10ms
}
public function testStopwatchLapReturnsNonNegative(): void
{
$sw = Stopwatch::start();
self::assertGreaterThanOrEqual(0.0, $sw->lap());
}
public function testStopwatchSubsequentLapsAreIndependent(): void
{
$sw = Stopwatch::start();
$lap1 = $sw->lap();
$lap2 = $sw->lap();
// Both laps are non-negative and their sum should not exceed total elapsed
self::assertGreaterThanOrEqual(0.0, $lap1);
self::assertGreaterThanOrEqual(0.0, $lap2);
}
public function testStopwatchLapDoesNotResetTotalElapsed(): void
{
$sw = Stopwatch::start();
$sw->lap();
$total = $sw->elapsed();
self::assertGreaterThanOrEqual(0.0, $total);
}
}