diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a4a3fb8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +/tests export-ignore +/vendor export-ignore + +/README.md export-ignore +/LICENSE export-ignore +/Makefile export-ignore +/phpunit.xml export-ignore +/phpstan.neon.dist export-ignore +/infection.json.dist export-ignore + +/.github export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f0ce8fc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 + +updates: + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 + labels: + - "php" + - "security" + - "dependencies" + groups: + php-security: + applies-to: security-updates + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "build" + labels: + - "dependencies" + - "github-actions" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml new file mode 100644 index 0000000..d0ba49e --- /dev/null +++ b/.github/workflows/auto-assign.yml @@ -0,0 +1,25 @@ +name: Auto assign issues and pull requests + +on: + issues: + types: + - opened + pull_request: + types: + - opened + +jobs: + run: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - name: Assign issues and pull requests + uses: gustavofreze/auto-assign@2.1.0 + with: + assignees: '${{ vars.ASSIGNEES }}' + github_token: '${{ secrets.GITHUB_TOKEN }}' + allow_self_assign: 'true' + allow_no_assignees: 'true' + assignment_options: 'ISSUE,PULL_REQUEST' \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6be2ffc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +name: CI + +on: + pull_request: + +permissions: + contents: read + +env: + PHP_VERSION: '8.5' + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Configure PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + tools: composer:2 + + - name: Validate composer.json + run: composer validate --no-interaction + + - name: Install dependencies + run: composer install --no-progress --optimize-autoloader --prefer-dist --no-interaction + + - name: Upload vendor and composer.lock as artifact + uses: actions/upload-artifact@v6 + with: + name: vendor-artifact + path: | + vendor + composer.lock + + auto-review: + name: Auto review + runs-on: ubuntu-latest + needs: build + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Configure PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + tools: composer:2 + + - name: Download vendor artifact from build + uses: actions/download-artifact@v7 + with: + name: vendor-artifact + path: . + + - name: Run review + run: composer review + + tests: + name: Tests + runs-on: ubuntu-latest + needs: auto-review + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Configure PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + tools: composer:2 + + - name: Download vendor artifact from build + uses: actions/download-artifact@v7 + with: + name: vendor-artifact + path: . + + - name: Run tests + run: composer tests diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..4c6d7f7 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,35 @@ +name: Security checks + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: "0 0 * * *" + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: [ "actions" ] + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42b841a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea + +vendor +report +.phpunit.* + +*.lock diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ef9a884 --- /dev/null +++ b/Makefile @@ -0,0 +1,68 @@ +PWD := $(CURDIR) +ARCH := $(shell uname -m) +PLATFORM := + +ifeq ($(ARCH),arm64) + PLATFORM := --platform=linux/amd64 +endif + +DOCKER_RUN = docker run ${PLATFORM} --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.5-alpine + +RESET := \033[0m +GREEN := \033[0;32m +YELLOW := \033[0;33m + +.DEFAULT_GOAL := help + +.PHONY: configure +configure: ## Configure development environment + @${DOCKER_RUN} composer update --optimize-autoloader + +.PHONY: test +test: ## Run all tests with coverage + @${DOCKER_RUN} composer tests + +.PHONY: test-file +test-file: ## Run tests for a specific file (usage: make test-file FILE=path/to/file) + @${DOCKER_RUN} composer test-file ${FILE} + +.PHONY: test-no-coverage +test-no-coverage: ## Run all tests without coverage + @${DOCKER_RUN} composer tests-no-coverage + +.PHONY: review +review: ## Run static code analysis + @${DOCKER_RUN} composer review + +.PHONY: show-reports +show-reports: ## Open static analysis reports (e.g., coverage, lints) in the browser + @sensible-browser report/coverage/coverage-html/index.html report/coverage/mutation-report.html + +.PHONY: clean +clean: ## Remove dependencies and generated artifacts + @sudo chown -R ${USER}:${USER} ${PWD} + @rm -rf report vendor .phpunit.cache *.lock + +.PHONY: help +help: ## Display this help message + @echo "Usage: make [target]" + @echo "" + @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')" + @grep -E '^(configure):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')" + @grep -E '^(test|test-file|test-no-coverage):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')" + @grep -E '^(review):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Reports$$(printf '$(RESET)')" + @grep -E '^(show-reports):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Cleanup$$(printf '$(RESET)')" + @grep -E '^(clean):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' diff --git a/README.md b/README.md index 119c7f2..3cf378e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,214 @@ -# time -Immutable and strict time Value Objects for safe parsing, formatting and normalization. +# Time + +[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) + +* [Overview](#overview) +* [Installation](#installation) +* [How to use](#how-to-use) + * [Instant](#instant) + * [Timezone](#timezone) + * [Timezones](#timezones) +* [License](#license) +* [Contributing](#contributing) + +
+ +## Overview + +Value Object representing time in an immutable and strict way, focused on safe parsing, formatting and normalization. +
+ +## Installation + +```bash +composer require tiny-blocks/time +``` + +
+ +## How to use + +The library provides immutable Value Objects for representing points in time and IANA timezones. All instants are +normalized to UTC internally. + +### Instant + +An `Instant` represents a single point on the timeline, always stored in UTC with microsecond precision. + +#### Creating from a string + +Parses a date-time string with an explicit UTC offset. The value is normalized to UTC regardless of the original offset. + +```php +use TinyBlocks\Time\Instant; + +$instant = Instant::fromString(value: '2026-02-17T13:30:00-03:00'); + +$instant->toIso8601(); # 2026-02-17T16:30:00+00:00 +$instant->toUnixSeconds(); # 1771345800 +$instant->toDateTimeImmutable(); # DateTimeImmutable (UTC) +``` + +#### Creating from Unix seconds + +Creates an `Instant` from a Unix timestamp in seconds. + +```php +use TinyBlocks\Time\Instant; + +$instant = Instant::fromUnixSeconds(seconds: 0); + +$instant->toIso8601(); # 1970-01-01T00:00:00+00:00 +$instant->toUnixSeconds(); # 0 +``` + +#### Creating from the current moment + +Captures the current moment with microsecond precision, normalized to UTC. + +```php +use TinyBlocks\Time\Instant; + +$instant = Instant::now(); + +$instant->toIso8601(); # 2026-02-17T10:30:00+00:00 (current UTC time) +$instant->toUnixSeconds(); # 1771324200 (current Unix timestamp) +$instant->toDateTimeImmutable(); # DateTimeImmutable (UTC, with microseconds) +``` + +#### Formatting as ISO 8601 + +The `toIso8601` method always returns the format `YYYY-MM-DDTHH:MM:SS+00:00`, without fractional seconds. + +```php +use TinyBlocks\Time\Instant; + +$instant = Instant::fromString(value: '2026-02-17T19:30:00+09:00'); + +$instant->toIso8601(); # 2026-02-17T10:30:00+00:00 +``` + +#### Accessing the underlying DateTimeImmutable + +Returns a `DateTimeImmutable` in UTC with full microsecond precision. + +```php +use TinyBlocks\Time\Instant; + +$instant = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); +$dateTime = $instant->toDateTimeImmutable(); + +$dateTime->getTimezone()->getName(); # UTC +$dateTime->format('Y-m-d\TH:i:s.u'); # 2026-02-17T10:30:00.000000 +``` + +### Timezone + +A `Timezone` is a Value Object representing a single valid [IANA timezone](https://www.iana.org) identifier. + +#### Creating from an identifier + +```php +use TinyBlocks\Time\Timezone; + +$timezone = Timezone::from(identifier: 'America/Sao_Paulo'); + +$timezone->value; # America/Sao_Paulo +$timezone->toString(); # America/Sao_Paulo +``` + +#### Creating a UTC timezone + +```php +use TinyBlocks\Time\Timezone; + +$timezone = Timezone::utc(); + +$timezone->value; # UTC +``` + +#### Converting to DateTimeZone + +```php +use TinyBlocks\Time\Timezone; + +$timezone = Timezone::from(identifier: 'Asia/Tokyo'); +$dateTimeZone = $timezone->toDateTimeZone(); + +$dateTimeZone->getName(); # Asia/Tokyo +``` + +### Timezones + +An immutable collection of `Timezone` objects. + +#### Creating from objects + +```php +use TinyBlocks\Time\Timezone; +use TinyBlocks\Time\Timezones; + +$timezones = Timezones::from( + Timezone::from(identifier: 'America/Sao_Paulo'), + Timezone::from(identifier: 'America/New_York'), + Timezone::from(identifier: 'Asia/Tokyo') +); + +$timezones->count(); # 3 +``` + +#### Creating from strings + +```php +use TinyBlocks\Time\Timezones; + +$timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo', 'Europe/London'); + +$timezones->count(); # 3 +$timezones->toStrings(); # ["UTC", "America/Sao_Paulo", "Europe/London"] +``` + +#### Getting all timezones + +Returns all `Timezone` objects in the collection: + +```php +$timezones->all(); # [Timezone("UTC"), Timezone("America/Sao_Paulo"), Timezone("Europe/London")] +``` + +#### Finding a timezone by identifier + +Searches for a specific IANA identifier within the collection. Returns `null` if not found. + +```php +use TinyBlocks\Time\Timezones; + +$timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo', 'Asia/Tokyo'); + +$timezones->findByIdentifier(iana: 'Asia/Tokyo'); # Timezone("Asia/Tokyo") +$timezones->findByIdentifier(iana: 'Europe/London'); # null +``` + +#### Checking if a timezone exists in the collection + +```php +use TinyBlocks\Time\Timezones; + +$timezones = Timezones::fromStrings('America/Sao_Paulo', 'Asia/Tokyo'); + +$timezones->contains(iana: 'Asia/Tokyo'); # true +$timezones->contains(iana: 'America/New_York'); # false +``` + +
+ +## License + +Time is licensed under [MIT](LICENSE). + +
+ +## Contributing + +Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to +contribute to the project. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b0b16c1 --- /dev/null +++ b/composer.json @@ -0,0 +1,75 @@ +{ + "name": "tiny-blocks/time", + "type": "library", + "license": "MIT", + "homepage": "https://github.com/tiny-blocks/time", + "description": "Value Object representing time in an immutable and strict way, focused on safe parsing, formatting and normalization.", + "prefer-stable": true, + "minimum-stability": "stable", + "keywords": [ + "vo", + "psr", + "utc", + "time", + "iso-8601", + "datetime", + "immutable", + "tiny-blocks", + "value-object" + ], + "authors": [ + { + "name": "Gustavo Freze de Araujo Santos", + "homepage": "https://github.com/gustavofreze" + } + ], + "support": { + "issues": "https://github.com/tiny-blocks/time/issues", + "source": "https://github.com/tiny-blocks/time" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "infection/extension-installer": true + } + }, + "autoload": { + "psr-4": { + "TinyBlocks\\Time\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Test\\TinyBlocks\\Time\\": "tests/" + } + }, + "require": { + "php": "^8.5", + "tiny-blocks/value-object": "^3.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5", + "phpstan/phpstan": "^2.1", + "infection/infection": "^0.32", + "squizlabs/php_codesniffer": "^4.0" + }, + "scripts": { + "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", + "phpcs": "php ./vendor/bin/phpcs --standard=PSR12 --extensions=php ./src", + "phpstan": "php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress", + "test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", + "mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage", + "test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests", + "review": [ + "@phpcs", + "@phpstan" + ], + "tests": [ + "@test", + "@mutation-test" + ], + "tests-no-coverage": [ + "@test-no-coverage" + ] + } +} diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..437290c --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,24 @@ +{ + "logs": { + "text": "report/infection/logs/infection-text.log", + "summary": "report/infection/logs/infection-summary.log" + }, + "tmpDir": "report/infection/", + "minMsi": 100, + "timeout": 30, + "source": { + "directories": [ + "src" + ] + }, + "phpUnit": { + "configDir": "", + "customPath": "./vendor/bin/phpunit" + }, + "mutators": { + "@default": true, + "AssignCoalesce": false + }, + "minCoveredMsi": 100, + "testFramework": "phpunit" +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..dd23e27 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,8 @@ +parameters: + paths: + - src + level: 9 + tmpDir: report/phpstan + ignoreErrors: + - '#Parameter#' + reportUnmatchedIgnoredErrors: false diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..40c80a2 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,37 @@ + + + + + + src + + + + + + tests + + + + + + + + + + + + + + + + + diff --git a/src/Instant.php b/src/Instant.php new file mode 100644 index 0000000..009d6c2 --- /dev/null +++ b/src/Instant.php @@ -0,0 +1,105 @@ +toDateTimeZone(); + $datetime = DateTimeImmutable::createFromFormat( + self::MICROSECOND_FORMAT, + sprintf('%.6F', microtime(true)), + $utc + ); + + /** @var DateTimeImmutable $datetime */ + return new Instant(datetime: $datetime->setTimezone($utc)); + } + + /** + * Creates an Instant by decoding a date-time string. + * + * @param string $value A date-time string in a supported format (e.g. 2026-02-17T10:30:00+00:00). + * @return Instant The created Instant, normalized to UTC. + * @throws InvalidInstant If the value cannot be decoded into a valid instant. + */ + public static function fromString(string $value): Instant + { + $decoder = TextDecoder::create(); + $datetime = $decoder->decode(value: $value); + + return new Instant(datetime: $datetime); + } + + /** + * Creates an Instant from a Unix timestamp in seconds. + * + * @param int $seconds The number of seconds since the Unix epoch (1970-01-01T00:00:00Z). + * @return Instant The created Instant, normalized to UTC. + */ + public static function fromUnixSeconds(int $seconds): Instant + { + $utc = Timezone::utc()->toDateTimeZone(); + $datetime = DateTimeImmutable::createFromFormat(self::UNIX_FORMAT, (string)$seconds, $utc); + + /** @var DateTimeImmutable $datetime */ + return new Instant(datetime: $datetime->setTimezone($utc)); + } + + /** + * Formats this instant as an ISO 8601 string in UTC (e.g. 2026-02-17T10:30:00+00:00). + * + * @return string The ISO 8601 representation without fractional seconds. + */ + public function toIso8601(): string + { + return $this->datetime->format(self::ISO8601_FORMAT); + } + + /** + * Returns the number of seconds since the Unix epoch. + * + * @return int The Unix timestamp in seconds. + */ + public function toUnixSeconds(): int + { + return $this->datetime->getTimestamp(); + } + + /** + * Returns the underlying DateTimeImmutable instance in UTC. + * + * @return DateTimeImmutable The UTC date-time with microsecond precision. + */ + public function toDateTimeImmutable(): DateTimeImmutable + { + return $this->datetime; + } +} diff --git a/src/Internal/Decoders/Decoder.php b/src/Internal/Decoders/Decoder.php new file mode 100644 index 0000000..f36e8f6 --- /dev/null +++ b/src/Internal/Decoders/Decoder.php @@ -0,0 +1,18 @@ +setTimezone(new DateTimeZone('UTC')); + } +} diff --git a/src/Internal/Exceptions/InvalidInstant.php b/src/Internal/Exceptions/InvalidInstant.php new file mode 100644 index 0000000..91ef7be --- /dev/null +++ b/src/Internal/Exceptions/InvalidInstant.php @@ -0,0 +1,17 @@ + could not be decoded into a valid instant.'; + + parent::__construct(message: sprintf($template, $this->value)); + } +} diff --git a/src/Internal/Exceptions/InvalidTimezone.php b/src/Internal/Exceptions/InvalidTimezone.php new file mode 100644 index 0000000..2f3bba1 --- /dev/null +++ b/src/Internal/Exceptions/InvalidTimezone.php @@ -0,0 +1,17 @@ + is invalid.'; + + parent::__construct(message: sprintf($template, $this->identifier)); + } +} diff --git a/src/Internal/TextDecoder.php b/src/Internal/TextDecoder.php new file mode 100644 index 0000000..b6ffc85 --- /dev/null +++ b/src/Internal/TextDecoder.php @@ -0,0 +1,42 @@ +decoders = $decoders; + } + + public static function create(): self + { + return new TextDecoder(decoders: [new OffsetDateTimeDecoder()]); + } + + public function decode(string $value): DateTimeImmutable + { + foreach ($this->decoders as $decoder) { + $result = $decoder->decode(value: $value); + + if ($result !== null) { + return $result; + } + } + + throw new InvalidInstant(value: $value); + } +} diff --git a/src/Timezone.php b/src/Timezone.php new file mode 100644 index 0000000..5f68a78 --- /dev/null +++ b/src/Timezone.php @@ -0,0 +1,78 @@ +value = $identifier; + } + + /** + * Creates a Timezone representing UTC. + * + * @return Timezone The UTC Timezone instance. + */ + public static function utc(): Timezone + { + return new Timezone(identifier: 'UTC'); + } + + /** + * Creates a Timezone from a valid IANA identifier. + * + * @param string $identifier The IANA timezone identifier (e.g. America/Sao_Paulo). + * @return Timezone The created Timezone instance. + * @throws InvalidTimezone If the identifier is not a valid IANA timezone. + */ + public static function from(string $identifier): Timezone + { + return new Timezone(identifier: $identifier); + } + + /** + * Returns the IANA timezone identifier as a string. + * + * @return string The IANA timezone identifier. + */ + public function toString(): string + { + return $this->value; + } + + /** + * Converts this Timezone to a DateTimeZone instance. + * + * @return DateTimeZone The corresponding DateTimeZone. + */ + public function toDateTimeZone(): DateTimeZone + { + return new DateTimeZone($this->value); + } + + /** + * @return list + */ + private static function allIdentifiers(): array + { + /** @var list|null $identifiers */ + static $identifiers = null; + + return $identifiers ??= DateTimeZone::listIdentifiers(); + } +} diff --git a/src/Timezones.php b/src/Timezones.php new file mode 100644 index 0000000..f0f0750 --- /dev/null +++ b/src/Timezones.php @@ -0,0 +1,114 @@ + */ + private array $items; + + /** + * @param list $items + */ + private function __construct(array $items) + { + $this->items = $items; + } + + /** + * Creates a collection from Timezone objects. + * + * @param Timezone ...$timezones One or more Timezone instances. + * @return Timezones The created collection. + */ + public static function from(Timezone ...$timezones): Timezones + { + return new Timezones(items: $timezones); + } + + /** + * Creates a collection from IANA identifier strings. + * + * @param string ...$identifiers One or more IANA timezone identifiers (e.g. America/Sao_Paulo). + * @return Timezones The created collection. + * @throws InvalidTimezone If any identifier is not a valid IANA timezone. + */ + public static function fromStrings(string ...$identifiers): Timezones + { + $items = array_map( + static fn(string $identifier): Timezone => Timezone::from(identifier: $identifier), + $identifiers + ); + + return new Timezones(items: $items); + } + + /** + * Returns all Timezone objects in this collection. + * + * @return list The list of all Timezone objects. + */ + public function all(): array + { + return $this->items; + } + + /** + * Returns the number of timezones in this collection. + * + * @return int The total count. + */ + public function count(): int + { + return count($this->items); + } + + /** + * Checks whether the given IANA identifier exists in this collection. + * + * @param string $iana The IANA timezone identifier to check (e.g. America/New_York). + * @return bool True if the identifier exists in this collection, false otherwise. + */ + public function contains(string $iana): bool + { + return array_any( + $this->items, + static fn(Timezone $timezone): bool => $timezone->value === $iana + ); + } + + /** + * Finds a Timezone by its IANA identifier. + * + * @param string $iana The IANA timezone identifier to search for (e.g. America/Sao_Paulo). + * @return Timezone|null The matching Timezone, or null if not found. + */ + public function findByIdentifier(string $iana): ?Timezone + { + return array_find( + $this->items, + static fn(Timezone $timezone): bool => $timezone->value === $iana + ); + } + + /** + * Returns all timezone identifiers as plain strings. + * + * @return list The list of IANA timezone identifier strings. + */ + public function toStrings(): array + { + return array_map( + static fn(Timezone $timezone): string => $timezone->toString(), + $this->items + ); + } +} diff --git a/tests/InstantTest.php b/tests/InstantTest.php new file mode 100644 index 0000000..186902e --- /dev/null +++ b/tests/InstantTest.php @@ -0,0 +1,369 @@ +toDateTimeImmutable()->getTimezone()->getName()); + } + + public function testInstantNowIsCloseToCurrentTime(): void + { + /** @Given the current Unix timestamp before creating the Instant */ + $before = time(); + + /** @When creating an Instant from now */ + $instant = Instant::now(); + + /** @And capturing the Unix timestamp after */ + $after = time(); + + /** @Then the Instant's Unix seconds should be within the before/after window */ + self::assertGreaterThanOrEqual($before, $instant->toUnixSeconds()); + self::assertLessThanOrEqual($after, $instant->toUnixSeconds()); + } + + public function testInstantNowPreservesMicrosecondPrecision(): void + { + /** @Given an Instant created from now */ + $instant = Instant::now(); + + /** @When formatting the underlying DateTimeImmutable with microseconds */ + $microseconds = (int)$instant->toDateTimeImmutable()->format('u'); + + /** @Then the microsecond component should be representable (six digits available) */ + self::assertGreaterThanOrEqual(0, $microseconds); + self::assertLessThanOrEqual(999999, $microseconds); + } + + public function testInstantNowIso8601HasNoFractionalSeconds(): void + { + /** @Given an Instant created from now */ + $instant = Instant::now(); + + /** @When formatting as ISO 8601 */ + $iso = $instant->toIso8601(); + + /** @Then the output should match YYYY-MM-DDTHH:MM:SS+00:00 without fractions */ + self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/', $iso); + } + + public function testInstantNowProducesDistinctInstances(): void + { + /** @Given two Instants created from now in sequence */ + $first = Instant::now(); + $second = Instant::now(); + + /** @Then both should be valid Instants in UTC */ + self::assertSame('UTC', $first->toDateTimeImmutable()->getTimezone()->getName()); + self::assertSame('UTC', $second->toDateTimeImmutable()->getTimezone()->getName()); + + /** @And the second should not be before the first */ + self::assertGreaterThanOrEqual( + $first->toDateTimeImmutable()->format('U.u'), + $second->toDateTimeImmutable()->format('U.u') + ); + } + + #[DataProvider('validStringsDataProvider')] + public function testInstantFromString( + string $value, + string $expectedIso8601, + int $expectedUnixSeconds + ): void { + /** @Given a valid date-time string with offset */ + /** @When creating an Instant from the string */ + $instant = Instant::fromString(value: $value); + + /** @Then the ISO 8601 representation should match the expected UTC value */ + self::assertSame($expectedIso8601, $instant->toIso8601()); + + /** @And the Unix seconds should match the expected timestamp */ + self::assertSame($expectedUnixSeconds, $instant->toUnixSeconds()); + } + + #[DataProvider('unixSecondsDataProvider')] + public function testInstantFromUnixSeconds( + int $seconds, + string $expectedIso8601 + ): void { + /** @Given a valid Unix timestamp in seconds */ + /** @When creating an Instant from Unix seconds */ + $instant = Instant::fromUnixSeconds(seconds: $seconds); + + /** @Then the ISO 8601 representation should match the expected UTC value */ + self::assertSame($expectedIso8601, $instant->toIso8601()); + + /** @And the Unix seconds should round-trip correctly */ + self::assertSame($seconds, $instant->toUnixSeconds()); + } + + public function testInstantToDateTimeImmutableReturnsUtc(): void + { + /** @Given an Instant created from a string with a non-UTC offset */ + $instant = Instant::fromString(value: '2026-02-17T15:30:00+05:00'); + + /** @When converting to DateTimeImmutable */ + $dateTime = $instant->toDateTimeImmutable(); + + /** @Then the timezone should be UTC */ + self::assertSame('UTC', $dateTime->getTimezone()->getName()); + + /** @And the date-time should reflect the UTC-converted value */ + self::assertSame('2026-02-17T10:30:00', $dateTime->format('Y-m-d\TH:i:s')); + } + + public function testInstantFromStringNormalizesToUtc(): void + { + /** @Given a date-time string with a positive offset */ + $instant = Instant::fromString(value: '2026-02-17T18:00:00+03:00'); + + /** @Then the ISO 8601 output should be normalized to UTC */ + self::assertSame('2026-02-17T15:00:00+00:00', $instant->toIso8601()); + + /** @And the DateTimeImmutable timezone should be UTC */ + self::assertSame('UTC', $instant->toDateTimeImmutable()->getTimezone()->getName()); + } + + public function testInstantFromStringWithNegativeOffset(): void + { + /** @Given a date-time string with a negative offset */ + $instant = Instant::fromString(value: '2026-02-17T07:00:00-05:00'); + + /** @Then the ISO 8601 output should be normalized to UTC */ + self::assertSame('2026-02-17T12:00:00+00:00', $instant->toIso8601()); + } + + public function testInstantFromStringWithUtcOffset(): void + { + /** @Given a date-time string already in UTC */ + $instant = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + + /** @Then the ISO 8601 output should remain unchanged */ + self::assertSame('2026-02-17T10:30:00+00:00', $instant->toIso8601()); + } + + public function testInstantFromUnixSecondsEpoch(): void + { + /** @Given Unix timestamp zero (epoch) */ + $instant = Instant::fromUnixSeconds(seconds: 0); + + /** @Then the ISO 8601 output should be the Unix epoch in UTC */ + self::assertSame('1970-01-01T00:00:00+00:00', $instant->toIso8601()); + + /** @And the Unix seconds should be zero */ + self::assertSame(0, $instant->toUnixSeconds()); + } + + public function testInstantFromUnixSecondsNegativeValue(): void + { + /** @Given a negative Unix timestamp representing a date before the epoch */ + $instant = Instant::fromUnixSeconds(seconds: -86400); + + /** @Then the ISO 8601 output should be one day before the epoch */ + self::assertSame('1969-12-31T00:00:00+00:00', $instant->toIso8601()); + + /** @And the Unix seconds should round-trip correctly */ + self::assertSame(-86400, $instant->toUnixSeconds()); + } + + public function testInstantFromUnixSecondsToDateTimeImmutableIsUtc(): void + { + /** @Given an Instant created from Unix seconds */ + $instant = Instant::fromUnixSeconds(seconds: 1771324200); + + /** @When converting to DateTimeImmutable */ + $dateTime = $instant->toDateTimeImmutable(); + + /** @Then the timezone should be UTC */ + self::assertSame('UTC', $dateTime->getTimezone()->getName()); + } + + public function testInstantFromStringAndFromUnixSecondsProduceSameResult(): void + { + /** @Given an Instant created from a string */ + $fromString = Instant::fromString(value: '2026-02-17T00:00:00+00:00'); + + /** @And an Instant created from the equivalent Unix seconds */ + $fromUnix = Instant::fromUnixSeconds(seconds: $fromString->toUnixSeconds()); + + /** @Then both should produce the same ISO 8601 output */ + self::assertSame($fromString->toIso8601(), $fromUnix->toIso8601()); + + /** @And both should produce the same Unix seconds */ + self::assertSame($fromString->toUnixSeconds(), $fromUnix->toUnixSeconds()); + } + + public function testInstantDateTimeImmutablePreservesMicroseconds(): void + { + /** @Given an Instant created from a valid string */ + $instant = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + + /** @When accessing the underlying DateTimeImmutable */ + $dateTime = $instant->toDateTimeImmutable(); + + /** @Then the format should support microsecond precision */ + $formatted = $dateTime->format('Y-m-d\TH:i:s.u'); + self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}$/', $formatted); + } + + public function testInstantIso8601OutputNeverContainsFractionalSeconds(): void + { + /** @Given an Instant created from any valid input */ + $instant = Instant::fromString(value: '2026-06-15T23:59:59+00:00'); + + /** @When formatting as ISO 8601 */ + $iso = $instant->toIso8601(); + + /** @Then the output should match YYYY-MM-DDTHH:MM:SS+00:00 without fractions */ + self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/', $iso); + } + + public function testInstantFromStringWithDayBoundaryOffset(): void + { + /** @Given a date-time string where the UTC conversion crosses a day boundary */ + $instant = Instant::fromString(value: '2026-02-18T01:00:00+03:00'); + + /** @Then the ISO 8601 output should reflect the previous day in UTC */ + self::assertSame('2026-02-17T22:00:00+00:00', $instant->toIso8601()); + } + + public function testInstantFromStringWithMaxPositiveOffset(): void + { + /** @Given a date-time string with the maximum positive UTC offset (+14:00) */ + $instant = Instant::fromString(value: '2026-02-17T14:00:00+14:00'); + + /** @Then the ISO 8601 output should be normalized to UTC */ + self::assertSame('2026-02-17T00:00:00+00:00', $instant->toIso8601()); + } + + public function testInstantFromStringWithMaxNegativeOffset(): void + { + /** @Given a date-time string with the maximum negative UTC offset (-12:00) */ + $instant = Instant::fromString(value: '2026-02-16T12:00:00-12:00'); + + /** @Then the ISO 8601 output should be normalized to UTC */ + self::assertSame('2026-02-17T00:00:00+00:00', $instant->toIso8601()); + } + + #[DataProvider('invalidStringsDataProvider')] + public function testInstantWhenInvalidString(string $value): void + { + /** @Given an invalid date-time string */ + /** @Then an InvalidInstant exception should be thrown */ + $this->expectException(InvalidInstant::class); + $this->expectExceptionMessage(sprintf('The value <%s> could not be decoded into a valid instant.', $value)); + + /** @When trying to create an Instant from the invalid string */ + Instant::fromString(value: $value); + } + + public static function validStringsDataProvider(): array + { + return [ + 'UTC offset' => [ + 'value' => '2026-02-17T10:30:00+00:00', + 'expectedIso8601' => '2026-02-17T10:30:00+00:00', + 'expectedUnixSeconds' => 1771324200 + ], + 'Midnight UTC' => [ + 'value' => '2026-01-01T00:00:00+00:00', + 'expectedIso8601' => '2026-01-01T00:00:00+00:00', + 'expectedUnixSeconds' => 1767225600 + ], + 'End of day UTC' => [ + 'value' => '2026-02-17T23:59:59+00:00', + 'expectedIso8601' => '2026-02-17T23:59:59+00:00', + 'expectedUnixSeconds' => 1771372799 + ], + 'Positive offset +05:30' => [ + 'value' => '2026-02-17T16:00:00+05:30', + 'expectedIso8601' => '2026-02-17T10:30:00+00:00', + 'expectedUnixSeconds' => 1771324200 + ], + 'Negative offset -03:00' => [ + 'value' => '2026-02-17T07:30:00-03:00', + 'expectedIso8601' => '2026-02-17T10:30:00+00:00', + 'expectedUnixSeconds' => 1771324200 + ], + 'Negative offset -05:00' => [ + 'value' => '2026-02-17T05:30:00-05:00', + 'expectedIso8601' => '2026-02-17T10:30:00+00:00', + 'expectedUnixSeconds' => 1771324200 + ], + 'Positive offset +09:00' => [ + 'value' => '2026-02-17T19:30:00+09:00', + 'expectedIso8601' => '2026-02-17T10:30:00+00:00', + 'expectedUnixSeconds' => 1771324200 + ], + 'Negative offset -09:30' => [ + 'value' => '2026-02-17T01:00:00-09:30', + 'expectedIso8601' => '2026-02-17T10:30:00+00:00', + 'expectedUnixSeconds' => 1771324200 + ] + ]; + } + + public static function unixSecondsDataProvider(): array + { + return [ + 'Epoch' => [ + 'seconds' => 0, + 'expectedIso8601' => '1970-01-01T00:00:00+00:00' + ], + 'Year 2000 midnight' => [ + 'seconds' => 946684800, + 'expectedIso8601' => '2000-01-01T00:00:00+00:00' + ], + 'One day after epoch' => [ + 'seconds' => 86400, + 'expectedIso8601' => '1970-01-02T00:00:00+00:00' + ], + 'One day before epoch' => [ + 'seconds' => -86400, + 'expectedIso8601' => '1969-12-31T00:00:00+00:00' + ], + 'Year 2026 reference' => [ + 'seconds' => 1771324200, + 'expectedIso8601' => '2026-02-17T10:30:00+00:00' + ], + 'Large future timestamp' => [ + 'seconds' => 2147483647, + 'expectedIso8601' => '2038-01-19T03:14:07+00:00' + ] + ]; + } + + public static function invalidStringsDataProvider(): array + { + return [ + 'Date only' => ['value' => '2026-02-17'], + 'Time only' => ['value' => '10:30:00'], + 'Plain text' => ['value' => 'not-a-date'], + 'Invalid day' => ['value' => '2026-02-30T10:30:00+00:00'], + 'Empty string' => ['value' => ''], + 'Invalid month' => ['value' => '2026-13-17T10:30:00+00:00'], + 'Missing offset' => ['value' => '2026-02-17T10:30:00'], + 'Truncated offset' => ['value' => '2026-02-17T10:30:00+00'], + 'Slash-separated date' => ['value' => '2026/02/17T10:30:00+00:00'], + 'Missing time separator' => ['value' => '2026-02-17 10:30:00+00:00'], + 'Z suffix instead offset' => ['value' => '2026-02-17T10:30:00Z'], + 'With fractional seconds' => ['value' => '2026-02-17T10:30:00.123456+00:00'], + 'Unix timestamp as string' => ['value' => '1771324200'] + ]; + } +} diff --git a/tests/TimezoneTest.php b/tests/TimezoneTest.php new file mode 100644 index 0000000..d873d11 --- /dev/null +++ b/tests/TimezoneTest.php @@ -0,0 +1,112 @@ +value); + } + + public function testTimezoneUtcToStringReturnsUtc(): void + { + /** @Given a UTC Timezone */ + $timezone = Timezone::utc(); + + /** @When converting to string */ + $result = $timezone->toString(); + + /** @Then the result should be UTC */ + self::assertSame('UTC', $result); + } + + public function testTimezoneUtcToDateTimeZoneReturnsUtc(): void + { + /** @Given a UTC Timezone */ + $timezone = Timezone::utc(); + + /** @When converting to DateTimeZone */ + $dateTimeZone = $timezone->toDateTimeZone(); + + /** @Then the DateTimeZone name should be UTC */ + self::assertSame('UTC', $dateTimeZone->getName()); + } + + #[DataProvider('validIdentifiersDataProvider')] + public function testTimezoneFromValidIdentifier(string $identifier): void + { + /** @Given a valid IANA timezone identifier */ + /** @When creating a Timezone from the identifier */ + $timezone = Timezone::from(identifier: $identifier); + + /** @Then the value should match the given identifier */ + self::assertSame($identifier, $timezone->value); + + /** @And toString should return the same identifier */ + self::assertSame($identifier, $timezone->toString()); + } + + #[DataProvider('validIdentifiersDataProvider')] + public function testTimezoneToDateTimeZoneMatchesIdentifier(string $identifier): void + { + /** @Given a Timezone created from a valid identifier */ + $timezone = Timezone::from(identifier: $identifier); + + /** @When converting to DateTimeZone */ + $dateTimeZone = $timezone->toDateTimeZone(); + + /** @Then the DateTimeZone name should match the original identifier */ + self::assertSame($identifier, $dateTimeZone->getName()); + } + + #[DataProvider('invalidIdentifiersDataProvider')] + public function testTimezoneWhenInvalidIdentifier(string $identifier): void + { + /** @Given an invalid timezone identifier */ + /** @Then an InvalidTimezone exception should be thrown */ + $this->expectException(InvalidTimezone::class); + $this->expectExceptionMessage(sprintf('Timezone <%s> is invalid.', $identifier)); + + /** @When trying to create a Timezone from the invalid identifier */ + Timezone::from(identifier: $identifier); + } + + public static function validIdentifiersDataProvider(): array + { + return [ + 'UTC' => ['identifier' => 'UTC'], + 'Asia/Tokyo' => ['identifier' => 'Asia/Tokyo'], + 'Asia/Kolkata' => ['identifier' => 'Asia/Kolkata'], + 'Europe/London' => ['identifier' => 'Europe/London'], + 'Pacific/Auckland' => ['identifier' => 'Pacific/Auckland'], + 'Australia/Sydney' => ['identifier' => 'Australia/Sydney'], + 'America/New_York' => ['identifier' => 'America/New_York'], + 'America/Sao_Paulo' => ['identifier' => 'America/Sao_Paulo'] + ]; + } + + public static function invalidIdentifiersDataProvider(): array + { + return [ + 'Spaces' => ['identifier' => 'America/ New_York'], + 'Partial' => ['identifier' => 'America/'], + 'Plain text' => ['identifier' => 'Invalid/Timezone'], + 'Abbreviation' => ['identifier' => 'EST'], + 'Empty string' => ['identifier' => ''], + 'Numeric offset' => ['identifier' => '+00:00'] + ]; + } +} diff --git a/tests/TimezonesTest.php b/tests/TimezonesTest.php new file mode 100644 index 0000000..b3a9356 --- /dev/null +++ b/tests/TimezonesTest.php @@ -0,0 +1,186 @@ +count()); + + /** @And the item should match the original Timezone */ + self::assertSame('America/Sao_Paulo', $timezones->all()[0]->value); + } + + public function testTimezonesFromMultipleTimezones(): void + { + /** @Given multiple Timezone objects */ + $first = Timezone::from(identifier: 'America/Sao_Paulo'); + $second = Timezone::from(identifier: 'America/New_York'); + $third = Timezone::from(identifier: 'Asia/Tokyo'); + + /** @When creating a Timezones collection */ + $timezones = Timezones::from($first, $second, $third); + + /** @Then the collection should contain all three items */ + self::assertSame(3, $timezones->count()); + + /** @And they should be in the same order */ + self::assertSame('America/Sao_Paulo', $timezones->all()[0]->value); + self::assertSame('America/New_York', $timezones->all()[1]->value); + self::assertSame('Asia/Tokyo', $timezones->all()[2]->value); + } + + public function testTimezonesFromStrings(): void + { + /** @Given valid IANA identifier strings */ + /** @When creating a Timezones collection from strings */ + $timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo', 'Europe/London'); + + /** @Then the collection should contain all three items */ + self::assertSame(3, $timezones->count()); + + /** @And the values should match the input order */ + self::assertSame(['UTC', 'America/Sao_Paulo', 'Europe/London'], $timezones->toStrings()); + } + + public function testTimezonesFromStringsWithInvalidIdentifier(): void + { + /** @Given a mix of valid and invalid identifier strings */ + /** @Then an InvalidTimezone exception should be thrown */ + $this->expectException(InvalidTimezone::class); + $this->expectExceptionMessage('Timezone is invalid.'); + + /** @When creating a Timezones collection with the invalid identifier */ + Timezones::fromStrings('UTC', 'Invalid/Zone'); + } + + public function testTimezonesContainsReturnsTrueForExistingIdentifier(): void + { + /** @Given a Timezones collection with known identifiers */ + $timezones = Timezones::fromStrings('America/Sao_Paulo', 'America/New_York'); + + /** @Then contains should return true for an existing identifier */ + self::assertTrue($timezones->contains(iana: 'America/Sao_Paulo')); + } + + public function testTimezonesContainsReturnsFalseForMissingIdentifier(): void + { + /** @Given a Timezones collection with known identifiers */ + $timezones = Timezones::fromStrings('America/Sao_Paulo', 'America/New_York'); + + /** @Then contains should return false for a non-existing identifier */ + self::assertFalse($timezones->contains(iana: 'Asia/Tokyo')); + } + + public function testTimezonesFindByIdentifierReturnsMatchingTimezone(): void + { + /** @Given a Timezones collection with multiple identifiers */ + $timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo', 'Asia/Tokyo'); + + /** @When searching for an existing identifier */ + $found = $timezones->findByIdentifier(iana: 'Asia/Tokyo'); + + /** @Then the matching Timezone should be returned */ + self::assertNotNull($found); + self::assertSame('Asia/Tokyo', $found->value); + } + + public function testTimezonesFindByIdentifierReturnsNullWhenNotFound(): void + { + /** @Given a Timezones collection without Europe/London */ + $timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo'); + + /** @When searching for a non-existing identifier */ + $found = $timezones->findByIdentifier(iana: 'Europe/London'); + + /** @Then null should be returned */ + self::assertNull($found); + } + + public function testTimezonesCountMatchesAllSize(): void + { + /** @Given a Timezones collection with four items */ + $timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo', 'Asia/Tokyo', 'Europe/London'); + + /** @Then count() should match the number of items in all() */ + self::assertCount($timezones->count(), $timezones->all()); + } + + public function testTimezonesIsCountable(): void + { + /** @Given a Timezones collection */ + $timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo'); + + /** @Then the native count() function should work */ + self::assertSame(2, count($timezones)); + } + + public function testTimezonesToStringsReturnsPlainIdentifiers(): void + { + /** @Given a Timezones collection */ + $timezones = Timezones::fromStrings('America/Sao_Paulo', 'Asia/Tokyo'); + + /** @When converting to strings */ + $strings = $timezones->toStrings(); + + /** @Then each element should match its corresponding Timezone value */ + $all = $timezones->all(); + + foreach ($strings as $index => $string) { + self::assertIsString($string); + self::assertSame($all[$index]->value, $string); + } + } + + public function testTimezonesFromEmptyReturnsEmptyCollection(): void + { + /** @Given no Timezone objects */ + /** @When creating an empty Timezones collection */ + $timezones = Timezones::from(); + + /** @Then the collection should be empty */ + self::assertSame(0, $timezones->count()); + self::assertSame([], $timezones->all()); + self::assertSame([], $timezones->toStrings()); + } + + public function testTimezonesPreservesInsertionOrder(): void + { + /** @Given identifiers in a specific order */ + $identifiers = ['Pacific/Auckland', 'Asia/Tokyo', 'UTC', 'America/New_York']; + + /** @When creating a collection from those strings */ + $timezones = Timezones::fromStrings(...$identifiers); + + /** @Then toStrings should preserve the original order */ + self::assertSame($identifiers, $timezones->toStrings()); + } + + public function testTimezonesCreatedFromSameIdentifiersAreConsistent(): void + { + /** @Given two Timezones collections created from the same identifiers */ + $first = Timezones::fromStrings('UTC', 'America/Sao_Paulo'); + $second = Timezones::fromStrings('UTC', 'America/Sao_Paulo'); + + /** @Then their string representations should be identical */ + self::assertSame($first->toStrings(), $second->toStrings()); + + /** @And their counts should match */ + self::assertSame($first->count(), $second->count()); + } +}