diff --git a/src/Chronos.php b/src/Chronos.php index a74bfb6..ada7963 100644 --- a/src/Chronos.php +++ b/src/Chronos.php @@ -660,9 +660,96 @@ public static function createFromFormat( throw new InvalidArgumentException($message); } + $testNow = static::getTestNow(); + if ($testNow !== null) { + $dateTime = static::applyTestNowToMissingComponents($dateTime, $format, $testNow); + } + return $dateTime; } + /** + * Apply testNow values to date/time components that weren't in the format string. + * + * @param static $dateTime The parsed datetime instance. + * @param string $format The format string used for parsing. + * @param \Cake\Chronos\Chronos $testNow The test now instance. + * @return static + */ + protected static function applyTestNowToMissingComponents( + self $dateTime, + string $format, + Chronos $testNow, + ): static { + // Parse format string to find which characters are actual format specifiers (not escaped) + $formatChars = static::getFormatCharacters($format); + + // Check which components are present in the format + $hasYear = (bool)array_intersect($formatChars, ['Y', 'y', 'o', 'X', 'x']); + $hasMonth = (bool)array_intersect($formatChars, ['m', 'n', 'M', 'F']); + $hasDay = (bool)array_intersect($formatChars, ['d', 'j', 'D', 'l', 'N', 'z', 'w', 'W', 'S']); + $hasHour = (bool)array_intersect($formatChars, ['H', 'G', 'h', 'g']); + $hasMinute = (bool)array_intersect($formatChars, ['i']); + $hasSecond = (bool)array_intersect($formatChars, ['s']); + $hasMicro = (bool)array_intersect($formatChars, ['u', 'v']); + + // If the format includes '!' or '|', PHP resets unspecified components to Unix epoch or zero + // In that case, we should not override with testNow + $hasReset = in_array('!', $formatChars, true) || in_array('|', $formatChars, true); + if ($hasReset) { + return $dateTime; + } + + // Replace missing components with testNow values + $year = $hasYear ? $dateTime->year : $testNow->year; + $month = $hasMonth ? $dateTime->month : $testNow->month; + $day = $hasDay ? $dateTime->day : $testNow->day; + $hour = $hasHour ? $dateTime->hour : $testNow->hour; + $minute = $hasMinute ? $dateTime->minute : $testNow->minute; + $second = $hasSecond ? $dateTime->second : $testNow->second; + $micro = $hasMicro ? $dateTime->micro : $testNow->micro; + + // Only modify if something needs to change + if ( + !$hasYear || !$hasMonth || !$hasDay || + !$hasHour || !$hasMinute || !$hasSecond || !$hasMicro + ) { + return $dateTime + ->setDate($year, $month, $day) + ->setTime($hour, $minute, $second, $micro); + } + + return $dateTime; + } + + /** + * Extract format characters from a format string, handling escapes. + * + * @param string $format The format string. + * @return array Array of format characters. + */ + protected static function getFormatCharacters(string $format): array + { + $chars = []; + $length = strlen($format); + $i = 0; + + while ($i < $length) { + $char = $format[$i]; + + // Backslash escapes the next character + if ($char === '\\' && $i + 1 < $length) { + $i += 2; + continue; + } + + $chars[] = $char; + $i++; + } + + return $chars; + } + /** * Returns parse warnings and errors from the last ``createFromFormat()`` * call. diff --git a/tests/TestCase/DateTime/CreateFromFormatTest.php b/tests/TestCase/DateTime/CreateFromFormatTest.php index c4176ec..91318db 100644 --- a/tests/TestCase/DateTime/CreateFromFormatTest.php +++ b/tests/TestCase/DateTime/CreateFromFormatTest.php @@ -29,6 +29,88 @@ public function testCreateFromFormatReturnsInstance() $this->assertTrue($d instanceof Chronos); } + public function testCreateFromFormatWithTestNowMissingYear() + { + Chronos::setTestNow(new Chronos('2020-12-01 14:30:45')); + $d = Chronos::createFromFormat('m-d H:i:s', '10-05 09:15:30'); + $this->assertDateTime($d, 2020, 10, 5, 9, 15, 30); + } + + public function testCreateFromFormatWithTestNowMissingDate() + { + Chronos::setTestNow(new Chronos('2020-12-01 14:30:45')); + $d = Chronos::createFromFormat('H:i:s', '09:15:30'); + $this->assertDateTime($d, 2020, 12, 1, 9, 15, 30); + } + + public function testCreateFromFormatWithTestNowMissingTime() + { + Chronos::setTestNow(new Chronos('2020-12-01 14:30:45')); + $d = Chronos::createFromFormat('Y-m-d', '2021-06-15'); + $this->assertDateTime($d, 2021, 6, 15, 14, 30, 45); + } + + public function testCreateFromFormatWithTestNowPartialDate() + { + Chronos::setTestNow(new Chronos('2020-12-01 00:00:00')); + $d = Chronos::createFromFormat('m-d', '10-05'); + $this->assertDateTime($d, 2020, 10, 5, 0, 0, 0); + } + + public function testCreateFromFormatWithTestNowDayOnly() + { + Chronos::setTestNow(new Chronos('2020-12-01 00:00:00')); + $d = Chronos::createFromFormat('d', '05'); + $this->assertDateTime($d, 2020, 12, 5, 0, 0, 0); + } + + public function testCreateFromFormatWithTestNowComplete() + { + // When format is complete, testNow should not affect the result + Chronos::setTestNow(new Chronos('2020-12-01 14:30:45')); + $d = Chronos::createFromFormat('Y-m-d H:i:s', '1975-05-21 22:32:11'); + $this->assertDateTime($d, 1975, 5, 21, 22, 32, 11); + } + + public function testCreateFromFormatWithTestNowResetModifier() + { + // The '!' modifier resets to Unix epoch, should not use testNow + Chronos::setTestNow(new Chronos('2020-12-01 14:30:45')); + $d = Chronos::createFromFormat('!Y-m-d', '2021-06-15'); + $this->assertDateTime($d, 2021, 6, 15, 0, 0, 0); + } + + public function testCreateFromFormatWithTestNowPipeModifier() + { + // The '|' modifier resets unspecified components to zero, should not use testNow + Chronos::setTestNow(new Chronos('2020-12-01 14:30:45')); + $d = Chronos::createFromFormat('Y-m-d|', '2021-06-15'); + $this->assertDateTime($d, 2021, 6, 15, 0, 0, 0); + } + + public function testCreateFromFormatWithoutTestNow() + { + // Without testNow set, behavior should use real current time for missing components + Chronos::setTestNow(null); + $d = Chronos::createFromFormat('Y-m-d H:i:s', '1975-05-21 22:32:11'); + $this->assertDateTime($d, 1975, 5, 21, 22, 32, 11); + } + + public function testCreateFromFormatWithTestNowEscapedCharacters() + { + // Escaped format characters should not be treated as format specifiers + Chronos::setTestNow(new Chronos('2020-12-01 14:30:45')); + $d = Chronos::createFromFormat('\Y\-m-d', 'Y-10-05'); + $this->assertDateTime($d, 2020, 10, 5, 14, 30, 45); + } + + public function testCreateFromFormatWithTestNowMicroseconds() + { + Chronos::setTestNow(new Chronos('2020-12-01 14:30:45.123456')); + $d = Chronos::createFromFormat('Y-m-d H:i:s', '2021-06-15 09:15:30'); + $this->assertSame(123456, $d->micro); + } + public function testCreateFromFormatWithTimezoneString() { $d = Chronos::createFromFormat('Y-m-d H:i:s', '1975-05-21 22:32:11', 'Europe/London');