Skip to content

Commit ebde951

Browse files
committed
Merge remote-tracking branch 'origin/3.x' into 3.next
2 parents 4a83d99 + e425685 commit ebde951

7 files changed

Lines changed: 363 additions & 3 deletions

File tree

CONTRIBUTING.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Contributing to Chronos
2+
3+
Thank you for your interest in contributing to Chronos! This document provides guidelines and instructions for contributing.
4+
5+
## Getting Started
6+
7+
1. Fork the repository
8+
2. Clone your fork:
9+
```bash
10+
git clone https://github.com/YOUR_USERNAME/chronos.git
11+
cd chronos
12+
```
13+
3. Install dependencies:
14+
```bash
15+
composer install
16+
```
17+
18+
## Running Tests
19+
20+
Run the test suite using PHPUnit:
21+
22+
```bash
23+
composer test
24+
```
25+
26+
Or directly:
27+
28+
```bash
29+
vendor/bin/phpunit
30+
```
31+
32+
## Code Style
33+
34+
This project follows the [CakePHP coding standards](https://book.cakephp.org/5/en/contributing/cakephp-coding-conventions.html).
35+
36+
Check for coding standard violations:
37+
38+
```bash
39+
composer cs-check
40+
```
41+
42+
Automatically fix coding standard violations:
43+
44+
```bash
45+
composer cs-fix
46+
```
47+
48+
## Static Analysis
49+
50+
PHPStan is used for static analysis. First, install the tools:
51+
52+
```bash
53+
composer stan-setup
54+
```
55+
56+
Then run the analysis:
57+
58+
```bash
59+
composer stan
60+
```
61+
62+
## Running All Checks
63+
64+
To run tests, coding standards, and static analysis together:
65+
66+
```bash
67+
composer check
68+
```
69+
70+
## Submitting Changes
71+
72+
1. Create a new branch for your changes
73+
2. Make your changes and commit them with clear, descriptive messages
74+
3. Ensure all checks pass (`composer check`)
75+
4. Push to your fork and submit a pull request
76+
77+
## Reporting Issues
78+
79+
- Search existing issues before creating a new one
80+
- Include PHP version, Chronos version, and a minimal reproduction case
81+
- Use clear, descriptive titles
82+
83+
## Documentation
84+
85+
Documentation source files are located in the `docs/` directory. The documentation is published at [book.cakephp.org/chronos](https://book.cakephp.org/chronos/3/en/).

src/Chronos.php

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,9 +692,96 @@ public static function createFromFormat(
692692
throw new InvalidArgumentException($message);
693693
}
694694

695+
$testNow = static::getTestNow();
696+
if ($testNow !== null) {
697+
$dateTime = static::applyTestNowToMissingComponents($dateTime, $format, $testNow);
698+
}
699+
695700
return $dateTime;
696701
}
697702

703+
/**
704+
* Apply testNow values to date/time components that weren't in the format string.
705+
*
706+
* @param static $dateTime The parsed datetime instance.
707+
* @param string $format The format string used for parsing.
708+
* @param \Cake\Chronos\Chronos $testNow The test now instance.
709+
* @return static
710+
*/
711+
protected static function applyTestNowToMissingComponents(
712+
self $dateTime,
713+
string $format,
714+
Chronos $testNow,
715+
): static {
716+
// Parse format string to find which characters are actual format specifiers (not escaped)
717+
$formatChars = static::getFormatCharacters($format);
718+
719+
// Check which components are present in the format
720+
$hasYear = (bool)array_intersect($formatChars, ['Y', 'y', 'o', 'X', 'x']);
721+
$hasMonth = (bool)array_intersect($formatChars, ['m', 'n', 'M', 'F']);
722+
$hasDay = (bool)array_intersect($formatChars, ['d', 'j', 'D', 'l', 'N', 'z', 'w', 'W', 'S']);
723+
$hasHour = (bool)array_intersect($formatChars, ['H', 'G', 'h', 'g']);
724+
$hasMinute = (bool)array_intersect($formatChars, ['i']);
725+
$hasSecond = (bool)array_intersect($formatChars, ['s']);
726+
$hasMicro = (bool)array_intersect($formatChars, ['u', 'v']);
727+
728+
// If the format includes '!' or '|', PHP resets unspecified components to Unix epoch or zero
729+
// In that case, we should not override with testNow
730+
$hasReset = in_array('!', $formatChars, true) || in_array('|', $formatChars, true);
731+
if ($hasReset) {
732+
return $dateTime;
733+
}
734+
735+
// Replace missing components with testNow values
736+
$year = $hasYear ? $dateTime->year : $testNow->year;
737+
$month = $hasMonth ? $dateTime->month : $testNow->month;
738+
$day = $hasDay ? $dateTime->day : $testNow->day;
739+
$hour = $hasHour ? $dateTime->hour : $testNow->hour;
740+
$minute = $hasMinute ? $dateTime->minute : $testNow->minute;
741+
$second = $hasSecond ? $dateTime->second : $testNow->second;
742+
$micro = $hasMicro ? $dateTime->micro : $testNow->micro;
743+
744+
// Only modify if something needs to change
745+
if (
746+
!$hasYear || !$hasMonth || !$hasDay ||
747+
!$hasHour || !$hasMinute || !$hasSecond || !$hasMicro
748+
) {
749+
return $dateTime
750+
->setDate($year, $month, $day)
751+
->setTime($hour, $minute, $second, $micro);
752+
}
753+
754+
return $dateTime;
755+
}
756+
757+
/**
758+
* Extract format characters from a format string, handling escapes.
759+
*
760+
* @param string $format The format string.
761+
* @return array<string> Array of format characters.
762+
*/
763+
protected static function getFormatCharacters(string $format): array
764+
{
765+
$chars = [];
766+
$length = strlen($format);
767+
$i = 0;
768+
769+
while ($i < $length) {
770+
$char = $format[$i];
771+
772+
// Backslash escapes the next character
773+
if ($char === '\\' && $i + 1 < $length) {
774+
$i += 2;
775+
continue;
776+
}
777+
778+
$chars[] = $char;
779+
$i++;
780+
}
781+
782+
return $chars;
783+
}
784+
698785
/**
699786
* Returns parse warnings and errors from the last ``createFromFormat()``
700787
* call.

src/ChronosDatePeriod.php

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414

1515
namespace Cake\Chronos;
1616

17+
use DateInterval;
1718
use DatePeriod;
19+
use InvalidArgumentException;
1820
use Iterator;
1921

2022
/**
@@ -32,15 +34,39 @@ class ChronosDatePeriod implements Iterator
3234
protected Iterator $iterator;
3335

3436
/**
35-
* @param \DatePeriod $period
37+
* @param \DatePeriod $period The DatePeriod to wrap.
38+
* @throws \InvalidArgumentException If the period has a zero interval which would cause an infinite loop.
3639
*/
3740
public function __construct(DatePeriod $period)
3841
{
42+
if (static::isZeroInterval($period->getDateInterval())) {
43+
throw new InvalidArgumentException(
44+
'Cannot create a period with a zero interval. This would cause an infinite loop when iterating.',
45+
);
46+
}
47+
3948
/** @var \Iterator<int, \DateTimeInterface> $iterator */
4049
$iterator = $period->getIterator();
4150
$this->iterator = $iterator;
4251
}
4352

53+
/**
54+
* Check if a DateInterval is effectively zero.
55+
*
56+
* @param \DateInterval $interval The interval to check.
57+
* @return bool True if the interval is zero.
58+
*/
59+
protected static function isZeroInterval(DateInterval $interval): bool
60+
{
61+
return $interval->y === 0
62+
&& $interval->m === 0
63+
&& $interval->d === 0
64+
&& $interval->h === 0
65+
&& $interval->i === 0
66+
&& $interval->s === 0
67+
&& (int)($interval->f * 1_000_000) === 0;
68+
}
69+
4470
/**
4571
* @return \Cake\Chronos\ChronosDate
4672
*/

src/ChronosPeriod.php

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414

1515
namespace Cake\Chronos;
1616

17+
use DateInterval;
1718
use DatePeriod;
19+
use InvalidArgumentException;
1820
use Iterator;
1921

2022
/**
@@ -32,15 +34,39 @@ class ChronosPeriod implements Iterator
3234
protected Iterator $iterator;
3335

3436
/**
35-
* @param \DatePeriod $period
37+
* @param \DatePeriod $period The DatePeriod to wrap.
38+
* @throws \InvalidArgumentException If the period has a zero interval which would cause an infinite loop.
3639
*/
3740
public function __construct(DatePeriod $period)
3841
{
42+
if (static::isZeroInterval($period->getDateInterval())) {
43+
throw new InvalidArgumentException(
44+
'Cannot create a period with a zero interval. This would cause an infinite loop when iterating.',
45+
);
46+
}
47+
3948
/** @var \Iterator<int, \DateTimeInterface> $iterator */
4049
$iterator = $period->getIterator();
4150
$this->iterator = $iterator;
4251
}
4352

53+
/**
54+
* Check if a DateInterval is effectively zero.
55+
*
56+
* @param \DateInterval $interval The interval to check.
57+
* @return bool True if the interval is zero.
58+
*/
59+
protected static function isZeroInterval(DateInterval $interval): bool
60+
{
61+
return $interval->y === 0
62+
&& $interval->m === 0
63+
&& $interval->d === 0
64+
&& $interval->h === 0
65+
&& $interval->i === 0
66+
&& $interval->s === 0
67+
&& (int)($interval->f * 1_000_000) === 0;
68+
}
69+
4470
/**
4571
* @return \Cake\Chronos\Chronos
4672
*/

tests/TestCase/ChronosDatePeriodTest.php

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use DateInterval;
2020
use DatePeriod;
2121
use DateTime;
22+
use InvalidArgumentException;
2223

2324
class ChronosDatePeriodTest extends TestCase
2425
{
@@ -30,11 +31,37 @@ public function testChronosPeriod(): void
3031
$output[$key] = $value;
3132
}
3233
$this->assertCount(4, $output);
33-
$this->assertInstanceOf(ChronosDAte::class, $output[0]);
34+
$this->assertInstanceOf(ChronosDate::class, $output[0]);
3435
$this->assertSame('2025-01-01 00:00:00', $output[0]->format('Y-m-d H:i:s'));
3536
$this->assertInstanceOf(ChronosDate::class, $output[1]);
3637
$this->assertSame('2025-01-02 00:00:00', $output[1]->format('Y-m-d H:i:s'));
3738
$this->assertInstanceOf(ChronosDate::class, $output[3]);
3839
$this->assertSame('2025-01-04 00:00:00', $output[3]->format('Y-m-d H:i:s'));
3940
}
41+
42+
public function testZeroIntervalThrowsException(): void
43+
{
44+
$this->expectException(InvalidArgumentException::class);
45+
$this->expectExceptionMessage('Cannot create a period with a zero interval');
46+
47+
$period = new DatePeriod(
48+
new DateTime('2025-01-01'),
49+
new DateInterval('PT0S'),
50+
new DateTime('2025-01-02'),
51+
);
52+
new ChronosDatePeriod($period);
53+
}
54+
55+
public function testZeroIntervalAllZeroComponents(): void
56+
{
57+
$this->expectException(InvalidArgumentException::class);
58+
59+
$interval = new DateInterval('P0D');
60+
$period = new DatePeriod(
61+
new DateTime('2025-01-01'),
62+
$interval,
63+
new DateTime('2025-01-02'),
64+
);
65+
new ChronosDatePeriod($period);
66+
}
4067
}

