Skip to content

Commit 5aa6dae

Browse files
committed
Add clock extension API for writing time-sensitive classes
1 parent 4ce7f40 commit 5aa6dae

13 files changed

+614
-2
lines changed

infection.json5

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@
3030
"ArrayItem": true,
3131
"ArrayItemRemoval": true,
3232
"ArrayOneItem": true,
33-
"AssignCoalesce": true,
33+
"AssignCoalesce": {
34+
"ignore": [
35+
"Nexus\\Clock\\InternalClock::getCurrent"
36+
]
37+
},
3438
"Assignment": true,
3539
"AssignmentEqual": true,
3640
"BCMath": true,
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of the Nexus framework.
7+
*
8+
* (c) John Paul E. Balandan, CPA <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace Nexus\Clock\Extension;
15+
16+
use Nexus\Clock\Clock;
17+
use Nexus\Clock\FrozenClock;
18+
use Nexus\Clock\InternalClock;
19+
use Psr\Clock\ClockInterface;
20+
21+
/**
22+
* A trait to help write time-sensitive classes.
23+
*
24+
* @phpstan-require-implements ClockAwareInterface
25+
*/
26+
trait ClockAware
27+
{
28+
private ?ClockInterface $clock = null;
29+
30+
public function setClock(?ClockInterface $clock): self
31+
{
32+
$this->clock = $clock;
33+
34+
return $this;
35+
}
36+
37+
public function getClock(): Clock
38+
{
39+
if (null === $this->clock) {
40+
return InternalClock::getCurrent();
41+
}
42+
43+
if (! $this->clock instanceof Clock) {
44+
return new FrozenClock($this->clock->now());
45+
}
46+
47+
return $this->clock;
48+
}
49+
50+
/**
51+
* Gets the current timestamp.
52+
*/
53+
private function now(): int
54+
{
55+
return $this->getClock()->now()->getTimestamp();
56+
}
57+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of the Nexus framework.
7+
*
8+
* (c) John Paul E. Balandan, CPA <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace Nexus\Clock\Extension;
15+
16+
use Nexus\Clock\Clock;
17+
use Psr\Clock\ClockInterface;
18+
19+
/**
20+
* Interface for mutable classes that require access to a clock.
21+
*/
22+
interface ClockAwareInterface
23+
{
24+
/**
25+
* Sets the clock instance on the class.
26+
*/
27+
public function setClock(?ClockInterface $clock): self;
28+
29+
/**
30+
* Gets the clock instance.
31+
*/
32+
public function getClock(): Clock;
33+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of the Nexus framework.
7+
*
8+
* (c) John Paul E. Balandan, CPA <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace Nexus\Clock\Extension;
15+
16+
use Nexus\Clock\Clock;
17+
use Nexus\Clock\FrozenClock;
18+
use Nexus\Clock\InternalClock;
19+
use PHPUnit\Framework\Attributes\After;
20+
use PHPUnit\Framework\Attributes\Before;
21+
22+
/**
23+
* Trait to be used by test cases when mocking the clock.
24+
*/
25+
trait ClockSensitive
26+
{
27+
private static ?Clock $originalClock = null;
28+
29+
#[Before]
30+
protected static function saveClock(): void
31+
{
32+
self::$originalClock = InternalClock::getCurrent();
33+
}
34+
35+
#[After]
36+
protected static function restoreClock(): void
37+
{
38+
if (null !== self::$originalClock) {
39+
InternalClock::set(self::$originalClock);
40+
self::$originalClock = null;
41+
}
42+
}
43+
44+
/**
45+
* Mocks the current time to a fixed non-moving clock.
46+
*/
47+
private static function mockTime(\DateTimeImmutable|string $mock = 'now', bool $saveClock = true): Clock
48+
{
49+
if ($saveClock) {
50+
self::saveClock();
51+
}
52+
53+
InternalClock::set(match (true) {
54+
$mock instanceof \DateTimeImmutable => new FrozenClock($mock),
55+
default => new FrozenClock(new \DateTimeImmutable($mock)),
56+
});
57+
58+
return InternalClock::getCurrent();
59+
}
60+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of the Nexus framework.
7+
*
8+
* (c) John Paul E. Balandan, CPA <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace Nexus\Clock\Extension;
15+
16+
use Nexus\Clock\InternalClock;
17+
use Psr\Clock\ClockInterface;
18+
19+
/**
20+
* A trait to help write time-sensitive immutable classes.
21+
*
22+
* @property null|ClockInterface $clock
23+
*/
24+
trait ImmutableClockAware
25+
{
26+
/**
27+
* Gets the current timestamp.
28+
*/
29+
private function now(): int
30+
{
31+
$clock = $this->clock ?? InternalClock::getCurrent();
32+
33+
return $clock->now()->getTimestamp();
34+
}
35+
}

src/Nexus/Clock/InternalClock.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of the Nexus framework.
7+
*
8+
* (c) John Paul E. Balandan, CPA <[email protected]>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace Nexus\Clock;
15+
16+
/**
17+
* A reference to the internal clock.
18+
*
19+
* This is primarily useful for mocking the clock in tests.
20+
* Classes should not use this class directly, but instead
21+
* use the `Clock` interface or the two traits `ClockAware`
22+
* and `ImmutableClockAware`.
23+
*
24+
* @internal
25+
*/
26+
final class InternalClock implements Clock
27+
{
28+
private static ?Clock $internalClock;
29+
30+
public function __construct(
31+
private readonly ?Clock $innerClock = null,
32+
private ?\DateTimeZone $timezone = null,
33+
) {}
34+
35+
/**
36+
* Returns the current internal clock.
37+
*
38+
* If no internal clock has been set, a new instance of SystemClock
39+
* will be created with the UTC timezone.
40+
*/
41+
public static function getCurrent(): Clock
42+
{
43+
self::$internalClock ??= new SystemClock('UTC');
44+
45+
return self::$internalClock;
46+
}
47+
48+
/**
49+
* Sets the internal clock.
50+
*/
51+
public static function set(?Clock $clock): void
52+
{
53+
self::$internalClock = $clock;
54+
}
55+
56+
public function now(): \DateTimeImmutable
57+
{
58+
$now = ($this->innerClock ?? self::getCurrent())->now();
59+
60+
if (null !== $this->timezone) {
61+
$now = $now->setTimezone($this->timezone);
62+
}
63+
64+
return $now;
65+
}
66+
67+
public function sleep(float|int $seconds): void
68+
{
69+
($this->innerClock ?? self::getCurrent())->sleep($seconds);
70+
}
71+
}

tests/AutoReview/SourceCodeTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ public function testSourceClassDoesNotHaveUnnecessaryProtectedMethods(string $cl
179179
{
180180
$rc = new \ReflectionClass($class);
181181

182-
if ($rc->isAbstract() || $rc->isInterface()) {
182+
if ($rc->isAbstract() || $rc->isInterface() || $rc->isTrait()) {
183183
$this->expectNotToPerformAssertions();
184184

185185
return;

tests/AutoReview/TestCodeTest.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace Nexus\Tests\AutoReview;
1515

16+
use Nexus\Clock\Extension\ClockSensitive;
1617
use Nexus\Collection\Collection;
1718
use Nexus\Option\None;
1819
use Nexus\Option\Some;
@@ -40,6 +41,7 @@ final class TestCodeTest extends TestCase
4041

4142
private const RECOGNISED_GROUP_NAMES = [
4243
'auto-review',
44+
'clock-sensitive',
4345
'package-test',
4446
'static-analysis',
4547
'unit-test',
@@ -357,6 +359,33 @@ public function testEachTestClassIsFinalOrAbstractAndIsInternal(string $class):
357359
self::assertStringContainsString('@internal', $docComment, \sprintf('Test class "%s" should be marked as @internal.', $class));
358360
}
359361

362+
/**
363+
* @param class-string<TestCase> $class
364+
*/
365+
#[DataProvider('provideTestClassCases')]
366+
public function testClockSensitiveTestUsesClockSensitiveTraitAndGroup(string $class): void
367+
{
368+
$rc = new \ReflectionClass($class);
369+
370+
if (\in_array(ClockSensitive::class, $rc->getTraitNames(), true)) {
371+
$attributes = array_map(static function (\ReflectionAttribute $attribute): string {
372+
$groupAttribute = $attribute->newInstance();
373+
\assert($groupAttribute instanceof Group);
374+
375+
return $groupAttribute->name();
376+
}, $rc->getAttributes(Group::class));
377+
378+
self::assertContains('clock-sensitive', $attributes, \sprintf(
379+
'Time-sensitive test class "%s" should have the #[Group(\'clock-sensitive\')] attribute.',
380+
$class,
381+
));
382+
383+
return;
384+
}
385+
386+
$this->expectNotToPerformAssertions();
387+
}
388+
360389
/**
361390
* @return iterable<class-string<TestCase>, array{class-string<TestCase>}>
362391
*/

0 commit comments

Comments
 (0)