diff --git a/src/TimeTraveler.php b/src/TimeTraveler.php index 226e940..6a00f65 100644 --- a/src/TimeTraveler.php +++ b/src/TimeTraveler.php @@ -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)) @@ -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 ); } diff --git a/tests/TimeTravelerTest.php b/tests/TimeTravelerTest.php index f3da657..953de71 100644 --- a/tests/TimeTravelerTest.php +++ b/tests/TimeTravelerTest.php @@ -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')]