tests/TestCase/ChronosPeriodTest.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use DateInterval;
2020
use DatePeriod;
2121
use DateTime;
22+
use InvalidArgumentException;
2223

2324
class ChronosPeriodTest extends TestCase
2425
{
@@ -35,4 +36,30 @@ public function testChronosPeriod(): void
3536
$this->assertInstanceOf(Chronos::class, $output[1]);
3637
$this->assertSame('2025-01-01 01:00:00', $output[1]->format('Y-m-d H:i:s'));
3738
}
39+
40+
public function testZeroIntervalThrowsException(): void
41+
{
42+
$this->expectException(InvalidArgumentException::class);
43+
$this->expectExceptionMessage('Cannot create a period with a zero interval');
44+
45+
$period = new DatePeriod(
46+
new DateTime('2025-01-01'),
47+
new DateInterval('PT0S'),
48+
new DateTime('2025-01-02'),
49+
);
50+
new ChronosPeriod($period);
51+
}
52+
53+
public function testZeroIntervalAllZeroComponents(): void
54+
{
55+
$this->expectException(InvalidArgumentException::class);
56+
57+
$interval = new DateInterval('P0D');
58+
$period = new DatePeriod(
59+
new DateTime('2025-01-01'),
60+
$interval,
61+
new DateTime('2025-01-02'),
62+
);
63+
new ChronosPeriod($period);
64+
}
3865
}

0 commit comments

Comments
 (0)