From 4a72fc9720ecc6ca074fd645a19ef802e95ee891 Mon Sep 17 00:00:00 2001 From: Sylvain Fabre Date: Fri, 4 Nov 2022 21:15:30 +0100 Subject: [PATCH] Time traveler --- src/AbsoluteDate.php | 8 +++- src/TimeTraveler.php | 71 ++++++++++++++++++++++++++++ tests/TimeTravelerTest.php | 96 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 src/TimeTraveler.php create mode 100644 tests/TimeTravelerTest.php diff --git a/src/AbsoluteDate.php b/src/AbsoluteDate.php index edb586f..c49c8bf 100644 --- a/src/AbsoluteDate.php +++ b/src/AbsoluteDate.php @@ -56,8 +56,12 @@ public function format(string $format = self::DEFAULT_DATE_FORMAT): string /** * Modify the internal datetime - * Supported modify strings : + * + * This method only supports year, month, and day modifiers * @link https://www.php.net/manual/fr/datetime.formats.php + * + * @see TimeTraveler for coherent modifications month-over-month & year-over-year + * * @return AbsoluteDate */ public function modify(string $modifier): self @@ -76,7 +80,7 @@ public function modify(string $modifier): self 'ago', 'this', 'of', - 'previous' + 'previous', ]; preg_match_all('/([a-z]+)/', $modifier, $matches); $invalidPatterns = array_diff($matches[0], $validPatterns); diff --git a/src/TimeTraveler.php b/src/TimeTraveler.php new file mode 100644 index 0000000..c0bf09e --- /dev/null +++ b/src/TimeTraveler.php @@ -0,0 +1,71 @@ +modify('+1 month') = 2020-02-31 => 2020-03-02 so more than a month + * addMonth(new AbsoluteDate(2020-01-31)) = 2020-02-29 + */ + public function addMonth(AbsoluteDate $from): AbsoluteDate + { + $expectedMonth = (intval($from->format('n')) % 12) + 1; + $next = $from->modify('+1 month'); + while ($expectedMonth !== intval($next->format('n'))) { + $next = $next->modify('-1 day'); + } + return $next; + } + + /** + * 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)) + * ->modify('+1 month') = 2020-02-31 => 2020-03-02 + * ->modify('+1 month') = 2020-04-02 + * ... + * ->modify('+1 month') = 2021-02-02 but we would expect 2021-01-31 + * + * $this->addMonth( + * $this->addMonth( + * ... + * $this->addMonth(new AbsoluteDate(2020-01-31)) + * ... + * ) + * ) = 2021-01-28 but we would expect 2021-01-31 + */ + public function addMonthWithReference(AbsoluteDate $reference, AbsoluteDate $from): AbsoluteDate + { + $next = $this->addMonth($from); + $day = min( + intval($reference->format('j')), + intval($next->modify('last day of this month')->format('j')) + ); + $dayString = str_pad(strval($day), 2, '0', STR_PAD_LEFT); + return new AbsoluteDate($next->format('Y-m') . '-' . $dayString); + } + + /** + * Add at most a year to a date + * + * (new AbsoluteDate(2020-02-29))->modify('+1 year') = 2021-02-29 => 2020-03-01 so more than a year + */ + public function addYear(AbsoluteDate $from): AbsoluteDate + { + $expectedMonth = intval($from->format('n')); + $next = $from->modify('+1 year'); + while ($expectedMonth !== intval($next->format('n'))) { + $next = $next->modify('-1 day'); + } + return $next; + } +} diff --git a/tests/TimeTravelerTest.php b/tests/TimeTravelerTest.php new file mode 100644 index 0000000..f085e03 --- /dev/null +++ b/tests/TimeTravelerTest.php @@ -0,0 +1,96 @@ +timeTraveler = new TimeTraveler(); + } + + /** @dataProvider provideMonths */ + public function testAddMonth(string $from, string $expected): void + { + self::assertSame($expected, $this->timeTraveler->addMonth(new AbsoluteDate($from))->__toString()); + } + + /** @return array{string, string}[] */ + public function provideMonths(): iterable + { + yield ['2020-01-01', '2020-02-01']; + yield ['2020-01-28', '2020-02-28']; + yield ['2020-01-29', '2020-02-29']; + yield ['2020-01-30', '2020-02-29']; + yield ['2020-01-31', '2020-02-29']; + yield ['2020-02-29', '2020-03-29']; + } + + /** @dataProvider provideMonthsWithReference */ + public function testAddMonthWithReference(string $reference, string $from, string $expected): void + { + self::assertSame($expected, $this->timeTraveler->addMonthWithReference( + new AbsoluteDate($reference), + new AbsoluteDate($from) + )->__toString()); + } + + /** @return array{string, string, string}[] */ + public function provideMonthsWithReference(): iterable + { + 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']; + + // 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 ['2020-01-31', '2020-03-31', '2020-04-30']; + } + + /** @dataProvider provideMonthsWithReferenceOverYear */ + public function testAddMonthWithReferenceWorksYearOverYear(string $reference, string $expected): void + { + $reference = new AbsoluteDate($reference); + + for ($i = 0; $i < 12; $i++) { + $actual = $this->timeTraveler->addMonthWithReference($reference, $actual ?? $reference); + } + self::assertTrue(isset($actual)); + self::assertSame($expected, $actual->__toString()); + } + + /** @return array{string, string}[] */ + public function provideMonthsWithReferenceOverYear(): iterable + { + yield ['2020-01-01', '2021-01-01']; + yield ['2020-01-15', '2021-01-15']; + yield ['2020-01-28', '2021-01-28']; + yield ['2020-01-29', '2021-01-29']; + yield ['2020-01-30', '2021-01-30']; + yield ['2020-01-31', '2021-01-31']; + } + + /** @dataProvider provideYears */ + public function testAddYear(string $from, string $expected): void + { + self::assertSame($expected, $this->timeTraveler->addYear(new AbsoluteDate($from))->__toString()); + } + + /** @return array{string, string}[] */ + public function provideYears(): iterable + { + yield ['2020-01-01', '2021-01-01']; + yield ['2020-01-31', '2021-01-31']; + yield ['2020-02-29', '2021-02-28']; + } +}