This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/vendor/
|
||||
/composer.lock
|
||||
/.php-cs-fixer.cache
|
||||
/.phpunit.result.cache
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
@@ -0,0 +1,8 @@
|
||||
parameters:
|
||||
level: 9
|
||||
paths:
|
||||
- src
|
||||
- tests
|
||||
treatPhpDocTypesAsCertain: false
|
||||
parallel:
|
||||
maximumNumberOfProcesses: 1
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user