Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,19 @@ protected static function expressionsForHourCarry(array $segments, array $parts,
*/
protected static function shiftAndGroup($field, $offset, $mod, $min = 0)
{
if ($offset === 0 || ! preg_match('/^[\d,]+$/', $field)) {
if ($offset === 0) {
return [0 => $field];
}

$values = static::expandField($field, $mod, $min);

if ($values === null) {
return [0 => $field];
}

$groups = [];

foreach (explode(',', $field) as $value) {
foreach ($values as $value) {
$new = (int) $value + $offset;
$carry = 0;

Expand All @@ -153,6 +159,35 @@ protected static function shiftAndGroup($field, $offset, $mod, $min = 0)
})->all();
}

/**
* Expand a cron field containing stepped values.
*
* @param string $field
* @param int $mod
* @param int $min
* @return array<int>|null
*/
protected static function expandField($field, $mod, $min = 0)
{
if (preg_match('/^[\d,]+$/', $field)) {
return array_map('intval', explode(',', $field));
}

if (! preg_match('/^(?<start>\*|\d+)(?:-(?<end>\d+))?\/(?<step>\d+)$/', $field, $matches)) {
return null;
}

$start = $matches['start'] === '*' ? $min : (int) $matches['start'];
$end = $matches['end'] !== '' ? (int) $matches['end'] : $mod + $min - 1;
$step = (int) $matches['step'];

if ($step === 0 || $start < $min || $end >= $mod + $min || $start > $end) {
return null;
}

return range($start, $end, $step);
}

/**
* Shift a cron field by the given offset.
*
Expand Down
27 changes: 24 additions & 3 deletions tests/Integration/Console/Scheduling/ScheduleListCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -256,14 +256,16 @@ public static function expressionTimezoneConversionProvider()
// No conversion needed — same timezone
'same timezone' => ['0 8 * * *', 'UTC', null, ['0 8 * * *']],

// Wildcards/steps — pass through unchanged
// Wildcards and unsupported ranges — pass through unchanged
'every minute' => ['* * * * *', 'America/New_York', null, ['* * * * *']],
'every five minutes' => ['*/5 * * * *', 'Asia/Tokyo', null, ['*/5 * * * *']],
'every two hours' => ['0 */2 * * *', 'Asia/Tokyo', null, ['0 */2 * * *']],
'every odd hour' => ['0 1-23/2 * * *', 'Asia/Tokyo', null, ['0 1-23/2 * * *']],
'quarterly step month' => ['0 0 1 1-12/3 *', 'Asia/Tokyo', null, ['0 15 31 1-12/3 *']],
'weekdays range' => ['0 8 * * 1-5', 'Asia/Tokyo', null, ['0 23 * * 1-5']],

// Stepped hours
'every two hours' => ['0 */2 * * *', 'Asia/Tokyo', null, ['0 15,17,19,21,23 * * *', '0 1,3,5,7,9,11,13 * * *']],
'every odd hour' => ['0 1-23/2 * * *', 'Asia/Tokyo', null, ['0 16,18,20,22 * * *', '0 0,2,4,6,8,10,12,14 * * *']],

// Simple hour shift (no day boundary)
'daily LA to UTC' => ['0 8 * * *', 'America/Los_Angeles', null, ['0 16 * * *']],
'daily Tokyo to UTC' => ['0 14 * * *', 'Asia/Tokyo', null, ['0 5 * * *']],
Expand Down Expand Up @@ -343,6 +345,25 @@ public function testExpressionTimezoneConversion($expression, $eventTimezone, $d
}
}

public function testExpressionTimezoneConversionHandlesSteppedHours()
{
Carbon::setTestNow('2023-07-01');

$this->schedule->command('inspire')->everyFourHours()->timezone('Europe/London');

$this->withoutMockingConsoleOutput()->artisan(ScheduleListCommand::class, [
'--timezone' => 'UTC',
'--json' => true,
]);

$data = json_decode(Artisan::output(), true);

$this->assertSame([
'0 23 * * *',
'0 3,7,11,15,19 * * *',
], array_column($data, 'expression'));
}

public function testDisplayScheduleCliSplitsExpressionWhenMixedCarry()
{
// 13+8=21 (no carry), 17+8=1 (carry +1) — splits into two CLI rows
Expand Down
Loading