Skip to content
Merged
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
35 changes: 4 additions & 31 deletions src/TimeTraveler.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,7 @@ public function removeMonth(AbsoluteDate $from): AbsoluteDate
}

/**
* The last occurrence of the reference day of month that is at most one month after $from
*
* The result never goes past addMonth($from). When the reference day of that month is already past it, the
* result steps back to the reference day of the month of $from, so a date that drifted away from the
* reference day rejoins it without ever spanning more than a month:
* addMonthWithReference(2020-01-31, 2020-08-01) = 2020-08-31, not 2020-09-30
* addMonthWithReference(2020-01-31, 2020-08-16) = 2020-08-31
* addMonthWithReference(2020-01-15, 2020-08-20) = 2020-09-15
* The result may be the very next day when $from is the day before the reference day:
* addMonthWithReference(2020-01-31, 2020-07-30) = 2020-07-31
*
* A $from on the reference day gets the reference day of the following month, which keeps months calculation
* coherent year over year where addMonth() alone drifts after a shorter month:
* Ensures that months calculation are coherent year over year
*
* (new AbsoluteDate(2020-01-31))->modify('+1 year') = 2021-01-31
* (new AbsoluteDate(2020-01-31))
Expand All @@ -78,29 +66,14 @@ public function removeMonth(AbsoluteDate $from): AbsoluteDate
*/
public function addMonthWithReference(AbsoluteDate $reference, AbsoluteDate $from): AbsoluteDate
{
$oneMonthLater = $this->addMonth($from);

$inTheMonthAfter = $this->referenceDayWithinMonthOf($oneMonthLater, $reference);
if ($inTheMonthAfter->isBeforeOrEqualTo($oneMonthLater)) {
return $inTheMonthAfter;
}

return $this->referenceDayWithinMonthOf($from, $reference);
}

