From c790d1f2da92071afe70d7411d602773f1ef1401 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 23 May 2026 14:11:03 -0300 Subject: [PATCH 1/6] chore: Update member ordering rules and PHPStan level. --- .claude/rules/php-library-code-style.md | 82 +++++++++++-------------- .claude/rules/php-library-testing.md | 5 +- phpstan.neon.dist | 4 ++ 3 files changed, 43 insertions(+), 48 deletions(-) diff --git a/.claude/rules/php-library-code-style.md b/.claude/rules/php-library-code-style.md index 8485df7..997b294 100644 --- a/.claude/rules/php-library-code-style.md +++ b/.claude/rules/php-library-code-style.md @@ -44,27 +44,45 @@ Verify every item before producing any PHP code. If any item fails, revise befor 5. Classes follow the rules in "Inheritance and constructors". `final readonly` is the default, with documented exceptions for extension points and for parents that are not `readonly`. 6. Members are ordered constants first, then constructor, then static methods, then instance - methods. Within each group, order by body size ascending (number of lines between `{` and `}`). - Constants and enum cases, which have no body, are ordered by name length ascending. This - ordering may be overridden only when the alternative carries explicit documentation value: - grouping by domain class with section markers (HTTP status codes by 1xx/2xx/3xx/etc), - mirroring the order of an implemented interface, or similar evident structure. The override - must be obvious at first reading. + methods. Within each group, order by **member name length ascending** (count the name only, + without parentheses, arguments, or return type). Constants, enum cases, and methods share + the same name-length-ascending rule, applied within their respective groups. This mirrors + the rule that governs constructor parameters and named arguments (rule 7). When two names + have equal length, order them alphabetically. This ordering may be overridden only when the + alternative carries explicit documentation value: grouping by domain class with section + markers (HTTP status codes by 1xx/2xx/3xx/etc), mirroring the order of an implemented + interface, or similar evident structure. The override must be obvious at first reading. **At call sites** (chained method calls in production code, tests, or documentation - examples), consecutive method invocations on the same receiver are ordered by the **visible - width** of each call expression ascending. The body is not visible at the call site, so the - visible width is the practical proxy for body size. Boolean toggles such as `->secure()` and - `->httpOnly()` come before parameterized `with*` builders for the same reason. When two - calls have equal width, order them alphabetically by method name. + examples), consecutive method invocations on the same receiver are ordered by **method name + length ascending**, the same rule that governs member declarations. Boolean toggles such as + `->secure()` and `->httpOnly()` come before parameterized `with*` builders because their + names are shorter, not because the expression is narrower. When two method names have equal + length, order them alphabetically. **Terminal methods that change the receiver type** stay at the end of the chain regardless - of width. A `build()` that returns the built value, a `commit()` that finalizes a unit of - work, a `send()` that flushes a request, are terminal: the chain ends with them. The + of name length. A `build()` that returns the built value, a `commit()` that finalizes a unit + of work, a `send()` that flushes a request, are terminal: the chain ends with them. The ordering rule applies only to consecutive calls on the same receiver type; calls that transition to a different type are not reorderable. The same applies in reverse to the factory or accessor that starts the chain (`Cookie::create(...)`, `$repository`) — it stays at its position. + + **PHPUnit test classes** follow a dedicated sub-grouping inside the instance-methods group + that overrides the name-length-ascending rule: + + 1. **Lifecycle hooks** first, in PHPUnit execution order: + `setUpBeforeClass` → `setUp` → `tearDown` → `tearDownAfterClass`. Only those actually + defined appear; never introduce an empty hook to satisfy the rule. + 2. **Test methods** (prefix `test`) next, ordered by name length ascending (alphabetical + tiebreak). + 3. **Data providers** last, ordered by name length ascending (alphabetical tiebreak). + + A method is a data provider if and only if its name appears as the string argument of a + `#[DataProvider('')]` attribute or a `@dataProvider ` docblock annotation on a + test method in the same class. The naming convention (`*DataProvider`) is informational + only; the reference is the authoritative signal. A method named `*DataProvider` that no + test references is dead code under rule 17, not a data provider. 7. Constructor parameters are ordered by parameter name length ascending (count the name only, without `$` or type), except when parameters have an implicit semantic order (for example, `$start/$end`, `$from/$to`, `$startAt/$endAt`), which takes precedence. Parameters with default @@ -225,10 +243,7 @@ are `canceled` (not `cancelled`), `organization` (not `organisation`), `initiali ### When required -- Every method of an interface, **including interfaces declared inside `src/Internal/`**. - Interfaces define contracts. The contract is documentation by definition, regardless of - namespace. The `Internal/` boundary applies to implementations, not to the contracts that - internal collaborators expose to each other. +- Every method of an interface. - Every public method of a concrete class outside `src/Internal/`. Public classes are at the public API boundary by definition. Consumers call every public method directly, and the PHPDoc is the contract for each call. Trivial getters and `with*` methods are not exempt. @@ -244,10 +259,7 @@ are `canceled` (not `cancelled`), `organization` (not `organisation`), `initiali interface. The interface carries the docblock. - Anything inside `src/Internal/`. Internal types are implementation detail and must not carry PHPDoc. The namespace itself is the boundary. See `php-library-architecture.md` for the - architectural meaning of `Internal/`. **Exception**: interfaces and their methods. An - interface declared inside `src/Internal/` still defines a contract, and the contract is - documented per `### When required` regardless of namespace. The prohibition covers concrete - classes, traits, enums, and anonymous classes inside `Internal/`, never interfaces. + architectural meaning of `Internal/`. - Anywhere inside `tests/`. Test methods name the scenario via the `testXxxWhenYyyGivenThenZzz` naming convention, and the `@Given`/`@When`/`@Then`/`@And` annotation blocks defined in `php-library-testing.md` describe the steps. PHPDoc documentation (summary plus @@ -270,10 +282,7 @@ The PHPDoc prohibitions above take priority over the typed-array case. When PHPS - On a **constructor parameter** → suppress via `ignoreErrors` in `phpstan.neon.dist`. Do not add PHPDoc. -- On anything inside **`src/Internal/`** (concrete classes, traits, enums) → suppress via - `ignoreErrors`. Do not add PHPDoc. Interfaces inside `src/Internal/` are the exception: - they carry PHPDoc per `### When required`, and the PHPStan errors they raise are resolved - through the PHPDoc, never through `ignoreErrors`. +- On anything inside **`src/Internal/`** → suppress via `ignoreErrors`. Do not add PHPDoc. - On anything inside **`tests/`** → suppress via `ignoreErrors`. Do not add PHPDoc. - On a **public method of a public (non-Internal) class** → add full PHPDoc with summary, `@param` descriptions, and the typed-array information. The bare-tag form remains @@ -338,8 +347,7 @@ public function __construct(public array $entries) } ``` -**Prohibited.** PHPDoc on a **concrete class** inside `src/Internal/` (the prohibition does -not extend to interfaces; see "Correct" below for an Internal/ interface): +**Prohibited.** PHPDoc on anything inside `src/Internal/`: ```php namespace TinyBlocks\Http\Internal\Client; @@ -353,26 +361,6 @@ final readonly class Url } ``` -**Correct.** Interface declared **inside `src/Internal/`** still carries PHPDoc on every -method. The Internal/ prohibition covers concrete classes; interfaces are exempt because they -are the contract: - -```php -namespace TinyBlocks\Http\Internal\Client; - -interface RequestResolver -{ - /** - * Resolves the given URL against the configured base URL. - * - * @param string $url The path or absolute URL to resolve. - * @return string The absolute URL to dispatch. - * @throws MalformedPath If the URL violates RFC 3986. - */ - public function resolve(string $url): string; -} -``` - **Correct.** Generic array type with summary and `@param` description: ```php diff --git a/.claude/rules/php-library-testing.md b/.claude/rules/php-library-testing.md index 86a0c10..81fdfb8 100644 --- a/.claude/rules/php-library-testing.md +++ b/.claude/rules/php-library-testing.md @@ -36,7 +36,8 @@ Verify every item before producing any test code. If any item fails, revise befo 4. No intermediate variables used only once. Chain method calls when the intermediate state is not referenced elsewhere (e.g., `Money::of(...)->add(...)` instead of `$money = Money::of(...)` followed by `$money->add(...)`). -5. No private or helper methods in test classes. The only non-test methods allowed are data +5. No private or helper methods in test classes. The only non-test methods allowed are PHPUnit + lifecycle hooks (`setUp`, `setUpBeforeClass`, `tearDown`, `tearDownAfterClass`) and data providers. Setup logic complex enough to extract belongs in a dedicated fixture class. 6. Test only the public API. Never assert on private state or `Internal/` classes directly. 7. Test the behavior that **raises** an exception, never the exception itself. Exception classes @@ -69,6 +70,8 @@ Verify every item before producing any test code. If any item fails, revise befo 15. Never use `@codeCoverageIgnore`, attributes, or configuration that exclude code from coverage. Never suppress mutants via `infection.json.dist` or any other mechanism. See "Coverage and mutation discipline". +16. Member ordering in test classes follows `php-library-code-style.md` rule 6 (PHPUnit + test-class sub-grouping). ## Structure: Given/When/Then (BDD) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 5d84ec0..fabf435 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,6 +4,10 @@ parameters: - src - tests ignoreErrors: + # DateTimeImmutable::createFromFormat returns DateTimeImmutable|false; the UNIX format 'U' always succeeds for a valid integer, so the false branch is unreachable. + - identifier: method.nonObject + path: src/Instant.php + # Property and constructor parameter are intentionally untyped; PHPDoc on constructors is prohibited per code-style rule. Suppress missing iterable value-type plus cascading return.type and argument.type errors that propagate from the untyped property. - identifier: missingType.iterableValue path: src/Timezones.php From 75a27d000b75b0dde3e6ecaaef6b8466c88af29c Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 23 May 2026 14:11:08 -0300 Subject: [PATCH 2/6] refactor: Standardize PHPDoc wording for predicate and conversion methods. --- src/TimeOfDay.php | 24 ++++++++++++------------ src/Timezone.php | 4 ++-- src/Timezones.php | 6 +++--- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/TimeOfDay.php b/src/TimeOfDay.php index b71d1f2..deced75 100644 --- a/src/TimeOfDay.php +++ b/src/TimeOfDay.php @@ -65,9 +65,9 @@ public static function fromString(string $value): TimeOfDay } /** - * Derives the time of day from an Instant (in UTC). + * Creates a TimeOfDay from an Instant. * - * @param Instant $instant The point in time to extract the time from. + * @param Instant $instant The point in time to extract the time from (in UTC). * @return TimeOfDay The corresponding time of day. */ public static function fromInstant(Instant $instant): TimeOfDay @@ -111,7 +111,7 @@ public function toMinutesSinceMidnight(): int } /** - * Returns the Duration from midnight to this time of day. + * Returns the TimeOfDay as a Duration since midnight. * * @return Duration The duration since midnight. */ @@ -121,7 +121,7 @@ public function toDuration(): Duration } /** - * Returns true if this time is strictly before another. + * Tells whether this time is strictly before another. * * @param TimeOfDay $other The time to compare against. * @return bool True if this time precedes the other. @@ -132,7 +132,7 @@ public function isBefore(TimeOfDay $other): bool } /** - * Returns true if this time is strictly after another. + * Tells whether this time is strictly after another. * * @param TimeOfDay $other The time to compare against. * @return bool True if this time follows the other. @@ -143,7 +143,7 @@ public function isAfter(TimeOfDay $other): bool } /** - * Returns true if this time is before or equal to another. + * Tells whether this time is before or equal to another. * * @param TimeOfDay $other The time to compare against. * @return bool True if this time is at or before the other. @@ -154,7 +154,7 @@ public function isBeforeOrEqual(TimeOfDay $other): bool } /** - * Returns true if this time is after or equal to another. + * Tells whether this time is after or equal to another. * * @param TimeOfDay $other The time to compare against. * @return bool True if this time is at or after the other. @@ -174,19 +174,19 @@ public function isAfterOrEqual(TimeOfDay $other): bool */ public function durationUntil(TimeOfDay $other): Duration { - $diff = $other->toMinutesSinceMidnight() - $this->toMinutesSinceMidnight(); + $difference = $other->toMinutesSinceMidnight() - $this->toMinutesSinceMidnight(); - if ($diff <= 0) { + if ($difference <= 0) { throw InvalidTimeOfDay::becauseEndIsNotAfterStart(from: $this, to: $other); } - return Duration::fromMinutes(minutes: $diff); + return Duration::fromMinutes(minutes: $difference); } /** - * Formats this time as "HH:MM". + * Returns the TimeOfDay as a string. * - * @return string The formatted time string. + * @return string The time formatted as "HH:MM". */ public function toString(): string { diff --git a/src/Timezone.php b/src/Timezone.php index dea8eb9..ff70214 100644 --- a/src/Timezone.php +++ b/src/Timezone.php @@ -46,7 +46,7 @@ public static function from(string $identifier): Timezone } /** - * Returns the IANA timezone identifier as a string. + * Returns the Timezone as a string. * * @return string The IANA timezone identifier. */ @@ -56,7 +56,7 @@ public function toString(): string } /** - * Converts this Timezone to a DateTimeZone instance. + * Returns the Timezone as a DateTimeZone. * * @return DateTimeZone The corresponding DateTimeZone. */ diff --git a/src/Timezones.php b/src/Timezones.php index cd244da..a1681f5 100644 --- a/src/Timezones.php +++ b/src/Timezones.php @@ -20,7 +20,7 @@ private function __construct(array $items) } /** - * Creates a collection from Timezone objects. + * Creates a Timezones from Timezone objects. * * @param Timezone ...$timezones One or more Timezone instances. * @return Timezones The created collection. @@ -31,7 +31,7 @@ public static function from(Timezone ...$timezones): Timezones } /** - * Creates a collection from IANA identifier strings. + * Creates a Timezones from IANA identifier strings. * * @param string ...$identifiers One or more IANA timezone identifiers (e.g. America/Sao_Paulo). * @return Timezones The created collection. @@ -107,7 +107,7 @@ public function findByIdentifierOrUtc(string $iana): Timezone } /** - * Returns all timezone identifiers as plain strings. + * Returns the Timezones as strings. * * @return list The list of IANA timezone identifier strings. */ From 4c7b3ed5f9e45015508284f4e1e8ffebb5c73a15 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 23 May 2026 14:11:15 -0300 Subject: [PATCH 3/6] test: Rewrite existing test suite to full BDD Given/When/Then format. --- tests/Unit/DayOfWeekTest.php | 171 ++++++++++++++++++++++++++--------- tests/Unit/DurationTest.php | 101 ++++++++++++++------- tests/Unit/PeriodTest.php | 23 +++-- tests/Unit/TimeOfDayTest.php | 166 +++++++++++++++++++++++++--------- tests/Unit/TimezonesTest.php | 70 +++++++++----- 5 files changed, 384 insertions(+), 147 deletions(-) diff --git a/tests/Unit/DayOfWeekTest.php b/tests/Unit/DayOfWeekTest.php index e8702d9..ab2b2ec 100644 --- a/tests/Unit/DayOfWeekTest.php +++ b/tests/Unit/DayOfWeekTest.php @@ -13,42 +13,74 @@ final class DayOfWeekTest extends TestCase { public function testDayOfWeekMondayIsWeekday(): void { - /** @Then Monday should be a weekday */ - self::assertTrue(DayOfWeek::Monday->isWeekday()); - self::assertFalse(DayOfWeek::Monday->isWeekend()); + /** @Given Monday as the day of the week */ + $day = DayOfWeek::Monday; + + /** @When checking if it is a weekday */ + $isWeekday = $day->isWeekday(); + + /** @Then it should be a weekday */ + self::assertTrue($isWeekday); + + /** @And it should not be a weekend day */ + self::assertFalse($day->isWeekend()); } public function testDayOfWeekFridayIsWeekday(): void { - /** @Then Friday should be a weekday */ - self::assertTrue(DayOfWeek::Friday->isWeekday()); - self::assertFalse(DayOfWeek::Friday->isWeekend()); + /** @Given Friday as the day of the week */ + $day = DayOfWeek::Friday; + + /** @When checking if it is a weekday */ + $isWeekday = $day->isWeekday(); + + /** @Then it should be a weekday */ + self::assertTrue($isWeekday); + + /** @And it should not be a weekend day */ + self::assertFalse($day->isWeekend()); } public function testDayOfWeekSaturdayIsWeekend(): void { - /** @Then Saturday should be a weekend day */ - self::assertTrue(DayOfWeek::Saturday->isWeekend()); - self::assertFalse(DayOfWeek::Saturday->isWeekday()); + /** @Given Saturday as the day of the week */ + $day = DayOfWeek::Saturday; + + /** @When checking if it is a weekend day */ + $isWeekend = $day->isWeekend(); + + /** @Then it should be a weekend day */ + self::assertTrue($isWeekend); + + /** @And it should not be a weekday */ + self::assertFalse($day->isWeekday()); } public function testDayOfWeekSundayIsWeekend(): void { - /** @Then Sunday should be a weekend day */ - self::assertTrue(DayOfWeek::Sunday->isWeekend()); - self::assertFalse(DayOfWeek::Sunday->isWeekday()); + /** @Given Sunday as the day of the week */ + $day = DayOfWeek::Sunday; + + /** @When checking if it is a weekend day */ + $isWeekend = $day->isWeekend(); + + /** @Then it should be a weekend day */ + self::assertTrue($isWeekend); + + /** @And it should not be a weekday */ + self::assertFalse($day->isWeekday()); } public function testDayOfWeekAllDaysHaveCorrectIsoValues(): void { + /** @Given all days of the week in ISO 8601 order */ + $days = DayOfWeek::cases(); + + /** @When inspecting their backing values */ + $values = array_map(static fn(DayOfWeek $day): int => $day->value, $days); + /** @Then each day should map to its ISO 8601 numeric value */ - self::assertSame(1, DayOfWeek::Monday->value); - self::assertSame(2, DayOfWeek::Tuesday->value); - self::assertSame(3, DayOfWeek::Wednesday->value); - self::assertSame(4, DayOfWeek::Thursday->value); - self::assertSame(5, DayOfWeek::Friday->value); - self::assertSame(6, DayOfWeek::Saturday->value); - self::assertSame(7, DayOfWeek::Sunday->value); + self::assertSame([1, 2, 3, 4, 5, 6, 7], $values); } public function testDayOfWeekFromInstantOnMonday(): void @@ -56,8 +88,11 @@ public function testDayOfWeekFromInstantOnMonday(): void /** @Given an Instant on Monday 2026-02-16 */ $instant = Instant::fromString(value: '2026-02-16T10:00:00+00:00'); + /** @When deriving the day of the week from the Instant */ + $day = DayOfWeek::fromInstant(instant: $instant); + /** @Then the day should be Monday */ - self::assertSame(DayOfWeek::Monday, DayOfWeek::fromInstant(instant: $instant)); + self::assertSame(DayOfWeek::Monday, $day); } public function testDayOfWeekFromInstantOnTuesday(): void @@ -65,8 +100,11 @@ public function testDayOfWeekFromInstantOnTuesday(): void /** @Given an Instant on Tuesday 2026-02-17 */ $instant = Instant::fromString(value: '2026-02-17T10:30:00+00:00'); + /** @When deriving the day of the week from the Instant */ + $day = DayOfWeek::fromInstant(instant: $instant); + /** @Then the day should be Tuesday */ - self::assertSame(DayOfWeek::Tuesday, DayOfWeek::fromInstant(instant: $instant)); + self::assertSame(DayOfWeek::Tuesday, $day); } public function testDayOfWeekFromInstantOnWednesday(): void @@ -74,8 +112,11 @@ public function testDayOfWeekFromInstantOnWednesday(): void /** @Given an Instant on Wednesday 2026-02-18 */ $instant = Instant::fromString(value: '2026-02-18T14:30:00+00:00'); + /** @When deriving the day of the week from the Instant */ + $day = DayOfWeek::fromInstant(instant: $instant); + /** @Then the day should be Wednesday */ - self::assertSame(DayOfWeek::Wednesday, DayOfWeek::fromInstant(instant: $instant)); + self::assertSame(DayOfWeek::Wednesday, $day); } public function testDayOfWeekFromInstantOnThursday(): void @@ -83,8 +124,11 @@ public function testDayOfWeekFromInstantOnThursday(): void /** @Given an Instant at midnight on Thursday 2026-02-19 */ $instant = Instant::fromString(value: '2026-02-19T00:00:00+00:00'); + /** @When deriving the day of the week from the Instant */ + $day = DayOfWeek::fromInstant(instant: $instant); + /** @Then the day should be Thursday */ - self::assertSame(DayOfWeek::Thursday, DayOfWeek::fromInstant(instant: $instant)); + self::assertSame(DayOfWeek::Thursday, $day); } public function testDayOfWeekFromInstantOnFriday(): void @@ -92,8 +136,11 @@ public function testDayOfWeekFromInstantOnFriday(): void /** @Given an Instant on Friday 2026-02-20 */ $instant = Instant::fromString(value: '2026-02-20T17:00:00+00:00'); + /** @When deriving the day of the week from the Instant */ + $day = DayOfWeek::fromInstant(instant: $instant); + /** @Then the day should be Friday */ - self::assertSame(DayOfWeek::Friday, DayOfWeek::fromInstant(instant: $instant)); + self::assertSame(DayOfWeek::Friday, $day); } public function testDayOfWeekFromInstantOnSaturday(): void @@ -101,8 +148,11 @@ public function testDayOfWeekFromInstantOnSaturday(): void /** @Given an Instant on Saturday 2026-02-21 */ $instant = Instant::fromString(value: '2026-02-21T08:00:00+00:00'); + /** @When deriving the day of the week from the Instant */ + $day = DayOfWeek::fromInstant(instant: $instant); + /** @Then the day should be Saturday */ - self::assertSame(DayOfWeek::Saturday, DayOfWeek::fromInstant(instant: $instant)); + self::assertSame(DayOfWeek::Saturday, $day); } public function testDayOfWeekFromInstantOnSunday(): void @@ -110,41 +160,55 @@ public function testDayOfWeekFromInstantOnSunday(): void /** @Given an Instant on Sunday 2026-02-22 */ $instant = Instant::fromString(value: '2026-02-22T23:59:59+00:00'); + /** @When deriving the day of the week from the Instant */ + $day = DayOfWeek::fromInstant(instant: $instant); + /** @Then the day should be Sunday */ - self::assertSame(DayOfWeek::Sunday, DayOfWeek::fromInstant(instant: $instant)); + self::assertSame(DayOfWeek::Sunday, $day); } public function testDayOfWeekWeekdayAndWeekendAreMutuallyExclusive(): void { - /** @Then every day should be exactly one of weekday or weekend */ - self::assertNotSame(DayOfWeek::Monday->isWeekday(), DayOfWeek::Monday->isWeekend()); - self::assertNotSame(DayOfWeek::Tuesday->isWeekday(), DayOfWeek::Tuesday->isWeekend()); - self::assertNotSame(DayOfWeek::Wednesday->isWeekday(), DayOfWeek::Wednesday->isWeekend()); - self::assertNotSame(DayOfWeek::Thursday->isWeekday(), DayOfWeek::Thursday->isWeekend()); - self::assertNotSame(DayOfWeek::Friday->isWeekday(), DayOfWeek::Friday->isWeekend()); - self::assertNotSame(DayOfWeek::Saturday->isWeekday(), DayOfWeek::Saturday->isWeekend()); - self::assertNotSame(DayOfWeek::Sunday->isWeekday(), DayOfWeek::Sunday->isWeekend()); + /** @Given all days of the week */ + $days = DayOfWeek::cases(); + + /** @When checking that weekday and weekend are mutually exclusive for each day */ + $conflicts = array_filter( + $days, + static fn(DayOfWeek $day): bool => $day->isWeekday() === $day->isWeekend() + ); + + /** @Then no day should be both a weekday and a weekend day */ + self::assertCount(0, $conflicts); } public function testDayOfWeekExactlyFiveWeekdays(): void { - /** @Then there should be exactly 5 weekdays */ + /** @Given all days of the week */ + $allDays = DayOfWeek::cases(); + + /** @When filtering for weekdays */ $weekdays = array_filter( - DayOfWeek::cases(), + $allDays, static fn(DayOfWeek $day): bool => $day->isWeekday() ); + /** @Then there should be exactly 5 weekdays */ self::assertCount(5, $weekdays); } public function testDayOfWeekExactlyTwoWeekendDays(): void { - /** @Then there should be exactly 2 weekend days */ + /** @Given all days of the week */ + $allDays = DayOfWeek::cases(); + + /** @When filtering for weekend days */ $weekends = array_filter( - DayOfWeek::cases(), + $allDays, static fn(DayOfWeek $day): bool => $day->isWeekend() ); + /** @Then there should be exactly 2 weekend days */ self::assertCount(2, $weekends); } @@ -152,24 +216,36 @@ public function testDayOfWeekExactlyTwoWeekendDays(): void public function testDayOfWeekDistanceToSameDayReturnsZero(DayOfWeek $day): void { /** @Given the same day of the week */ + + /** @When computing the forward distance to itself */ + $distance = $day->distanceTo(other: $day); + /** @Then the distance to itself should be zero */ - self::assertSame(0, $day->distanceTo(other: $day)); + self::assertSame(0, $distance); } #[DataProvider('forwardDistanceDataProvider')] public function testDayOfWeekDistanceToForward(DayOfWeek $from, DayOfWeek $to, int $expectedDistance): void { /** @Given a starting day and a target day */ + + /** @When computing the forward distance */ + $distance = $from->distanceTo(other: $to); + /** @Then the forward distance should match the expected value */ - self::assertSame($expectedDistance, $from->distanceTo(other: $to)); + self::assertSame($expectedDistance, $distance); } #[DataProvider('wrapAroundDistanceDataProvider')] public function testDayOfWeekDistanceToWrapsAroundWeek(DayOfWeek $from, DayOfWeek $to, int $expectedDistance): void { /** @Given a starting day that is after the target day in the week */ + + /** @When computing the forward distance */ + $distance = $from->distanceTo(other: $to); + /** @Then the distance should wrap forward through the end of the week */ - self::assertSame($expectedDistance, $from->distanceTo(other: $to)); + self::assertSame($expectedDistance, $distance); } #[DataProvider('asymmetricDistanceDataProvider')] @@ -180,9 +256,16 @@ public function testDayOfWeekDistanceToIsNotSymmetric( int $expectedBackward ): void { /** @Given two distinct days of the week */ + + /** @When computing the forward distance */ + $forward = $from->distanceTo(other: $to); + + /** @And computing the backward distance */ + $backward = $to->distanceTo(other: $from); + /** @Then the forward and backward distances should differ */ - self::assertSame($expectedForward, $from->distanceTo(other: $to)); - self::assertSame($expectedBackward, $to->distanceTo(other: $from)); + self::assertSame($expectedForward, $forward); + self::assertSame($expectedBackward, $backward); /** @And together they should complete a full week */ self::assertSame(7, $expectedForward + $expectedBackward); @@ -192,6 +275,8 @@ public function testDayOfWeekDistanceToIsNotSymmetric( public function testDayOfWeekDistanceToNeverExceedsSix(DayOfWeek $from, DayOfWeek $to): void { /** @Given any pair of days */ + + /** @When computing the forward distance */ $distance = $from->distanceTo(other: $to); /** @Then the distance should be in the range [0, 6] */ diff --git a/tests/Unit/DurationTest.php b/tests/Unit/DurationTest.php index 48a2955..b33480c 100644 --- a/tests/Unit/DurationTest.php +++ b/tests/Unit/DurationTest.php @@ -12,7 +12,7 @@ final class DurationTest extends TestCase { public function testDurationZeroCreatesZeroDuration(): void { - /** @Given a zero Duration */ + /** @When creating a zero Duration */ $duration = Duration::zero(); /** @Then the seconds should be zero */ @@ -24,7 +24,7 @@ public function testDurationZeroCreatesZeroDuration(): void public function testDurationFromSecondsCreatesCorrectDuration(): void { - /** @Given a Duration created from 1800 seconds */ + /** @When creating a Duration from 1800 seconds */ $duration = Duration::fromSeconds(seconds: 1800); /** @Then it should hold 1800 seconds */ @@ -36,7 +36,7 @@ public function testDurationFromSecondsCreatesCorrectDuration(): void public function testDurationFromSecondsWithZero(): void { - /** @Given a Duration created from zero seconds */ + /** @When creating a Duration from zero seconds */ $duration = Duration::fromSeconds(seconds: 0); /** @Then it should hold zero seconds */ @@ -57,7 +57,7 @@ public function testDurationWhenNegativeSeconds(): void public function testDurationFromMinutesConvertsToSeconds(): void { - /** @Given a Duration created from 30 minutes */ + /** @When creating a Duration from 30 minutes */ $duration = Duration::fromMinutes(minutes: 30); /** @Then it should hold 1800 seconds */ @@ -66,7 +66,7 @@ public function testDurationFromMinutesConvertsToSeconds(): void public function testDurationFromMinutesWithZero(): void { - /** @Given a Duration created from zero minutes */ + /** @When creating a Duration from zero minutes */ $duration = Duration::fromMinutes(minutes: 0); /** @Then it should be zero */ @@ -84,7 +84,7 @@ public function testDurationWhenNegativeMinutes(): void public function testDurationFromHoursConvertsToSeconds(): void { - /** @Given a Duration created from 2 hours */ + /** @When creating a Duration from 2 hours */ $duration = Duration::fromHours(hours: 2); /** @Then it should hold 7200 seconds */ @@ -93,7 +93,7 @@ public function testDurationFromHoursConvertsToSeconds(): void public function testDurationFromHoursWithZero(): void { - /** @Given a Duration created from zero hours */ + /** @When creating a Duration from zero hours */ $duration = Duration::fromHours(hours: 0); /** @Then it should hold zero seconds */ @@ -114,7 +114,7 @@ public function testDurationWhenNegativeHours(): void public function testDurationFromDaysConvertsToSeconds(): void { - /** @Given a Duration created from 1 day */ + /** @When creating a Duration from 1 day */ $duration = Duration::fromDays(days: 1); /** @Then it should hold 86400 seconds */ @@ -123,7 +123,7 @@ public function testDurationFromDaysConvertsToSeconds(): void public function testDurationFromDaysWithZero(): void { - /** @Given a Duration created from zero days */ + /** @When creating a Duration from zero days */ $duration = Duration::fromDays(days: 0); /** @Then it should hold zero seconds */ @@ -318,8 +318,11 @@ public function testDurationIsGreaterThanReturnsTrueWhenLonger(): void /** @And a Duration of 30 minutes */ $thirtyMinutes = Duration::fromMinutes(minutes: 30); + /** @When comparing the longer to the shorter */ + $result = $twoHours->isGreaterThan(other: $thirtyMinutes); + /** @Then the longer should be greater than the shorter */ - self::assertTrue($twoHours->isGreaterThan(other: $thirtyMinutes)); + self::assertTrue($result); /** @And the shorter should not be greater than the longer */ self::assertFalse($thirtyMinutes->isGreaterThan(other: $twoHours)); @@ -333,8 +336,11 @@ public function testDurationIsGreaterThanReturnsFalseWhenEqual(): void /** @And another Duration of 30 minutes */ $secondThirtyMinutes = Duration::fromMinutes(minutes: 30); + /** @When comparing equal Durations */ + $result = $firstThirtyMinutes->isGreaterThan(other: $secondThirtyMinutes); + /** @Then neither should be greater than the other */ - self::assertFalse($firstThirtyMinutes->isGreaterThan(other: $secondThirtyMinutes)); + self::assertFalse($result); } public function testDurationIsLessThanReturnsTrueWhenShorter(): void @@ -345,8 +351,11 @@ public function testDurationIsLessThanReturnsTrueWhenShorter(): void /** @And a Duration of 1 hour */ $oneHour = Duration::fromHours(hours: 1); + /** @When comparing the shorter to the longer */ + $result = $fifteenMinutes->isLessThan(other: $oneHour); + /** @Then the shorter should be less than the longer */ - self::assertTrue($fifteenMinutes->isLessThan(other: $oneHour)); + self::assertTrue($result); /** @And the longer should not be less than the shorter */ self::assertFalse($oneHour->isLessThan(other: $fifteenMinutes)); @@ -360,71 +369,95 @@ public function testDurationIsLessThanReturnsFalseWhenEqual(): void /** @And another Duration of 1 hour */ $secondHour = Duration::fromHours(hours: 1); + /** @When comparing equal Durations */ + $result = $firstHour->isLessThan(other: $secondHour); + /** @Then neither should be less than the other */ - self::assertFalse($firstHour->isLessThan(other: $secondHour)); + self::assertFalse($result); } public function testDurationToSeconds(): void { - /** @Given a Duration created from 30 minutes */ + /** @Given a Duration of 30 minutes */ $duration = Duration::fromMinutes(minutes: 30); - /** @Then toSeconds should return 1800 */ - self::assertSame(1800, $duration->toSeconds()); + /** @When converting to seconds */ + $result = $duration->toSeconds(); + + /** @Then it should return 1800 */ + self::assertSame(1800, $result); } public function testDurationToMinutes(): void { - /** @Given a Duration created from 5400 seconds */ + /** @Given a Duration of 5400 seconds */ $duration = Duration::fromSeconds(seconds: 5400); - /** @Then toMinutes should return 90 */ - self::assertSame(90, $duration->toMinutes()); + /** @When converting to minutes */ + $result = $duration->toMinutes(); + + /** @Then it should return 90 */ + self::assertSame(90, $result); } public function testDurationToMinutesTruncates(): void { - /** @Given a Duration created from 100 seconds */ + /** @Given a Duration of 100 seconds */ $duration = Duration::fromSeconds(seconds: 100); - /** @Then toMinutes should return 1 (truncated from 1.67) */ - self::assertSame(1, $duration->toMinutes()); + /** @When converting to minutes */ + $result = $duration->toMinutes(); + + /** @Then it should return 1 (truncated from 1.67) */ + self::assertSame(1, $result); } public function testDurationToHours(): void { - /** @Given a Duration created from 2 hours */ + /** @Given a Duration of 2 hours */ $duration = Duration::fromHours(hours: 2); - /** @Then toHours should return 2 */ - self::assertSame(2, $duration->toHours()); + /** @When converting to hours */ + $result = $duration->toHours(); + + /** @Then it should return 2 */ + self::assertSame(2, $result); } public function testDurationToHoursTruncates(): void { - /** @Given a Duration created from 5400 seconds */ + /** @Given a Duration of 5400 seconds */ $duration = Duration::fromSeconds(seconds: 5400); - /** @Then toHours should return 1 (truncated from 1.5) */ - self::assertSame(1, $duration->toHours()); + /** @When converting to hours */ + $result = $duration->toHours(); + + /** @Then it should return 1 (truncated from 1.5) */ + self::assertSame(1, $result); } public function testDurationToDays(): void { - /** @Given a Duration created from 3 days */ + /** @Given a Duration of 3 days */ $duration = Duration::fromDays(days: 3); - /** @Then toDays should return 3 */ - self::assertSame(3, $duration->toDays()); + /** @When converting to days */ + $result = $duration->toDays(); + + /** @Then it should return 3 */ + self::assertSame(3, $result); } public function testDurationToDaysTruncates(): void { - /** @Given a Duration created from 36 hours */ + /** @Given a Duration of 36 hours */ $duration = Duration::fromHours(hours: 36); - /** @Then toDays should return 1 (truncated from 1.5) */ - self::assertSame(1, $duration->toDays()); + /** @When converting to days */ + $result = $duration->toDays(); + + /** @Then it should return 1 (truncated from 1.5) */ + self::assertSame(1, $result); } public function testDurationPlusAndMinusAreInverse(): void diff --git a/tests/Unit/PeriodTest.php b/tests/Unit/PeriodTest.php index d2d882c..beedaf9 100644 --- a/tests/Unit/PeriodTest.php +++ b/tests/Unit/PeriodTest.php @@ -371,8 +371,13 @@ public function testPeriodContainsIsConsistentWithOverlapsForSingleInstant(): vo /** @And a 1-second micro-period starting at that Instant */ $microPeriod = Period::startingAt(from: $contained, duration: Duration::fromSeconds(seconds: 1)); - /** @Then the period should contain the instant and overlap with the micro-period */ - self::assertTrue($period->contains(instant: $contained)); + /** @When checking if the period contains the instant */ + $containsResult = $period->contains(instant: $contained); + + /** @Then the period should contain the instant */ + self::assertTrue($containsResult); + + /** @And it should overlap with the micro-period */ self::assertTrue($period->overlapsWith(other: $microPeriod)); } @@ -390,11 +395,13 @@ public function testPeriodOverlapsWithNonOverlappingIsSymmetric(): void duration: Duration::fromHours(hours: 1) ); - /** @Then symmetry should hold for non-overlapping case */ - self::assertSame( - $periodA->overlapsWith(other: $periodB), - $periodB->overlapsWith(other: $periodA) - ); - self::assertFalse($periodA->overlapsWith(other: $periodB)); + /** @When checking if the periods overlap */ + $overlaps = $periodA->overlapsWith(other: $periodB); + + /** @Then they should not overlap */ + self::assertFalse($overlaps); + + /** @And the overlap check should be symmetric */ + self::assertSame($overlaps, $periodB->overlapsWith(other: $periodA)); } } diff --git a/tests/Unit/TimeOfDayTest.php b/tests/Unit/TimeOfDayTest.php index 981e84e..55cdc76 100644 --- a/tests/Unit/TimeOfDayTest.php +++ b/tests/Unit/TimeOfDayTest.php @@ -13,31 +13,37 @@ final class TimeOfDayTest extends TestCase { public function testTimeOfDayFromCreatesValidTimeOfDay(): void { - /** @Given valid hour and minute */ + /** @When creating a TimeOfDay with hour 8 and minute 30 */ $time = TimeOfDay::from(hour: 8, minute: 30); - /** @Then the components should match */ + /** @Then the hour should be 8 */ self::assertSame(8, $time->hour); + + /** @And the minute should be 30 */ self::assertSame(30, $time->minute); } public function testTimeOfDayFromWithZeros(): void { - /** @Given hour 0 and minute 0 */ + /** @When creating a TimeOfDay with hour 0 and minute 0 */ $time = TimeOfDay::from(hour: 0, minute: 0); - /** @Then it should be midnight */ + /** @Then the hour should be 0 */ self::assertSame(0, $time->hour); + + /** @And the minute should be 0 */ self::assertSame(0, $time->minute); } public function testTimeOfDayFromWithMaxValues(): void { - /** @Given maximum valid hour and minute */ + /** @When creating a TimeOfDay with hour 23 and minute 59 */ $time = TimeOfDay::from(hour: 23, minute: 59); - /** @Then the components should match */ + /** @Then the hour should be 23 */ self::assertSame(23, $time->hour); + + /** @And the minute should be 59 */ self::assertSame(59, $time->minute); } @@ -79,23 +85,31 @@ public function testTimeOfDayWhenMinuteExceeds59(): void public function testTimeOfDayMidnight(): void { - /** @Given midnight */ + /** @When creating the midnight TimeOfDay */ $time = TimeOfDay::midnight(); - /** @Then it should be 00:00 */ + /** @Then the hour should be 0 */ self::assertSame(0, $time->hour); + + /** @And the minute should be 0 */ self::assertSame(0, $time->minute); + + /** @And the string representation should be '00:00' */ self::assertSame('00:00', $time->toString()); } public function testTimeOfDayNoon(): void { - /** @Given noon */ + /** @When creating the noon TimeOfDay */ $time = TimeOfDay::noon(); - /** @Then it should be 12:00 */ + /** @Then the hour should be 12 */ self::assertSame(12, $time->hour); + + /** @And the minute should be 0 */ self::assertSame(0, $time->minute); + + /** @And the string representation should be '12:00' */ self::assertSame('12:00', $time->toString()); } @@ -107,8 +121,10 @@ public function testTimeOfDayFromInstant(): void /** @When extracting the time of day */ $time = TimeOfDay::fromInstant(instant: $instant); - /** @Then the components should match */ + /** @Then the hour should be 14 */ self::assertSame(14, $time->hour); + + /** @And the minute should be 30 */ self::assertSame(30, $time->minute); } @@ -120,8 +136,10 @@ public function testTimeOfDayFromInstantAtMidnight(): void /** @When extracting the time of day */ $time = TimeOfDay::fromInstant(instant: $instant); - /** @Then it should be midnight */ + /** @Then the hour should be 0 */ self::assertSame(0, $time->hour); + + /** @And the minute should be 0 */ self::assertSame(0, $time->minute); } @@ -133,38 +151,46 @@ public function testTimeOfDayFromInstantAtEndOfDay(): void /** @When extracting the time of day */ $time = TimeOfDay::fromInstant(instant: $instant); - /** @Then it should be 23:59 */ + /** @Then the hour should be 23 */ self::assertSame(23, $time->hour); + + /** @And the minute should be 59 */ self::assertSame(59, $time->minute); } public function testTimeOfDayFromStringValid(): void { - /** @Given a valid time string */ + /** @When creating a TimeOfDay from the string '08:30' */ $time = TimeOfDay::fromString(value: '08:30'); - /** @Then the components should match */ + /** @Then the hour should be 8 */ self::assertSame(8, $time->hour); + + /** @And the minute should be 30 */ self::assertSame(30, $time->minute); } public function testTimeOfDayFromStringMidnight(): void { - /** @Given midnight as string */ + /** @When creating a TimeOfDay from the string '00:00' */ $time = TimeOfDay::fromString(value: '00:00'); - /** @Then it should be midnight */ + /** @Then the hour should be 0 */ self::assertSame(0, $time->hour); + + /** @And the minute should be 0 */ self::assertSame(0, $time->minute); } public function testTimeOfDayFromStringEndOfDay(): void { - /** @Given end of day as string */ + /** @When creating a TimeOfDay from the string '23:59' */ $time = TimeOfDay::fromString(value: '23:59'); - /** @Then it should be 23:59 */ + /** @Then the hour should be 23 */ self::assertSame(23, $time->hour); + + /** @And the minute should be 59 */ self::assertSame(59, $time->minute); } @@ -188,12 +214,16 @@ public function testTimeOfDayFromStringWhenEmpty(): void public function testTimeOfDayFromStringWhenHasSeconds(): void { - /** @Given a time string with seconds */ + /** @When creating a TimeOfDay from the string '08:30:00' */ $time = TimeOfDay::fromString(value: '08:30:00'); - /** @Then the seconds should be discarded and the components should match */ + /** @Then the hour should be 8 */ self::assertSame(8, $time->hour); + + /** @And the minute should be 30 */ self::assertSame(30, $time->minute); + + /** @And the string representation should discard seconds */ self::assertSame('08:30', $time->toString()); } @@ -220,8 +250,11 @@ public function testTimeOfDayToMinutesSinceMidnightAtMidnight(): void /** @Given midnight */ $time = TimeOfDay::midnight(); + /** @When converting to minutes since midnight */ + $minutes = $time->toMinutesSinceMidnight(); + /** @Then minutes since midnight should be 0 */ - self::assertSame(0, $time->toMinutesSinceMidnight()); + self::assertSame(0, $minutes); } public function testTimeOfDayToMinutesSinceMidnightAtNoon(): void @@ -229,8 +262,11 @@ public function testTimeOfDayToMinutesSinceMidnightAtNoon(): void /** @Given noon */ $time = TimeOfDay::noon(); + /** @When converting to minutes since midnight */ + $minutes = $time->toMinutesSinceMidnight(); + /** @Then minutes since midnight should be 720 */ - self::assertSame(720, $time->toMinutesSinceMidnight()); + self::assertSame(720, $minutes); } public function testTimeOfDayToMinutesSinceMidnightAt0830(): void @@ -238,8 +274,11 @@ public function testTimeOfDayToMinutesSinceMidnightAt0830(): void /** @Given 08:30 */ $time = TimeOfDay::from(hour: 8, minute: 30); + /** @When converting to minutes since midnight */ + $minutes = $time->toMinutesSinceMidnight(); + /** @Then minutes since midnight should be 510 */ - self::assertSame(510, $time->toMinutesSinceMidnight()); + self::assertSame(510, $minutes); } public function testTimeOfDayToMinutesSinceMidnightAtEndOfDay(): void @@ -247,8 +286,11 @@ public function testTimeOfDayToMinutesSinceMidnightAtEndOfDay(): void /** @Given 23:59 */ $time = TimeOfDay::from(hour: 23, minute: 59); + /** @When converting to minutes since midnight */ + $minutes = $time->toMinutesSinceMidnight(); + /** @Then minutes since midnight should be 1439 */ - self::assertSame(1439, $time->toMinutesSinceMidnight()); + self::assertSame(1439, $minutes); } public function testTimeOfDayToDuration(): void @@ -259,8 +301,10 @@ public function testTimeOfDayToDuration(): void /** @When converting to Duration */ $duration = $time->toDuration(); - /** @Then the duration should be 510 minutes in seconds */ + /** @Then the duration should be 510 minutes */ self::assertSame(510, $duration->toMinutes()); + + /** @And 30600 seconds */ self::assertSame(30600, $duration->toSeconds()); } @@ -284,8 +328,11 @@ public function testTimeOfDayIsBeforeReturnsTrueWhenEarlier(): void /** @And a later time */ $later = TimeOfDay::from(hour: 14, minute: 30); + /** @When checking if the earlier time is before the later */ + $result = $earlier->isBefore(other: $later); + /** @Then the earlier should be before the later */ - self::assertTrue($earlier->isBefore(other: $later)); + self::assertTrue($result); } public function testTimeOfDayIsBeforeReturnsFalseWhenLater(): void @@ -296,8 +343,11 @@ public function testTimeOfDayIsBeforeReturnsFalseWhenLater(): void /** @And an earlier time */ $earlier = TimeOfDay::from(hour: 8, minute: 0); + /** @When checking if the later time is before the earlier */ + $result = $later->isBefore(other: $earlier); + /** @Then the later should not be before the earlier */ - self::assertFalse($later->isBefore(other: $earlier)); + self::assertFalse($result); } public function testTimeOfDayIsBeforeReturnsFalseWhenEqual(): void @@ -308,8 +358,11 @@ public function testTimeOfDayIsBeforeReturnsFalseWhenEqual(): void /** @And the same time */ $same = TimeOfDay::from(hour: 10, minute: 0); + /** @When checking if the time is before itself */ + $result = $time->isBefore(other: $same); + /** @Then isBefore should return false */ - self::assertFalse($time->isBefore(other: $same)); + self::assertFalse($result); } public function testTimeOfDayIsAfterReturnsTrueWhenLater(): void @@ -320,8 +373,11 @@ public function testTimeOfDayIsAfterReturnsTrueWhenLater(): void /** @And an earlier time */ $earlier = TimeOfDay::from(hour: 8, minute: 0); + /** @When checking if the later time is after the earlier */ + $result = $later->isAfter(other: $earlier); + /** @Then the later should be after the earlier */ - self::assertTrue($later->isAfter(other: $earlier)); + self::assertTrue($result); } public function testTimeOfDayIsAfterReturnsFalseWhenEqual(): void @@ -332,8 +388,11 @@ public function testTimeOfDayIsAfterReturnsFalseWhenEqual(): void /** @And the same time */ $same = TimeOfDay::from(hour: 10, minute: 0); + /** @When checking if the time is after itself */ + $result = $time->isAfter(other: $same); + /** @Then isAfter should return false */ - self::assertFalse($time->isAfter(other: $same)); + self::assertFalse($result); } public function testTimeOfDayIsBeforeOrEqualReturnsTrueWhenEqual(): void @@ -344,8 +403,11 @@ public function testTimeOfDayIsBeforeOrEqualReturnsTrueWhenEqual(): void /** @And the same time */ $same = TimeOfDay::from(hour: 10, minute: 0); + /** @When checking if the time is before or equal to itself */ + $result = $time->isBeforeOrEqual(other: $same); + /** @Then isBeforeOrEqual should return true */ - self::assertTrue($time->isBeforeOrEqual(other: $same)); + self::assertTrue($result); } public function testTimeOfDayIsAfterOrEqualReturnsTrueWhenEqual(): void @@ -356,8 +418,11 @@ public function testTimeOfDayIsAfterOrEqualReturnsTrueWhenEqual(): void /** @And the same time */ $same = TimeOfDay::from(hour: 10, minute: 0); + /** @When checking if the time is after or equal to itself */ + $result = $time->isAfterOrEqual(other: $same); + /** @Then isAfterOrEqual should return true */ - self::assertTrue($time->isAfterOrEqual(other: $same)); + self::assertTrue($result); } public function testTimeOfDayIsBeforeAndIsAfterAreMutuallyExclusive(): void @@ -368,10 +433,19 @@ public function testTimeOfDayIsBeforeAndIsAfterAreMutuallyExclusive(): void /** @And a later time */ $later = TimeOfDay::from(hour: 18, minute: 0); - /** @Then isBefore and isAfter should be mutually exclusive */ - self::assertTrue($earlier->isBefore(other: $later)); + /** @When checking if the earlier is before the later */ + $earlierIsBefore = $earlier->isBefore(other: $later); + + /** @Then earlier should be before later */ + self::assertTrue($earlierIsBefore); + + /** @And earlier should not be after later */ self::assertFalse($earlier->isAfter(other: $later)); + + /** @And later should be after earlier */ self::assertTrue($later->isAfter(other: $earlier)); + + /** @And later should not be before earlier */ self::assertFalse($later->isBefore(other: $earlier)); } @@ -422,11 +496,19 @@ public function testTimeOfDayDurationUntilWhenEqual(): void public function testTimeOfDayToStringFormatsCorrectly(): void { - /** @Then various times should format correctly */ - self::assertSame('00:00', TimeOfDay::from(hour: 0, minute: 0)->toString()); - self::assertSame('08:05', TimeOfDay::from(hour: 8, minute: 5)->toString()); - self::assertSame('14:30', TimeOfDay::from(hour: 14, minute: 30)->toString()); - self::assertSame('23:59', TimeOfDay::from(hour: 23, minute: 59)->toString()); + /** @When converting boundary and common times to their string representations */ + $strings = array_map( + static fn(TimeOfDay $time): string => $time->toString(), + [ + TimeOfDay::from(hour: 0, minute: 0), + TimeOfDay::from(hour: 8, minute: 5), + TimeOfDay::from(hour: 14, minute: 30), + TimeOfDay::from(hour: 23, minute: 59) + ] + ); + + /** @Then each time should format with zero-padded hour and minute */ + self::assertSame(['00:00', '08:05', '14:30', '23:59'], $strings); } public function testTimeOfDayFromStringAndToStringRoundTrip(): void @@ -452,8 +534,10 @@ public function testTimeOfDayFromInstantAndFromProduceSameResult(): void /** @And creating from hour and minute directly */ $fromFactory = TimeOfDay::from(hour: 14, minute: 30); - /** @Then both should produce the same result */ + /** @Then both should produce the same hour */ self::assertSame($fromInstant->hour, $fromFactory->hour); + + /** @And the same minute */ self::assertSame($fromInstant->minute, $fromFactory->minute); } diff --git a/tests/Unit/TimezonesTest.php b/tests/Unit/TimezonesTest.php index 7509c4d..386a725 100644 --- a/tests/Unit/TimezonesTest.php +++ b/tests/Unit/TimezonesTest.php @@ -28,20 +28,28 @@ public function testTimezonesFromSingleTimezone(): void public function testTimezonesFromMultipleTimezones(): void { - /** @Given multiple Timezone objects */ + /** @Given a Timezone for São Paulo */ $first = Timezone::from(identifier: 'America/Sao_Paulo'); + + /** @And a Timezone for New York */ $second = Timezone::from(identifier: 'America/New_York'); + + /** @And a Timezone for Tokyo */ $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 */ + /** @Then the collection should contain three items */ self::assertSame(3, $timezones->count()); - /** @And they should be in the same order */ + /** @And the first should be America/Sao_Paulo */ self::assertSame('America/Sao_Paulo', $timezones->all()[0]->value); + + /** @And the second should be America/New_York */ self::assertSame('America/New_York', $timezones->all()[1]->value); + + /** @And the third should be Asia/Tokyo */ self::assertSame('Asia/Tokyo', $timezones->all()[2]->value); } @@ -72,8 +80,11 @@ 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')); + /** @When checking if 'America/Sao_Paulo' is contained */ + $result = $timezones->contains(iana: 'America/Sao_Paulo'); + + /** @Then it should return true */ + self::assertTrue($result); } public function testTimezonesContainsReturnsFalseForMissingIdentifier(): void @@ -81,8 +92,11 @@ 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')); + /** @When checking if 'Asia/Tokyo' is contained */ + $result = $timezones->contains(iana: 'Asia/Tokyo'); + + /** @Then it should return false */ + self::assertFalse($result); } public function testTimezonesFindByIdentifierReturnsMatchingTimezone(): void @@ -139,8 +153,11 @@ 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()); + /** @When checking the count */ + $count = $timezones->count(); + + /** @Then count should match the number of items in all() */ + self::assertCount($count, $timezones->all()); } public function testTimezonesIsCountable(): void @@ -148,8 +165,11 @@ 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)); + /** @When counting via the native count() function */ + $count = count($timezones); + + /** @Then the count should be 2 */ + self::assertSame(2, $count); } public function testTimezonesToStringsReturnsPlainIdentifiers(): void @@ -160,12 +180,11 @@ public function testTimezonesToStringsReturnsPlainIdentifiers(): void /** @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::assertSame($all[$index]->value, $string); - } + /** @Then each identifier should match its corresponding Timezone value */ + self::assertSame( + array_map(static fn(Timezone $timezone): string => $timezone->value, $timezones->all()), + $strings + ); } public function testTimezonesFromEmptyReturnsEmptyCollection(): void @@ -173,9 +192,13 @@ public function testTimezonesFromEmptyReturnsEmptyCollection(): void /** @When creating an empty Timezones collection */ $timezones = Timezones::from(); - /** @Then the collection should be empty */ + /** @Then the count should be zero */ self::assertSame(0, $timezones->count()); + + /** @And all() should return an empty array */ self::assertSame([], $timezones->all()); + + /** @And toStrings() should return an empty array */ self::assertSame([], $timezones->toStrings()); } @@ -193,12 +216,17 @@ public function testTimezonesPreservesInsertionOrder(): void public function testTimezonesCreatedFromSameIdentifiersAreConsistent(): void { - /** @Given two Timezones collections created from the same identifiers */ + /** @Given a first Timezones collection */ $first = Timezones::fromStrings('UTC', 'America/Sao_Paulo'); + + /** @And a second Timezones collection from the same identifiers */ $second = Timezones::fromStrings('UTC', 'America/Sao_Paulo'); - /** @Then their string representations should be identical */ - self::assertSame($first->toStrings(), $second->toStrings()); + /** @When converting the first to strings */ + $strings = $first->toStrings(); + + /** @Then they should be identical to the second's string representation */ + self::assertSame($strings, $second->toStrings()); /** @And their counts should match */ self::assertSame($first->count(), $second->count()); From 14d30b936611763596ab4489fe551ddd7d4d4a14 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 23 May 2026 14:11:18 -0300 Subject: [PATCH 4/6] feat: Add Precision enum and sub-second output to Instant.toIso8601. --- src/Instant.php | 63 +++++- .../Decoders/OffsetDateTimeDecoder.php | 26 ++- src/Precision.php | 15 ++ tests/Unit/InstantTest.php | 205 +++++++++++++++++- 4 files changed, 284 insertions(+), 25 deletions(-) create mode 100644 src/Precision.php diff --git a/src/Instant.php b/src/Instant.php index 6ce589c..a746fb5 100644 --- a/src/Instant.php +++ b/src/Instant.php @@ -18,7 +18,11 @@ use ValueObjectBehavior; private const string UNIX_FORMAT = 'U'; + private const string OFFSET_FORMAT = 'P'; private const string ISO8601_FORMAT = 'Y-m-d\TH:i:sP'; + private const string ISO8601_MICRO_FORMAT = 'Y-m-d\TH:i:s.uP'; + private const string ISO8601_DATETIME_FORMAT = 'Y-m-d\TH:i:s'; + private const string FRACTIONAL_SECONDS_FORMAT = 'u'; private function __construct(private DateTimeImmutable $datetime) { @@ -36,7 +40,7 @@ public static function now(): Instant } /** - * Creates an Instant by decoding a date-time string. + * Creates an Instant from 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. @@ -61,7 +65,6 @@ 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)); } @@ -108,7 +111,7 @@ public function durationUntil(Instant $other): Duration } /** - * Returns true if this instant is strictly before the other. + * Tells whether this instant is strictly before the other. * * @param Instant $other The instant to compare against. * @return bool True if this instant precedes the other. @@ -119,7 +122,7 @@ public function isBefore(Instant $other): bool } /** - * Returns true if this instant is strictly after the other. + * Tells whether this instant is strictly after the other. * * @param Instant $other The instant to compare against. * @return bool True if this instant follows the other. @@ -130,7 +133,7 @@ public function isAfter(Instant $other): bool } /** - * Returns true if this instant is before or at the same moment as the other. + * Tells whether this instant is before or at the same moment as the other. * * @param Instant $other The instant to compare against. * @return bool True if this instant is at or before the other. @@ -141,7 +144,7 @@ public function isBeforeOrEqual(Instant $other): bool } /** - * Returns true if this instant is after or at the same moment as the other. + * Tells whether this instant is after or at the same moment as the other. * * @param Instant $other The instant to compare against. * @return bool True if this instant is at or after the other. @@ -152,13 +155,51 @@ public function isAfterOrEqual(Instant $other): bool } /** - * Formats this instant as an ISO 8601 string in UTC (e.g. 2026-02-17T10:30:00+00:00). + * Returns the Instant as an ISO 8601 string in UTC at the chosen sub-second precision. * - * @return string The ISO 8601 representation without fractional seconds. + * The output always carries the +00:00 offset and is composed of a date, a time, and an + * optional fractional-seconds component determined by the precision argument: + * + * - Precision::Seconds (default) — no fractional component, e.g. 2026-02-17T10:30:00+00:00. + * - Precision::Milliseconds — three fractional digits, e.g. 2026-02-17T08:27:21.106+00:00. + * - Precision::Microseconds — six fractional digits, e.g. 2026-02-17T08:27:21.106011+00:00. + * + * Use Microseconds when interoperating with stores that hold sub-second precision (e.g. a + * TIMESTAMP(6) column). Use Seconds for human-facing logs or APIs that do not carry + * sub-second timing. Use Milliseconds when consumers expect millisecond resolution but + * not microseconds (some web APIs, JavaScript Date). + * + * @param Precision $precision The sub-second granularity to include in the output. + * Defaults to Precision::Seconds. + * @return string The ISO 8601 representation in UTC at the requested precision. + */ + public function toIso8601(Precision $precision = Precision::Seconds): string + { + $template = '%s.%s%s'; + + return match ($precision) { + Precision::Seconds => $this->datetime->format(self::ISO8601_FORMAT), + Precision::Microseconds => $this->datetime->format(self::ISO8601_MICRO_FORMAT), + Precision::Milliseconds => sprintf( + $template, + $this->datetime->format(self::ISO8601_DATETIME_FORMAT), + substr($this->datetime->format(self::FRACTIONAL_SECONDS_FORMAT), 0, 3), + $this->datetime->format(self::OFFSET_FORMAT) + ) + }; + } + + /** + * Projects this instant into a calendar date under the given timezone. + * + * @param Timezone $zone The timezone used to determine the civil date. + * @return LocalDate The local date in the given timezone at this instant. */ - public function toIso8601(): string + public function toLocalDate(Timezone $zone): LocalDate { - return $this->datetime->format(self::ISO8601_FORMAT); + $dateTime = $this->datetime->setTimezone($zone->toDateTimeZone()); + + return LocalDate::fromString(value: $dateTime->format('Y-m-d')); } /** @@ -172,7 +213,7 @@ public function toUnixSeconds(): int } /** - * Returns the underlying DateTimeImmutable instance in UTC. + * Returns the Instant as a DateTimeImmutable in UTC. * * @return DateTimeImmutable The UTC date-time with microsecond precision. */ diff --git a/src/Internal/Decoders/OffsetDateTimeDecoder.php b/src/Internal/Decoders/OffsetDateTimeDecoder.php index 2cf1ab0..f5e9135 100644 --- a/src/Internal/Decoders/OffsetDateTimeDecoder.php +++ b/src/Internal/Decoders/OffsetDateTimeDecoder.php @@ -10,20 +10,34 @@ final readonly class OffsetDateTimeDecoder implements Decoder { private const string FORMAT = 'Y-m-d\TH:i:sP'; + private const string FORMAT_MICRO = 'Y-m-d\TH:i:s.uP'; private const string PATTERN = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+\-]\d{2}:\d{2}$/'; + private const string PATTERN_MICRO = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+[+\-]\d{2}:\d{2}$/'; public function decode(string $value): ?DateTimeImmutable { - if (preg_match(self::PATTERN, $value) !== 1) { - return null; + $utc = new DateTimeZone(timezone: 'UTC'); + + if (preg_match(self::PATTERN, $value) === 1) { + $parsed = DateTimeImmutable::createFromFormat(self::FORMAT, $value); + + if ($parsed === false || DateTimeImmutable::getLastErrors() !== false) { + return null; + } + + return $parsed->setTimezone($utc); } - $parsed = DateTimeImmutable::createFromFormat(self::FORMAT, $value); + if (preg_match(self::PATTERN_MICRO, $value) === 1) { + $parsed = DateTimeImmutable::createFromFormat(self::FORMAT_MICRO, $value); + + if ($parsed === false || DateTimeImmutable::getLastErrors() !== false) { + return null; + } - if ($parsed === false || DateTimeImmutable::getLastErrors() !== false) { - return null; + return $parsed->setTimezone($utc); } - return $parsed->setTimezone(new DateTimeZone(timezone: 'UTC')); + return null; } } diff --git a/src/Precision.php b/src/Precision.php new file mode 100644 index 0000000..e80afc7 --- /dev/null +++ b/src/Precision.php @@ -0,0 +1,15 @@ +durationUntil(other: $same); + + /** @Then the duration between them should be zero */ self::assertSame(0, $duration->toSeconds()); self::assertTrue($duration->isZero()); } @@ -730,6 +731,142 @@ public function testInstantComparisonWithDifferentOriginsProducesSameResult(): v self::assertTrue($fromString->isAfterOrEqual(other: $fromUnix)); } + #[DataProvider('precisionDataProvider')] + public function testToIso8601WithPrecision( + string $value, + Precision $precision, + string $expectedIso8601 + ): void { + /** @Given an Instant created from the string */ + $instant = Instant::fromString(value: $value); + + /** @When formatting with the given precision */ + $iso = $instant->toIso8601(precision: $precision); + + /** @Then the output should match the expected ISO 8601 string */ + self::assertSame($expectedIso8601, $iso); + } + + public function testToIso8601MicrosecondsRoundTrip(): void + { + /** @Given an Instant parsed from an ISO 8601 string with microseconds */ + $instant = Instant::fromString(value: '2026-02-17T10:30:00.123456+00:00'); + + /** @When formatting with microsecond precision */ + $iso = $instant->toIso8601(precision: Precision::Microseconds); + + /** @Then the output should be byte-identical to the original fractional string */ + self::assertSame('2026-02-17T10:30:00.123456+00:00', $iso); + } + + public function testToIso8601MillisecondsRoundTrip(): void + { + /** @Given an Instant parsed from an ISO 8601 string with microseconds */ + $instant = Instant::fromString(value: '2026-02-17T10:30:00.123456+00:00'); + + /** @When formatting with millisecond precision */ + $iso = $instant->toIso8601(precision: Precision::Milliseconds); + + /** @Then the output should truncate to three fractional digits */ + self::assertSame('2026-02-17T10:30:00.123+00:00', $iso); + } + + public function testToIso8601DefaultPrecisionIsSeconds(): void + { + /** @Given an Instant created from a database string with microseconds */ + $instant = Instant::fromString(value: '2026-02-17 08:27:21.106011'); + + /** @When formatting without specifying a precision */ + $iso = $instant->toIso8601(); + + /** @Then the output should match the seconds-only format */ + self::assertSame('2026-02-17T08:27:21+00:00', $iso); + } + + public function testToIso8601WithMicrosecondsPrecision(): void + { + /** @Given an Instant created from a database string with known microseconds */ + $instant = Instant::fromString(value: '2026-02-17 08:27:21.106011'); + + /** @When formatting with microsecond precision */ + $iso = $instant->toIso8601(precision: Precision::Microseconds); + + /** @Then the output should include all six fractional digits */ + self::assertSame('2026-02-17T08:27:21.106011+00:00', $iso); + } + + public function testToIso8601WithMillisecondsPrecision(): void + { + /** @Given an Instant created from a database string with known microseconds */ + $instant = Instant::fromString(value: '2026-02-17 08:27:21.106011'); + + /** @When formatting with millisecond precision */ + $iso = $instant->toIso8601(precision: Precision::Milliseconds); + + /** @Then the output should include exactly three fractional digits */ + self::assertSame('2026-02-17T08:27:21.106+00:00', $iso); + } + + public function testToIso8601MicrosecondsPrecisionZeroMicros(): void + { + /** @Given an Instant created from Unix seconds (no sub-second precision) */ + $instant = Instant::fromUnixSeconds(seconds: 1771324200); + + /** @When formatting with microsecond precision */ + $iso = $instant->toIso8601(precision: Precision::Microseconds); + + /** @Then the output should contain six zero fractional digits */ + self::assertSame('2026-02-17T10:30:00.000000+00:00', $iso); + } + + public function testToIso8601MillisecondsPrecisionZeroMicros(): void + { + /** @Given an Instant created from Unix seconds (no sub-second precision) */ + $instant = Instant::fromUnixSeconds(seconds: 1771324200); + + /** @When formatting with millisecond precision */ + $iso = $instant->toIso8601(precision: Precision::Milliseconds); + + /** @Then the output should contain three zero fractional digits */ + self::assertSame('2026-02-17T10:30:00.000+00:00', $iso); + } + + public function testFromStringIso8601WithFractionalSecondsIsInUtc(): void + { + /** @Given an ISO 8601 string with microseconds and a UTC offset */ + $instant = Instant::fromString(value: '2026-05-23T12:55:10.272097+00:00'); + + /** @When converting to DateTimeImmutable */ + $dateTime = $instant->toDateTimeImmutable(); + + /** @Then the timezone should be UTC */ + self::assertSame('UTC', $dateTime->getTimezone()->getName()); + } + + public function testFromStringIso8601WithFractionalSecondsNormalizesOffset(): void + { + /** @Given an ISO 8601 string with microseconds and a non-UTC offset */ + $instant = Instant::fromString(value: '2026-02-17T13:30:00.500000-03:00'); + + /** @When formatting as ISO 8601 */ + $iso = $instant->toIso8601(); + + /** @Then the output should be normalized to UTC without fractions */ + self::assertSame('2026-02-17T16:30:00+00:00', $iso); + } + + public function testFromStringIso8601WithFractionalSecondsPreservesMicroseconds(): void + { + /** @Given an ISO 8601 string with full microsecond precision */ + $instant = Instant::fromString(value: '2026-05-23T12:55:10.272097+00:00'); + + /** @When accessing the underlying DateTimeImmutable */ + $dateTime = $instant->toDateTimeImmutable(); + + /** @Then the microseconds should be preserved */ + self::assertSame('272097', $dateTime->format('u')); + } + public static function validStringsDataProvider(): array { return [ @@ -772,6 +909,21 @@ public static function validStringsDataProvider(): array 'value' => '2026-02-17T01:00:00-09:30', 'expectedIso8601' => '2026-02-17T10:30:00+00:00', 'expectedUnixSeconds' => 1771324200 + ], + 'UTC offset with microseconds' => [ + 'value' => '2026-02-17T10:30:00.123456+00:00', + 'expectedIso8601' => '2026-02-17T10:30:00+00:00', + 'expectedUnixSeconds' => 1771324200 + ], + 'UTC offset with short fraction' => [ + 'value' => '2026-02-17T10:30:00.272+00:00', + 'expectedIso8601' => '2026-02-17T10:30:00+00:00', + 'expectedUnixSeconds' => 1771324200 + ], + 'Positive offset with microseconds' => [ + 'value' => '2026-02-17T16:00:00.500000+05:30', + 'expectedIso8601' => '2026-02-17T10:30:00+00:00', + 'expectedUnixSeconds' => 1771324200 ] ]; } @@ -820,11 +972,12 @@ public static function invalidStringsDataProvider(): array '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'], - 'Database format with invalid day' => ['value' => '2026-02-30 08:27:21.106011'], - 'Database format with T separator' => ['value' => '2026-02-17T08:27:21.106011'], - 'Database format with invalid month' => ['value' => '2026-13-17 08:27:21.106011'] + 'Database format with invalid day' => ['value' => '2026-02-30 08:27:21.106011'], + 'Database format with T separator' => ['value' => '2026-02-17T08:27:21.106011'], + 'Database format with invalid month' => ['value' => '2026-13-17 08:27:21.106011'], + 'Fractional ISO 8601 with invalid day' => ['value' => '2026-02-30T10:30:00.000000+00:00'], + 'Fractional ISO 8601 with invalid month' => ['value' => '2026-13-17T10:30:00.123456+00:00'] ]; } @@ -861,4 +1014,40 @@ public static function validDatabaseStringsDataProvider(): array ] ]; } + + public static function precisionDataProvider(): array + { + return [ + 'Seconds precision, no fractions emitted' => [ + 'value' => '2026-02-17 08:27:21.106011', + 'precision' => Precision::Seconds, + 'expectedIso8601' => '2026-02-17T08:27:21+00:00' + ], + 'Microseconds precision, six-digit fraction' => [ + 'value' => '2026-02-17 08:27:21.106011', + 'precision' => Precision::Microseconds, + 'expectedIso8601' => '2026-02-17T08:27:21.106011+00:00' + ], + 'Milliseconds precision, three-digit fraction' => [ + 'value' => '2026-02-17 08:27:21.106011', + 'precision' => Precision::Milliseconds, + 'expectedIso8601' => '2026-02-17T08:27:21.106+00:00' + ], + 'Microseconds precision, three-digit input zero-padded' => [ + 'value' => '2026-02-17T10:30:00.272+00:00', + 'precision' => Precision::Microseconds, + 'expectedIso8601' => '2026-02-17T10:30:00.272000+00:00' + ], + 'Milliseconds precision, three-digit input round-trips' => [ + 'value' => '2026-02-17T10:30:00.272+00:00', + 'precision' => Precision::Milliseconds, + 'expectedIso8601' => '2026-02-17T10:30:00.272+00:00' + ], + 'Seconds precision strips fractions from ISO 8601 input' => [ + 'value' => '2026-02-17T10:30:00.123456+00:00', + 'precision' => Precision::Seconds, + 'expectedIso8601' => '2026-02-17T10:30:00+00:00' + ] + ]; + } } From b154934d62a1e2814f9ae0368a205688ef047aa0 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 23 May 2026 14:11:21 -0300 Subject: [PATCH 5/6] feat: Add LocalDate value object. --- src/Exceptions/InvalidLocalDate.php | 24 ++ src/LocalDate.php | 208 +++++++++ tests/Unit/LocalDateTest.php | 631 ++++++++++++++++++++++++++++ 3 files changed, 863 insertions(+) create mode 100644 src/Exceptions/InvalidLocalDate.php create mode 100644 src/LocalDate.php create mode 100644 tests/Unit/LocalDateTest.php diff --git a/src/Exceptions/InvalidLocalDate.php b/src/Exceptions/InvalidLocalDate.php new file mode 100644 index 0000000..ae4cc5b --- /dev/null +++ b/src/Exceptions/InvalidLocalDate.php @@ -0,0 +1,24 @@ + is not a valid local date.'; + + return new InvalidLocalDate(message: sprintf($template, $value)); + } + + public static function becauseComponentsAreInvalid(int $year, int $month, int $day): InvalidLocalDate + { + $template = 'Year <%d>, month <%d>, and day <%d> do not form a valid calendar date.'; + + return new InvalidLocalDate(message: sprintf($template, $year, $month, $day)); + } +} diff --git a/src/LocalDate.php b/src/LocalDate.php new file mode 100644 index 0000000..12db003 --- /dev/null +++ b/src/LocalDate.php @@ -0,0 +1,208 @@ + self::MAX_YEAR || !checkdate($month, $day, $year)) { + throw InvalidLocalDate::becauseComponentsAreInvalid(year: $year, month: $month, day: $day); + } + + $template = '%04d-%02d-%02d'; + + return LocalDate::fromString(value: sprintf($template, $year, $month, $day)); + } + + /** + * Creates a LocalDate representing today in the given timezone. + * + * @param Timezone $zone The timezone used to determine the current civil date. + * @return LocalDate Today's local date in the given timezone. + */ + public static function today(Timezone $zone): LocalDate + { + $dateTime = new DateTimeImmutable(datetime: 'now', timezone: $zone->toDateTimeZone()); + + return LocalDate::fromString(value: $dateTime->format(self::DATE_FORMAT)); + } + + /** + * Creates a LocalDate by parsing an ISO 8601 date string (YYYY-MM-DD). + * + * @param string $value A date string in YYYY-MM-DD format (e.g. "2026-05-23"). + * @return LocalDate The created local date. + * @throws InvalidLocalDate If the string is malformed or encodes an invalid date. + */ + public static function fromString(string $value): LocalDate + { + if (preg_match(self::DATE_PATTERN, $value) !== 1) { + throw InvalidLocalDate::becauseValueIsInvalid(value: $value); + } + + $utc = Timezone::utc()->toDateTimeZone(); + $parsed = DateTimeImmutable::createFromFormat(self::DATE_FORMAT, $value, $utc); + + if ($parsed === false || DateTimeImmutable::getLastErrors() !== false) { + throw InvalidLocalDate::becauseValueIsInvalid(value: $value); + } + + return new LocalDate(date: $parsed); + } + + /** + * Returns the year. + * + * @return int The four-digit calendar year. + */ + public function year(): int + { + return (int)$this->date->format('Y'); + } + + /** + * Returns the month of the year. + * + * @return int The month (1–12). + */ + public function month(): int + { + return (int)$this->date->format('n'); + } + + /** + * Tells whether this date is strictly after another. + * + * @param LocalDate $other The date to compare against. + * @return bool True if this date follows the other. + */ + public function isAfter(LocalDate $other): bool + { + return $this->date > $other->date; + } + + /** + * Tells whether this date is strictly before another. + * + * @param LocalDate $other The date to compare against. + * @return bool True if this date precedes the other. + */ + public function isBefore(LocalDate $other): bool + { + return $this->date < $other->date; + } + + /** + * Returns a copy of this date shifted backward by the given number of days. + * A negative count shifts forward. + * + * @param int $days The number of days to subtract (may be negative). + * @return LocalDate A new LocalDate shifted backward by the given count. + */ + public function minusDays(int $days): LocalDate + { + $template = '%+d days'; + $modified = $this->date->modify(sprintf($template, -$days)); + + return new LocalDate(date: $modified); + } + + /** + * Returns a copy of this date shifted forward by the given number of days. + * A negative count shifts backward. + * + * @param int $days The number of days to add (may be negative). + * @return LocalDate A new LocalDate shifted forward by the given count. + */ + public function plusDays(int $days): LocalDate + { + $template = '%+d days'; + $modified = $this->date->modify(sprintf($template, $days)); + + return new LocalDate(date: $modified); + } + + /** + * Returns the day of the week for this date. + * + * @return DayOfWeek The day of the week (Monday through Sunday). + */ + public function dayOfWeek(): DayOfWeek + { + return DayOfWeek::from((int)$this->date->format('N')); + } + + /** + * Returns the LocalDate as an ISO 8601 date string (YYYY-MM-DD). + * + * @return string The date in YYYY-MM-DD format (e.g. "2026-05-23"). + */ + public function toIso8601(): string + { + return $this->date->format(self::DATE_FORMAT); + } + + /** + * Returns the day of the month. + * + * @return int The day (1–31). + */ + public function dayOfMonth(): int + { + return (int)$this->date->format('j'); + } + + /** + * Tells whether this date is after or equal to another. + * + * @param LocalDate $other The date to compare against. + * @return bool True if this date is at or after the other. + */ + public function isAfterOrEqual(LocalDate $other): bool + { + return $this->date >= $other->date; + } + + /** + * Tells whether this date is before or equal to another. + * + * @param LocalDate $other The date to compare against. + * @return bool True if this date is at or before the other. + */ + public function isBeforeOrEqual(LocalDate $other): bool + { + return $this->date <= $other->date; + } +} diff --git a/tests/Unit/LocalDateTest.php b/tests/Unit/LocalDateTest.php new file mode 100644 index 0000000..a6fbe07 --- /dev/null +++ b/tests/Unit/LocalDateTest.php @@ -0,0 +1,631 @@ +dayOfMonth()); + self::assertSame(5, $date->month()); + self::assertSame(2026, $date->year()); + self::assertSame('2026-05-23', $date->toIso8601()); + } + + public function testLocalDateOfWhenYearAtMinimumBoundaryThenDateIsCreated(): void + { + /** @When creating a LocalDate with year 1 (minimum) */ + $date = LocalDate::of(year: 1, month: 1, day: 1); + + /** @Then the date is created successfully */ + self::assertSame('0001-01-01', $date->toIso8601()); + } + + public function testLocalDateOfWhenYearAtMaximumBoundaryThenDateIsCreated(): void + { + /** @When creating a LocalDate with year 9999 (maximum) */ + $date = LocalDate::of(year: 9999, month: 12, day: 31); + + /** @Then the date is created successfully */ + self::assertSame('9999-12-31', $date->toIso8601()); + } + + public function testLocalDateOfWhenLeapDayOnLeapYearThenDateIsCreated(): void + { + /** @When creating a LocalDate for February 29 on a leap year */ + $date = LocalDate::of(year: 2024, month: 2, day: 29); + + /** @Then the date is created successfully */ + self::assertSame('2024-02-29', $date->toIso8601()); + } + + public function testLocalDateOfWhenDayZeroThenInvalidLocalDate(): void + { + /** @Then an exception indicating invalid components should be thrown */ + $this->expectException(InvalidLocalDate::class); + $this->expectExceptionMessage('Year <2026>, month <1>, and day <0> do not form a valid calendar date.'); + + /** @When trying to create a LocalDate with day zero */ + LocalDate::of(year: 2026, month: 1, day: 0); + } + + public function testLocalDateOfWhenYearZeroThenInvalidLocalDate(): void + { + /** @Then an exception indicating invalid components should be thrown */ + $this->expectException(InvalidLocalDate::class); + $this->expectExceptionMessage('Year <0>, month <1>, and day <1> do not form a valid calendar date.'); + + /** @When trying to create a LocalDate with year zero */ + LocalDate::of(year: 0, month: 1, day: 1); + } + + public function testLocalDateOfWhenDayAboveMaxThenInvalidLocalDate(): void + { + /** @Then an exception indicating invalid components should be thrown */ + $this->expectException(InvalidLocalDate::class); + $this->expectExceptionMessage('Year <2026>, month <1>, and day <32> do not form a valid calendar date.'); + + /** @When trying to create a LocalDate with day 32 */ + LocalDate::of(year: 2026, month: 1, day: 32); + } + + public function testLocalDateOfWhenMonthZeroThenInvalidLocalDate(): void + { + /** @Then an exception indicating invalid components should be thrown */ + $this->expectException(InvalidLocalDate::class); + $this->expectExceptionMessage('Year <2026>, month <0>, and day <1> do not form a valid calendar date.'); + + /** @When trying to create a LocalDate with month zero */ + LocalDate::of(year: 2026, month: 0, day: 1); + } + + public function testLocalDateOfWhenMonthAboveRangeThenInvalidLocalDate(): void + { + /** @Then an exception indicating invalid components should be thrown */ + $this->expectException(InvalidLocalDate::class); + $this->expectExceptionMessage('Year <2026>, month <13>, and day <1> do not form a valid calendar date.'); + + /** @When trying to create a LocalDate with month 13 */ + LocalDate::of(year: 2026, month: 13, day: 1); + } + + public function testLocalDateOfWhenLeapDayOnNonLeapYearThenInvalidLocalDate(): void + { + /** @Then an exception indicating invalid components should be thrown */ + $this->expectException(InvalidLocalDate::class); + $this->expectExceptionMessage('Year <2026>, month <2>, and day <29> do not form a valid calendar date.'); + + /** @When trying to create February 29 on a non-leap year */ + LocalDate::of(year: 2026, month: 2, day: 29); + } + + public function testLocalDateOfWhenInvalidDayForMonthThenInvalidLocalDate(): void + { + /** @Then an exception indicating invalid components should be thrown */ + $this->expectException(InvalidLocalDate::class); + $this->expectExceptionMessage('Year <2026>, month <4>, and day <31> do not form a valid calendar date.'); + + /** @When trying to create April 31 (April has 30 days) */ + LocalDate::of(year: 2026, month: 4, day: 31); + } + + public function testLocalDateOfWhenYearAboveLimitThenInvalidLocalDate(): void + { + /** @Then an exception indicating invalid components should be thrown */ + $this->expectException(InvalidLocalDate::class); + $this->expectExceptionMessage('Year <10000>, month <1>, and day <1> do not form a valid calendar date.'); + + /** @When trying to create a LocalDate with year 10000 (above 4-digit limit) */ + LocalDate::of(year: 10000, month: 1, day: 1); + } + + public function testLocalDateFromStringWhenValidIso8601ThenDateIsCreated(): void + { + /** @When creating a LocalDate from a valid ISO 8601 date string */ + $date = LocalDate::fromString(value: '2026-05-23'); + + /** @Then the accessors reflect the parsed date */ + self::assertSame(23, $date->dayOfMonth()); + self::assertSame(5, $date->month()); + self::assertSame(2026, $date->year()); + self::assertSame('2026-05-23', $date->toIso8601()); + } + + #[DataProvider('invalidStringsDataProvider')] + public function testLocalDateFromStringWhenInvalidValueThenInvalidLocalDate(string $value): void + { + /** @Then an exception indicating an invalid local date value should be thrown */ + $this->expectException(InvalidLocalDate::class); + $this->expectExceptionMessage(sprintf('The value <%s> is not a valid local date.', $value)); + + /** @When trying to create a LocalDate from the invalid string */ + LocalDate::fromString(value: $value); + } + + public function testLocalDateTodayWhenUtcThenDateIsWithinCurrentDay(): void + { + /** @Given the current UTC date before calling today */ + $before = (new DateTimeImmutable(datetime: 'now', timezone: new DateTimeZone('UTC')))->format('Y-m-d'); + + /** @When getting today's local date in UTC */ + $today = LocalDate::today(zone: Timezone::utc()); + + /** @And the current UTC date after calling today */ + $after = (new DateTimeImmutable(datetime: 'now', timezone: new DateTimeZone('UTC')))->format('Y-m-d'); + + /** @Then today's date falls within the before/after bracket */ + self::assertGreaterThanOrEqual($before, $today->toIso8601()); + self::assertLessThanOrEqual($after, $today->toIso8601()); + } + + public function testLocalDateDayOfWeekWhenKnownDateThenReturnsCorrectDay(): void + { + /** @Given a LocalDate known to be a Saturday */ + $date = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @When retrieving the day of the week */ + $dayOfWeek = $date->dayOfWeek(); + + /** @Then the day of the week is Saturday */ + self::assertSame(DayOfWeek::Saturday, $dayOfWeek); + } + + public function testLocalDateDayOfWeekWhenMondayThenReturnsMonday(): void + { + /** @Given a LocalDate known to be a Monday */ + $date = LocalDate::of(year: 2026, month: 5, day: 25); + + /** @When retrieving the day of the week */ + $dayOfWeek = $date->dayOfWeek(); + + /** @Then the day of the week is Monday */ + self::assertSame(DayOfWeek::Monday, $dayOfWeek); + } + + public function testLocalDateToIso8601WhenValidDateThenReturnsCorrectString(): void + { + /** @Given a LocalDate for a known date */ + $date = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @When converting to ISO 8601 */ + $iso = $date->toIso8601(); + + /** @Then the output matches YYYY-MM-DD format */ + self::assertSame('2026-05-23', $iso); + } + + public function testLocalDateToIso8601WhenLeapDayThenReturnsCorrectString(): void + { + /** @Given a LocalDate for a leap day */ + $date = LocalDate::of(year: 2024, month: 2, day: 29); + + /** @When converting to ISO 8601 */ + $iso = $date->toIso8601(); + + /** @Then the output is the leap day string */ + self::assertSame('2024-02-29', $iso); + } + + public function testLocalDateToIso8601WhenYearBoundaryThenReturnsCorrectString(): void + { + /** @Given a LocalDate for the first day of a new year */ + $date = LocalDate::of(year: 2027, month: 1, day: 1); + + /** @When converting to ISO 8601 */ + $iso = $date->toIso8601(); + + /** @Then the output reflects the new year */ + self::assertSame('2027-01-01', $iso); + } + + public function testLocalDateFromStringRoundTripsWithToIso8601(): void + { + /** @Given a date string in ISO 8601 format */ + $value = '2026-05-23'; + + /** @When parsing and then formatting */ + $iso = LocalDate::fromString(value: $value)->toIso8601(); + + /** @Then the output is identical to the input */ + self::assertSame($value, $iso); + } + + public function testLocalDateIsAfterWhenAfterThenTrue(): void + { + /** @Given a later date */ + $later = LocalDate::of(year: 2026, month: 5, day: 24); + + /** @And an earlier date */ + $earlier = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @Then the later date is after the earlier */ + self::assertTrue($later->isAfter(other: $earlier)); + } + + public function testLocalDateIsAfterWhenEqualThenFalse(): void + { + /** @Given two dates representing the same day */ + $first = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @And another date representing the same day */ + $second = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @Then isAfter returns false for equal dates */ + self::assertFalse($first->isAfter(other: $second)); + } + + public function testLocalDateIsAfterWhenBeforeThenFalse(): void + { + /** @Given an earlier date */ + $earlier = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @And a later date */ + $later = LocalDate::of(year: 2026, month: 5, day: 24); + + /** @Then the earlier date is not after the later */ + self::assertFalse($earlier->isAfter(other: $later)); + } + + public function testLocalDateIsBeforeWhenBeforeThenTrue(): void + { + /** @Given an earlier date */ + $earlier = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @And a later date */ + $later = LocalDate::of(year: 2026, month: 5, day: 24); + + /** @Then the earlier date is before the later */ + self::assertTrue($earlier->isBefore(other: $later)); + } + + public function testLocalDateIsBeforeWhenEqualThenFalse(): void + { + /** @Given two dates representing the same day */ + $first = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @And another date representing the same day */ + $second = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @Then isBefore returns false for equal dates */ + self::assertFalse($first->isBefore(other: $second)); + } + + public function testLocalDateIsBeforeWhenAfterThenFalse(): void + { + /** @Given a later date */ + $later = LocalDate::of(year: 2026, month: 5, day: 24); + + /** @And an earlier date */ + $earlier = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @Then the later date is not before the earlier */ + self::assertFalse($later->isBefore(other: $earlier)); + } + + public function testLocalDateIsAfterOrEqualWhenAfterThenTrue(): void + { + /** @Given a later date */ + $later = LocalDate::of(year: 2026, month: 5, day: 24); + + /** @And an earlier date */ + $earlier = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @Then the later date is after or equal to the earlier */ + self::assertTrue($later->isAfterOrEqual(other: $earlier)); + } + + public function testLocalDateIsAfterOrEqualWhenEqualThenTrue(): void + { + /** @Given two dates representing the same day */ + $first = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @And another date representing the same day */ + $second = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @Then isAfterOrEqual returns true for equal dates */ + self::assertTrue($first->isAfterOrEqual(other: $second)); + } + + public function testLocalDateIsAfterOrEqualWhenBeforeThenFalse(): void + { + /** @Given an earlier date */ + $earlier = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @And a later date */ + $later = LocalDate::of(year: 2026, month: 5, day: 24); + + /** @Then the earlier date is not after or equal to the later */ + self::assertFalse($earlier->isAfterOrEqual(other: $later)); + } + + public function testLocalDateIsBeforeOrEqualWhenBeforeThenTrue(): void + { + /** @Given an earlier date */ + $earlier = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @And a later date */ + $later = LocalDate::of(year: 2026, month: 5, day: 24); + + /** @Then the earlier date is before or equal to the later */ + self::assertTrue($earlier->isBeforeOrEqual(other: $later)); + } + + public function testLocalDateIsBeforeOrEqualWhenEqualThenTrue(): void + { + /** @Given two dates representing the same day */ + $first = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @And another date representing the same day */ + $second = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @Then isBeforeOrEqual returns true for equal dates */ + self::assertTrue($first->isBeforeOrEqual(other: $second)); + } + + public function testLocalDateIsBeforeOrEqualWhenAfterThenFalse(): void + { + /** @Given a later date */ + $later = LocalDate::of(year: 2026, month: 5, day: 24); + + /** @And an earlier date */ + $earlier = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @Then the later date is not before or equal to the earlier */ + self::assertFalse($later->isBeforeOrEqual(other: $earlier)); + } + + public function testLocalDatePlusDaysWhenPositiveThenShiftsForward(): void + { + /** @Given a LocalDate */ + $date = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @When adding 7 days */ + $result = $date->plusDays(days: 7); + + /** @Then the result is 7 days later */ + self::assertSame('2026-05-30', $result->toIso8601()); + } + + public function testLocalDatePlusDaysWhenNegativeThenShiftsBackward(): void + { + /** @Given a LocalDate */ + $date = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @When adding -3 days (equivalent to subtracting 3) */ + $result = $date->plusDays(days: -3); + + /** @Then the result is 3 days earlier */ + self::assertSame('2026-05-20', $result->toIso8601()); + } + + public function testLocalDatePlusDaysWhenZeroThenReturnsSameDate(): void + { + /** @Given a LocalDate */ + $date = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @When adding zero days */ + $result = $date->plusDays(days: 0); + + /** @Then the result is the same date */ + self::assertSame('2026-05-23', $result->toIso8601()); + } + + public function testLocalDatePlusDaysWhenCrossesMonthBoundaryThenShiftsMonth(): void + { + /** @Given a date near the end of May */ + $date = LocalDate::of(year: 2026, month: 5, day: 29); + + /** @When adding 3 days */ + $result = $date->plusDays(days: 3); + + /** @Then the result crosses into June */ + self::assertSame('2026-06-01', $result->toIso8601()); + } + + public function testLocalDatePlusDaysWhenCrossesYearBoundaryThenShiftsYear(): void + { + /** @Given the last day of the year */ + $date = LocalDate::of(year: 2026, month: 12, day: 31); + + /** @When adding 1 day */ + $result = $date->plusDays(days: 1); + + /** @Then the result is the first day of the following year */ + self::assertSame('2027-01-01', $result->toIso8601()); + } + + public function testLocalDatePlusDaysWhenCrossesLeapDayThenCountsCorrectly(): void + { + /** @Given February 28 of a leap year */ + $date = LocalDate::of(year: 2024, month: 2, day: 28); + + /** @When adding 1 day */ + $result = $date->plusDays(days: 1); + + /** @Then the result is the leap day */ + self::assertSame('2024-02-29', $result->toIso8601()); + } + + public function testLocalDateMinusDaysWhenPositiveThenShiftsBackward(): void + { + /** @Given a LocalDate */ + $date = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @When subtracting 7 days */ + $result = $date->minusDays(days: 7); + + /** @Then the result is 7 days earlier */ + self::assertSame('2026-05-16', $result->toIso8601()); + } + + public function testLocalDateMinusDaysWhenNegativeThenShiftsForward(): void + { + /** @Given a LocalDate */ + $date = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @When subtracting -3 days (equivalent to adding 3) */ + $result = $date->minusDays(days: -3); + + /** @Then the result is 3 days later */ + self::assertSame('2026-05-26', $result->toIso8601()); + } + + public function testLocalDateMinusDaysWhenZeroThenReturnsSameDate(): void + { + /** @Given a LocalDate */ + $date = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @When subtracting zero days */ + $result = $date->minusDays(days: 0); + + /** @Then the result is the same date */ + self::assertSame('2026-05-23', $result->toIso8601()); + } + + public function testLocalDateMinusDaysWhenCrossesMonthBoundaryThenShiftsMonth(): void + { + /** @Given the first day of June */ + $date = LocalDate::of(year: 2026, month: 6, day: 1); + + /** @When subtracting 3 days */ + $result = $date->minusDays(days: 3); + + /** @Then the result crosses back into May */ + self::assertSame('2026-05-29', $result->toIso8601()); + } + + public function testLocalDateMinusDaysWhenCrossesYearBoundaryThenShiftsYear(): void + { + /** @Given the first day of the year */ + $date = LocalDate::of(year: 2027, month: 1, day: 1); + + /** @When subtracting 1 day */ + $result = $date->minusDays(days: 1); + + /** @Then the result is the last day of the previous year */ + self::assertSame('2026-12-31', $result->toIso8601()); + } + + public function testLocalDatePlusDaysAndMinusDaysAreInverse(): void + { + /** @Given a LocalDate */ + $date = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @When adding 10 days and then subtracting 10 days */ + $result = $date->plusDays(days: 10)->minusDays(days: 10); + + /** @Then the result is the original date */ + self::assertSame($date->toIso8601(), $result->toIso8601()); + } + + public function testLocalDateMinusDaysNegativeEquivalentToPlusDaysPositive(): void + { + /** @Given a LocalDate */ + $date = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @When adding positive days */ + $viaPlus = $date->plusDays(days: 5); + + /** @And subtracting negative days (same magnitude) */ + $viaMinus = $date->minusDays(days: -5); + + /** @Then both produce the same date */ + self::assertSame($viaPlus->toIso8601(), $viaMinus->toIso8601()); + } + + public function testLocalDatePlusDaysNegativeEquivalentToMinusDaysPositive(): void + { + /** @Given a LocalDate */ + $date = LocalDate::of(year: 2026, month: 5, day: 23); + + /** @When subtracting positive days */ + $viaMinus = $date->minusDays(days: 5); + + /** @And adding negative days (same magnitude) */ + $viaPlus = $date->plusDays(days: -5); + + /** @Then both produce the same date */ + self::assertSame($viaMinus->toIso8601(), $viaPlus->toIso8601()); + } + + public function testInstantToLocalDateWhenUtcMidnightThenSameDate(): void + { + /** @Given an Instant at midnight UTC on a known date */ + $instant = Instant::fromString(value: '2026-05-23T00:00:00+00:00'); + + /** @When projecting into UTC */ + $localDate = $instant->toLocalDate(zone: Timezone::utc()); + + /** @Then the local date matches the UTC date */ + self::assertSame('2026-05-23', $localDate->toIso8601()); + } + + public function testInstantToLocalDateWhenJustPastMidnightUtcInWestZoneThenPreviousDate(): void + { + /** @Given an Instant at 00:30 UTC on 2026-02-18 */ + $instant = Instant::fromString(value: '2026-02-18T00:30:00+00:00'); + + /** @When projecting into America/New_York (UTC-5 in February) */ + $localDate = $instant->toLocalDate(zone: Timezone::from(identifier: 'America/New_York')); + + /** @Then the local date is the previous day relative to UTC */ + self::assertSame('2026-02-17', $localDate->toIso8601()); + } + + public function testInstantToLocalDateWhenEastZoneIsAheadThenNextDate(): void + { + /** @Given an Instant at 23:30 UTC on 2026-02-17 */ + $instant = Instant::fromString(value: '2026-02-17T23:30:00+00:00'); + + /** @When projecting into Asia/Tokyo (UTC+9) */ + $localDate = $instant->toLocalDate(zone: Timezone::from(identifier: 'Asia/Tokyo')); + + /** @Then the local date is the following day relative to UTC */ + self::assertSame('2026-02-18', $localDate->toIso8601()); + } + + public function testInstantToLocalDateWhenUtcAndLocalDateRoundTrips(): void + { + /** @Given an Instant at a known UTC moment */ + $instant = Instant::fromString(value: '2026-05-23T12:00:00+00:00'); + + /** @When projecting into UTC */ + $localDate = $instant->toLocalDate(zone: Timezone::utc()); + + /** @Then the ISO 8601 string can be parsed back to the same date */ + self::assertSame($localDate->toIso8601(), LocalDate::fromString(value: $localDate->toIso8601())->toIso8601()); + } + + public static function invalidStringsDataProvider(): array + { + return [ + 'Empty string' => ['value' => ''], + 'Plain text' => ['value' => 'garbage'], + 'Invalid month' => ['value' => '2026-13-01'], + 'Invalid day for month' => ['value' => '2026-02-30'], + 'Wrong separator' => ['value' => '2026/05/23'], + 'Missing separators' => ['value' => '20260523'], + 'Single-digit month' => ['value' => '2026-5-23'], + 'Single-digit day' => ['value' => '2026-05-3'], + 'Full datetime string' => ['value' => '2026-05-23T00:00:00+00:00'], + 'Date with trailing whitespace' => ['value' => '2026-05-23 '], + 'Date with leading whitespace' => ['value' => ' 2026-05-23'] + ]; + } +} From 961c56fe71d06fdc08178e5e384406052219233f Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 23 May 2026 14:11:23 -0300 Subject: [PATCH 6/6] docs: Document LocalDate and sub-second Instant precision in README. --- README.md | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 134 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 32d81aa..083d4b9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Time -[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/tiny-blocks/time/blob/main/LICENSE) * [Overview](#overview) * [Installation](#installation) @@ -13,6 +13,7 @@ - [Adding and subtracting time](#adding-and-subtracting-time) - [Measuring distance between instants](#measuring-distance-between-instants) - [Comparing instants](#comparing-instants) + - [Emitting with sub-second precision](#emitting-with-sub-second-precision) + [Duration](#duration) - [Creating durations](#creating-durations) - [Arithmetic](#arithmetic) @@ -37,6 +38,13 @@ - [Comparing times](#comparing-times) - [Measuring distance between times](#measuring-distance-between-times) - [Converting to other representations](#converting-to-other-representations) + + [LocalDate](#localdate) + - [Creating from components](#creating-from-components-1) + - [Creating from a string](#creating-from-a-string-2) + - [Today in a timezone](#today-in-a-timezone) + - [Projecting an Instant](#projecting-an-instant) + - [Comparing dates](#comparing-dates) + - [Day arithmetic](#day-arithmetic) + [Timezone](#timezone) - [Creating from an identifier](#creating-from-an-identifier) - [Creating a UTC timezone](#creating-a-utc-timezone) @@ -54,8 +62,8 @@ ## Overview -Models time as immutable value objects for PHP, including instants, durations, periods, timezones, time-of-day, and -day-of-week. All instants are normalized to UTC with microsecond precision, with strict parsing, formatting, and +Models time as immutable value objects for PHP, including instants, durations, periods, timezones, time-of-day, +local dates, and day-of-week. All instants are normalized to UTC with microsecond precision, with strict parsing, formatting, and arithmetic operations. Declared as `final readonly class` for language-level immutability, with structural equality provided by the tiny-blocks value-object contract. @@ -226,6 +234,27 @@ $later->isAfter(other: $earlier); # true $later->isAfterOrEqual(other: $earlier); # true ``` +#### Emitting with sub-second precision + +By default `toIso8601()` emits seconds only. Pass a `Precision` value to include fractional +seconds in the output. Existing callers that omit the argument are unaffected. + +```php +toIso8601(); # 2026-05-23T12:55:10+00:00 +$instant->toIso8601(precision: Precision::Seconds); # 2026-05-23T12:55:10+00:00 +$instant->toIso8601(precision: Precision::Microseconds); # 2026-05-23T12:55:10.272097+00:00 +$instant->toIso8601(precision: Precision::Milliseconds); # 2026-05-23T12:55:10.272+00:00 +``` + ### Duration A `Duration` represents an immutable, unsigned quantity of time measured in seconds. It has no reference point on the @@ -648,6 +677,108 @@ $time->toDuration()->toSeconds(); # 30600 $time->toString(); # 08:30 ``` +### LocalDate + +A `LocalDate` is a value object representing a calendar date (year, month, day) without time and without timezone. +Dates are always in the proleptic Gregorian calendar and restricted to the range `0001–9999`. + +#### Creating from components + +```php +year(); # 2026 +$date->month(); # 5 +$date->dayOfMonth(); # 23 +$date->toIso8601(); # 2026-05-23 +``` + +#### Creating from a string + +Accepts only the canonical ISO 8601 date format `YYYY-MM-DD`. Any other format raises `InvalidLocalDate`. + +```php +toIso8601(); # 2026-05-23 +``` + +#### Today in a timezone + +```php +toIso8601(); # 2026-05-23 +``` + +#### Projecting an Instant + +```php +toLocalDate(zone: Timezone::utc()); + +$date->toIso8601(); # 2026-05-23 +``` + +#### Comparing dates + +```php +isBefore(other: $later); # true +$earlier->isBeforeOrEqual(other: $later); # true +$later->isAfter(other: $earlier); # true +$later->isAfterOrEqual(other: $earlier); # true +``` + +#### Day arithmetic + +```php +plusDays(days: 10)->toIso8601(); # 2026-06-02 +$date->minusDays(days: 30)->toIso8601(); # 2026-04-23 +``` + ### Timezone A `Timezone` is a value object representing a single valid [IANA timezone](https://www.iana.org) identifier.