diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ad1d763 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{neon,neon.dist}] +indent_style = tab + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..41ba0f2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +/examples/ export-ignore +/tests/ export-ignore + +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/psalm.xml.dist export-ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..7fb31b7 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,54 @@ +name: Tests + +on: + pull_request: + push: + branches: + - 'main' + +jobs: + tests: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-20.04 + + strategy: + matrix: + php: + - "7.4" + - "8.0" + - "8.1" + dependencies: + - "lowest" + - "highest" + + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + coverage: xdebug + env: + COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: "ramsey/composer-install@v1" + with: + dependency-versions: "${{ matrix.dependencies }}" + composer-options: "${{ matrix.composer-options }}" + + - name: Setup problem matchers for PHP + run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --error-format=github + + - name: Run Psalm + run: vendor/bin/psalm --output-format=github + + - name: Setup Problem Matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Run PHPUnit + run: vendor/bin/phpunit --testdox --coverage-text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe7cc68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/.build/ +/vendor/ + +/composer.lock +/phpstan.neon +/phpunit.xml +/psalm.xml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..237e66a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# CHANGELOG + +## [Unreleased] + +## 1.0.0 - 2021-12-10 +Initial Release + +[Unreleased]: https://github.com/beste/clock/compare/1.0.0...main diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..693c2ee --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Jérôme Gamez, https://github.com/beste + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..63814d0 --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# Clock + +[![Current version](https://img.shields.io/packagist/v/beste/clock.svg?logo=composer)](https://packagist.org/packages/beste/clock) +[![Packagist PHP Version Support](https://img.shields.io/packagist/php-v/beste/clock)](https://packagist.org/packages/beste/clock) +[![Tests](https://github.com/beste/clock/actions/workflows/tests.yml/badge.svg)](https://github.com/beste/clock/actions/workflows/tests.yml) + +A collection of Clock implementations. + +## Table of Contents + +- [Installation](#installation) +- [Clocks](#clocks) + - [`SystemClock`](#systemclock) - Time, as your computer (k)nows it + - [`LocalizedClock`](#localizedclock) - A clock in a(nother) time zone + - [`UTCClock`](#utcclock) - The clock that you should™ use + - [`FrozenClock`](#frozenclock) - A clock that stopped moving (perfect for tests) + - [`MinuteClock`](#minuteclock) - Who cares about seconds or even less? +- [Running Tests](#running-tests) + +## Installation + +```shell +composer require beste/clock +``` + +## Clocks + +### `SystemClock` + +A System Clock will return a time just as if you would use `new DateTimeImmutable()`. The time zone of the returned +value is determined by the clock's environment, for example by the time zone that has been configured in your +application, by a previously used `date_default_timezone_set()` or by the value of `date.timezone` in the +`php.ini`. If none of these are explicitly set, it uses the `UTC` timezone. + +```php +# examples/system_clock.php + +use Beste\Clock\SystemClock; + +$clock = SystemClock::create(); + +printf("On your system, the current date and time is %s\n", $clock->now()->format('Y-m-d H:i:s T (P)')); + +date_default_timezone_set('America/Los_Angeles'); + +printf("Now it's %s\n", $clock->now()->format('Y-m-d H:i:s T (P)')); + +date_default_timezone_set('Europe/Berlin'); + +printf("Now it's %s\n", $clock->now()->format('Y-m-d H:i:s T (P)')); +``` + +### `LocalizedClock` + +A localized clock is aware of the time zone in which it is located. While the time zone of the `SystemClock` is +determined from the environment (your PHP configuration), this clock uses the time zone that you initialize it with. + +```php +# examples/localized_clock.php + +use Beste\Clock\LocalizedClock; + +$berlin = LocalizedClock::in('Europe/Berlin'); +$denver = LocalizedClock::in(new DateTimeZone('America/Denver')); + +printf("Berlin: %s\n", $berlin->now()->format('Y-m-d H:i:s T (P)')); +printf("Denver: %s\n", $denver->now()->format('Y-m-d H:i:s T (P)')); +``` + +### `UTCClock` + +`UTC` is the abbreviation for [Coordinated Universal Time](https://en.wikipedia.org/wiki/Coordinated_Universal_Time) +and a special kind of time zone that is not affected by daylight saving time. It is commonly used for the communication +of time across different systems (e.g. between your PHP application and a database, or between a backend +and a frontend). An `UTCClock` instance behaves exactly the same as an instance of `LocalizedClock::in('UTC')`. + +```php +# examples/utc_clock.php + +use Beste\Clock\UTCClock; + +$clock = UTCClock::create(); + +$anotherTimeZone = 'Africa/Casablanca'; + +date_default_timezone_set($anotherTimeZone); + +printf("The system time zone is %s.\n", $anotherTimeZone); +printf("The clock's time zone is %s.\n", $clock->now()->getTimezone()->getName()); +``` + +### `FrozenClock` + +A frozen clock doesn't move - the time we set it with will stay the same... unless we change it. That makes the +frozen clock perfect for testing the behaviour of your time-based use cases, for example in Unit Tests. + +```php +# examples/frozen_clock.php + +use Beste\Clock\FrozenClock; +use Beste\Clock\SystemClock; + +$frozenClock = FrozenClock::withNowFrom(SystemClock::create()); + +printf("\nThe clock is frozen at %s", $frozenClock->now()->format('Y-m-d H:i:s T (P)')); +printf("\nLet's wait a second…"); +sleep(1); +printf("\nIt's one second later, but the clock is still frozen at %s", $frozenClock->now()->format('Y-m-d H:i:s T (P)')); + +$frozenClock->setTo($frozenClock->now()->modify('-5 minutes')); +printf("\nAfter turning back the clock 5 minutes, it's %s", $frozenClock->now()->format('Y-m-d H:i:s T (P)')); +``` + +### `MinuteClock` + +In some cases, microseconds, milliseconds, or even seconds are too precise for some use cases - sometimes it's just +enough if something happened in the same minute. Using the minute + +```php +# examples/minute_clock.php + +use Beste\Clock\FrozenClock; +use Beste\Clock\MinuteClock; + +$frozenClock = FrozenClock::at(new DateTimeImmutable('01:23:45')); +$clock = MinuteClock::wrapping($frozenClock); + +printf("For %s, the minute clock returns %s\n", + $frozenClock->now()->format('H:i:s'), + $clock->now()->format('H:i:s') +); + +$frozenClock->setTo($frozenClock->now()->modify('+10 seconds')); // 01:23:55 + +printf("For %s, the minute clock still returns %s\n", + $frozenClock->now()->format('H:i:s'), + $clock->now()->format('H:i:s') +); + +``` + +## Running tests + +```shell +composer test +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8f5429b --- /dev/null +++ b/composer.json @@ -0,0 +1,55 @@ +{ + "name": "beste/clock", + "type": "library", + "description": "A collection of Clock implementations", + "keywords": ["clock", "clock-interface", "psr20", "psr-20"], + "license": "MIT", + "authors": [ + { + "name": "Jérôme Gamez", + "email": "jerome@gamez.name" + } + ], + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan-strict-rules": "^1.0", + "phpunit/phpunit": "^9.5.10", + "psalm/plugin-phpunit": "^0.16.0", + "vimeo/psalm": "^4.15" + }, + "autoload": { + "psr-4": { + "Beste\\Clock\\": "src/Clock" + }, + "files": [ + "src/psr-polyfill.php", + "src/Clock.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Beste\\Clock\\Tests\\": "tests/Clock" + } + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "config": { + "sort-packages": true + }, + "scripts": { + "test": [ + "@phpstan", + "@psalm", + "@phpunit" + ], + "phpunit": "vendor/bin/phpunit", + "phpstan": "vendor/bin/phpstan analyse", + "psalm": "vendor/bin/psalm" + } +} diff --git a/examples/frozen_clock.php b/examples/frozen_clock.php new file mode 100644 index 0000000..a4851ec --- /dev/null +++ b/examples/frozen_clock.php @@ -0,0 +1,16 @@ +now()->format('Y-m-d H:i:s T (P)')); +printf("\nLet's wait a second…"); +sleep(1); +printf("\nIt's one second later, but the clock is still frozen at %s", $frozenClock->now()->format('Y-m-d H:i:s T (P)')); + +$frozenClock->setTo($frozenClock->now()->modify('-5 minutes')); +printf("\nAfter turning back the clock 5 minutes, it's %s", $frozenClock->now()->format('Y-m-d H:i:s T (P)')); diff --git a/examples/localized_clock.php b/examples/localized_clock.php new file mode 100644 index 0000000..8d5249c --- /dev/null +++ b/examples/localized_clock.php @@ -0,0 +1,11 @@ +now()->format('Y-m-d H:i:s T (P)')); +printf("Denver: %s\n", $denver->now()->format('Y-m-d H:i:s T (P)')); diff --git a/examples/minute_clock.php b/examples/minute_clock.php new file mode 100644 index 0000000..df80078 --- /dev/null +++ b/examples/minute_clock.php @@ -0,0 +1,21 @@ +now()->format('H:i:s'), + $clock->now()->format('H:i:s') +); + +$frozenClock->setTo($frozenClock->now()->modify('+10 seconds')); // 01:23:55 + +printf("For %s, the minute clock still returns %s\n", + $frozenClock->now()->format('H:i:s'), + $clock->now()->format('H:i:s') +); diff --git a/examples/system_clock.php b/examples/system_clock.php new file mode 100644 index 0000000..5b42edc --- /dev/null +++ b/examples/system_clock.php @@ -0,0 +1,17 @@ +now()->format('Y-m-d H:i:s T (P)')); + +date_default_timezone_set('America/Los_Angeles'); + +printf("Now it's %s\n", $clock->now()->format('Y-m-d H:i:s T (P)')); + +date_default_timezone_set('Europe/Berlin'); + +printf("Now it's %s\n", $clock->now()->format('Y-m-d H:i:s T (P)')); diff --git a/examples/utc_clock.php b/examples/utc_clock.php new file mode 100644 index 0000000..b445087 --- /dev/null +++ b/examples/utc_clock.php @@ -0,0 +1,14 @@ +now()->getTimezone()->getName()); diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..2712cf2 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,11 @@ +parameters: + level: max + + paths: + - src/ + - tests/ + + tmpDir: .build/phpstan/ + + bootstrapFiles: + - ./src/psr-polyfill.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..7d2165d --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,21 @@ + + + + + tests + + + + + + src/Clock + + + + diff --git a/psalm.xml.dist b/psalm.xml.dist new file mode 100644 index 0000000..29e9e91 --- /dev/null +++ b/psalm.xml.dist @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/src/Clock.php b/src/Clock.php new file mode 100644 index 0000000..5693d1d --- /dev/null +++ b/src/Clock.php @@ -0,0 +1,12 @@ +frozenAt = $frozenAt; + } + + public static function at(DateTimeImmutable $time): self + { + return new self($time); + } + + public static function withNowFrom(ClockInterface $clock): self + { + return new self($clock->now()); + } + + public static function fromUTC(): self + { + return new self(new DateTimeImmutable('now', new DateTimeZone('UTC'))); + } + + public function setTo(DateTimeImmutable $time): self + { + $this->frozenAt = $time; + + return $this; + } + + public function now(): DateTimeImmutable + { + return clone $this->frozenAt; + } +} diff --git a/src/Clock/LocalizedClock.php b/src/Clock/LocalizedClock.php new file mode 100644 index 0000000..7d086c1 --- /dev/null +++ b/src/Clock/LocalizedClock.php @@ -0,0 +1,42 @@ +timeZone = $timeZone; + } + + /** + * @param DateTimeZone|string $timeZone + */ + public static function in($timeZone): self + { + if (is_string($timeZone)) { + try { + $timeZone = new DateTimeZone($timeZone); + } catch (Throwable $e) { + throw new InvalidArgumentException($e->getMessage()); + } + } + + return new self($timeZone); + } + + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('now', $this->timeZone); + } +} diff --git a/src/Clock/MinuteClock.php b/src/Clock/MinuteClock.php new file mode 100644 index 0000000..309545f --- /dev/null +++ b/src/Clock/MinuteClock.php @@ -0,0 +1,37 @@ +clock = $clock; + } + + public static function wrapping(ClockInterface $clock): self + { + return new self($clock); + } + + public function now(): DateTimeImmutable + { + return $this->floor($this->clock->now()); + } + + private function floor(DateTimeImmutable $now): DateTimeImmutable + { + return $now->setTime( + (int) $now->format('H'), + (int) $now->format('i') + ); + } +} diff --git a/src/Clock/SystemClock.php b/src/Clock/SystemClock.php new file mode 100644 index 0000000..3cc3215 --- /dev/null +++ b/src/Clock/SystemClock.php @@ -0,0 +1,25 @@ +timeZone = new DateTimeZone('UTC'); + } + + public static function create(): self + { + return new self(); + } + + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('now', $this->timeZone); + } +} diff --git a/src/psr-polyfill.php b/src/psr-polyfill.php new file mode 100644 index 0000000..1a96e74 --- /dev/null +++ b/src/psr-polyfill.php @@ -0,0 +1,17 @@ +now()); + } + + /** + * @test + * + * @uses \Beste\Clock\FrozenClock::__construct + * @uses \Beste\Clock\FrozenClock::fromUTC() + * + * @covers ::now() + */ + public function itReturnsAnEqualNowButNotTheSame(): void + { + $clock = FrozenClock::fromUTC(); + + $first = $clock->now(); + $second = $clock->now(); + + self::assertEquals($first, $second); + self::assertNotSame($first, $second); + } + + /** + * @test + * + * @uses \Beste\Clock\FrozenClock::__construct + * @uses \Beste\Clock\FrozenClock::now + * + * @covers ::withNowFrom + */ + public function itFreezesTimeFromAnotherClock(): void + { + $now = new DateTimeImmutable('now'); + + $clock = $this->createMock(ClockInterface::class); + $clock + ->expects(self::once()) + ->method('now') + ->willReturn($now); + + $frozenClock = FrozenClock::withNowFrom($clock); + + self::assertEquals($now, $frozenClock->now()); + } + + /** + * @test + * + * @uses \Beste\Clock\FrozenClock::__construct + * @uses \Beste\Clock\FrozenClock::now + * + * @covers ::fromUTC + */ + public function itFreezesTheCurrentUTCTime(): void + { + $clock = FrozenClock::fromUTC(); + + self::assertSame('UTC', $clock->now()->getTimezone()->getName()); + self::assertEquals($clock->now(), $clock->now()); + } + + /** + * @test + * + * @uses \Beste\Clock\FrozenClock::__construct + * @uses \Beste\Clock\FrozenClock::at + * @uses \Beste\Clock\FrozenClock::now + * + * @covers ::setTo + */ + public function itCanBeSet(): void + { + $now = new DateTimeImmutable('2021-03-21 18:18:18'); + $then = new DateTimeImmutable('2021-03-21 19:19:19'); + + $clock = FrozenClock::at($now); + $clock->setTo($then); + + self::assertEquals($then, $clock->now()); + } +} diff --git a/tests/Clock/LocalizedClockTest.php b/tests/Clock/LocalizedClockTest.php new file mode 100644 index 0000000..6e394be --- /dev/null +++ b/tests/Clock/LocalizedClockTest.php @@ -0,0 +1,58 @@ +expectException(InvalidArgumentException::class); + + LocalizedClock::in('invalid'); + } + + /** + * @test + * @covers ::__construct + * @covers ::in + * @covers ::now + */ + public function itUsesTheGivenTimeZone(): void + { + $timeZone = new DateTimeZone('Asia/Bangkok'); + $clock = LocalizedClock::in($timeZone); + $now = $clock->now(); + + self::assertSame($timeZone->getName(), $now->getTimezone()->getName()); + } + + /** + * @test + * @covers ::__construct + * @covers ::in + * @covers ::now + */ + public function itAcceptsTheTimeZoneAsAString(): void + { + $timeZone = 'Pacific/Guam'; + $clock = LocalizedClock::in($timeZone); + $now = $clock->now(); + + self::assertSame($timeZone, $now->getTimezone()->getName()); + } +} diff --git a/tests/Clock/MinuteClockTest.php b/tests/Clock/MinuteClockTest.php new file mode 100644 index 0000000..729c0ac --- /dev/null +++ b/tests/Clock/MinuteClockTest.php @@ -0,0 +1,66 @@ +createMock(ClockInterface::class); + $mockClock->method('now')->willReturn(new DateTimeImmutable($input)); + + // Just to be sure that the test input is valid ^^ + self::assertSame($input, $mockClock->now()->format($format)); + + $clock = MinuteClock::wrapping($mockClock); + + self::assertSame($expected, $clock->now()->format($format)); + } + + /** + * @return array + */ + public function dateTimeStringProvider(): array + { + return [ + 'microseconds' => [ + 'Y-m-d H:i:s.u', + '2021-03-24 01:23:45.123456', + '2021-03-24 01:23:00.000000', + ], + 'milliseconds' => [ + 'Y-m-d H:i:s.v', + '2021-03-24 01:23:45.123', + '2021-03-24 01:23:00.000', + ], + 'seconds' => [ + 'Y-m-d H:i:s', + '2021-03-24 01:23:45', + '2021-03-24 01:23:00', + ], + ]; + } +} diff --git a/tests/Clock/SystemClockTest.php b/tests/Clock/SystemClockTest.php new file mode 100644 index 0000000..f8fa9d8 --- /dev/null +++ b/tests/Clock/SystemClockTest.php @@ -0,0 +1,47 @@ +defaultTimeZone = date_default_timezone_get(); + } + + protected function tearDown(): void + { + date_default_timezone_set($this->defaultTimeZone); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::create + * @covers ::now + */ + public function itUsesTheSystemTimeZone(): void + { + $timeZone = 'Europe/Berlin'; + self::assertNotSame($timeZone, $this->defaultTimeZone); + + date_default_timezone_set($timeZone); + + $clock = SystemClock::create(); + $now = $clock->now(); + + self::assertSame($timeZone, $now->getTimezone()->getName()); + } +} diff --git a/tests/Clock/UTCClockTest.php b/tests/Clock/UTCClockTest.php new file mode 100644 index 0000000..6981907 --- /dev/null +++ b/tests/Clock/UTCClockTest.php @@ -0,0 +1,44 @@ +defaultTimeZone = date_default_timezone_get(); + } + + protected function tearDown(): void + { + date_default_timezone_set($this->defaultTimeZone); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::create + * @covers ::now + */ + public function itUsesUTCRegardlessOfTheSystemTimeZone(): void + { + date_default_timezone_set('Europe/Berlin'); + + $clock = UTCClock::create(); + $now = $clock->now(); + + self::assertSame('UTC', $now->getTimezone()->getName()); + } +}