/**
* The reference day placed in the month of $date, clamped to that month's length — or that month's last day
* when the reference is itself the last day of its own month
*/
private function referenceDayWithinMonthOf(AbsoluteDate $date, AbsoluteDate $reference): AbsoluteDate
{
$next = $this->addMonth($from);
$day = min(
intval($reference->format('j')),
intval($date->modify('last day of this month')->format('j'))
intval($next->modify('last day of this month')->format('j'))
);
$dayString = str_pad(strval($day), 2, '0', STR_PAD_LEFT);
return $this->modifyForTheLastDayOfThisMonthIfNeedBe(
new AbsoluteDate($date->format('Y-m') . '-' . $dayString),
new AbsoluteDate($next->format('Y-m') . '-' . $dayString),
$reference
);
}
Expand Down
130 changes: 2 additions & 128 deletions tests/TimeTravelerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,144 +80,18 @@ public function testAddMonthWithReference(string $reference, string $from, strin
/** @return array{string, string, string}[] */
public static function provideMonthsWithReference(): iterable
{
// $from on the reference day: the reference day of the following month
yield ['2020-01-01', '2020-01-01', '2020-02-01'];
yield ['2020-01-01', '2020-02-01', '2020-03-01'];

yield ['2020-01-25', '2020-02-25', '2020-03-25'];
yield ['2020-01-15', '2020-12-15', '2021-01-15'];
yield ['2020-02-29', '2020-02-29', '2020-03-31'];
yield ['2020-01-31', '2020-01-31', '2020-02-29'];

// $from on a clamped last day of a shorter month: back on the reference day
// Test the result day matches the reference day
yield ['2020-01-30', '2020-02-29', '2020-03-30'];
yield ['2020-01-31', '2020-02-29', '2020-03-31'];
yield ['2021-01-31', '2021-02-28', '2021-03-31'];
yield ['2021-01-29', '2021-02-28', '2021-03-29'];
yield ['2020-01-31', '2020-03-31', '2020-04-30'];
yield ['2020-01-31', '2020-04-30', '2020-05-31'];
yield ['2020-01-31', '2020-11-30', '2020-12-31'];
yield ['2020-01-31', '2020-12-31', '2021-01-31'];

// a reference on the last day of its month means the last day of every month
yield ['2020-06-30', '2020-06-30', '2020-07-31'];
yield ['2020-06-30', '2020-07-31', '2020-08-31'];
yield ['2020-06-30', '2020-08-31', '2020-09-30'];
yield ['2021-02-28', '2021-02-28', '2021-03-31'];
yield ['2021-02-28', '2021-03-31', '2021-04-30'];
yield ['2020-04-30', '2020-08-10', '2020-08-31'];
yield ['2021-02-28', '2021-03-10', '2021-03-31'];
yield ['2020-06-30', '2020-07-01', '2020-07-31'];
yield ['2020-06-30', '2020-07-16', '2020-07-31'];

// $from before the reference day of its month: the reference day of that same month, since the following
// month's would be more than a month away
yield ['2020-01-31', '2020-08-01', '2020-08-31'];
yield ['2020-01-31', '2020-08-02', '2020-08-31'];
yield ['2020-01-31', '2020-08-15', '2020-08-31'];
yield ['2020-01-31', '2020-08-16', '2020-08-31'];
yield ['2020-01-29', '2020-05-01', '2020-05-29'];
yield ['2020-01-15', '2020-01-01', '2020-01-15'];
yield ['2020-01-15', '2020-01-10', '2020-01-15'];
yield ['2020-01-15', '2020-01-14', '2020-01-15'];
yield ['2025-01-31', '2025-05-15', '2025-05-31'];
yield ['2021-01-31', '2021-01-01', '2021-01-31'];

// $from the day before the reference day: a single-day step
yield ['2020-01-31', '2020-07-30', '2020-07-31'];
yield ['2020-01-15', '2020-03-14', '2020-03-15'];
yield ['2020-01-02', '2020-03-01', '2020-03-02'];
yield ['2020-01-01', '2020-01-31', '2020-02-01'];
yield ['2020-01-01', '2020-02-29', '2020-03-01'];
yield ['2020-01-01', '2020-04-30', '2020-05-01'];

// $from after the reference day of its month: the reference day of the following month
yield ['2020-01-15', '2020-01-20', '2020-02-15'];
yield ['2020-01-15', '2020-08-20', '2020-09-15'];
yield ['2020-01-15', '2020-01-31', '2020-02-15'];
yield ['2020-01-01', '2020-01-02', '2020-02-01'];
yield ['2020-01-01', '2020-01-16', '2020-02-01'];
yield ['2020-01-10', '2020-12-20', '2021-01-10'];

// the reference day does not exist in the month reached: its last day is the anchor, and being one month
// after $from at most, it is preferred to the reference day of the month of $from
yield ['2020-01-31', '2020-02-10', '2020-02-29'];
yield ['2021-01-31', '2021-02-10', '2021-02-28'];
yield ['2020-01-31', '2020-04-10', '2020-04-30'];
yield ['2020-01-31', '2020-01-30', '2020-02-29'];
yield ['2020-01-30', '2020-01-29', '2020-02-29'];
yield ['2021-01-30', '2021-01-29', '2021-02-28'];
yield ['2020-01-29', '2021-01-30', '2021-02-28'];
yield ['2020-01-31', '2020-08-30', '2020-09-30'];

// across a year boundary
yield ['2020-01-31', '2020-12-31', '2021-01-31'];
yield ['2020-01-31', '2020-12-15', '2020-12-31'];
yield ['2020-01-15', '2020-12-20', '2021-01-15'];
yield ['2020-01-01', '2020-12-31', '2021-01-01'];
yield ['2020-06-30', '2020-12-10', '2020-12-31'];
}

/**
* A date that drifted away from the reference day rejoins it at the very next step, and every later step
* lands on the reference day of the following month.
*/
public function testAddMonthWithReferenceRejoinsTheReferenceDayAfterADrift(): void
{
$reference = new AbsoluteDate('2020-01-31');
$actual = new AbsoluteDate('2020-08-02');

foreach (['2020-08-31', '2020-09-30', '2020-10-31', '2020-11-30', '2020-12-31', '2021-01-31'] as $expected) {
$actual = $this->timeTraveler->addMonthWithReference($reference, $actual);

self::assertSame($expected, $actual->__toString());
}
}

/**
* Whatever the reference day and the starting date (every day of 2020 and 2021, so leap and non-leap
* Februaries and two year boundaries), the result is after the start, never past one month later, and on the
* reference day of its month — that month's last day when the reference day does not exist in it or when the
* reference is itself a last day of month.
*
* The reference days are the ones sitting on a boundary: the first days of a month, the days around the
* middle where the month of the result flips, and the days a shorter month clamps. The days in between behave
* like their neighbours and would only add to the running time.
*/
public function testAddMonthWithReferenceAlwaysLandsOnTheReferenceDayWithinOneMonth(): void
{
$references = [];
foreach ([1, 2, 14, 15, 16, 27, 28, 29, 30, 31] as $referenceDay) {
$references[] = new AbsoluteDate(sprintf('2020-01-%02d', $referenceDay));
}
$references[] = new AbsoluteDate('2021-02-28');
$references[] = new AbsoluteDate('2020-02-29');
$references[] = new AbsoluteDate('2020-04-30');

$end = new AbsoluteDate('2022-02-01');
$from = new AbsoluteDate('2019-12-01');
while ($from->isBefore($end)) {
$oneMonthLater = $this->timeTraveler->addMonth($from);
foreach ($references as $reference) {
$actual = $this->timeTraveler->addMonthWithReference($reference, $from);
$context = sprintf('reference %s, from %s, got %s', $reference, $from, $actual);

self::assertTrue($from->isBefore($actual), $context);
self::assertTrue($actual->isBeforeOrEqualTo($oneMonthLater), $context . ', past ' . $oneMonthLater);
self::assertSame($this->expectedDayIn($actual, $reference), $actual->format('j'), $context);
}
$from = $from->modify('+1 day');
}
}

/** The day the reference lands on in the month of $date, as a string like AbsoluteDate::format('j') gives it */
private function expectedDayIn(AbsoluteDate $date, AbsoluteDate $reference): string
{
$lastDayOfMonth = $date->modify('last day of this month')->format('j');
if ($reference->equalsTo($reference->modify('last day of this month'))) {
return $lastDayOfMonth;
}

return (string) min((int) $reference->format('j'), (int) $lastDayOfMonth);
}

#[DataProvider('provideMonthsWithReferenceOverYear')]
Expand Down
Loading