From dbb7ed3d5dd9b9f09e08237408df810edac07e17 Mon Sep 17 00:00:00 2001 From: Antonio Spinelli Date: Mon, 3 Jul 2023 00:08:14 -0300 Subject: [PATCH] Add the Calendar Calculator It encapsulates some date operations like sum. The result will be the calculated date when calling the nextDateByInterval method, given the date, periodicity, and skipInterval parameters. For example, given a date of 2019-12-31, monthly periodicity, and skip interval 0, the results will be 2020-01-31. Also, if the skip interval is 1, the result is 2020-02-29. This is because the next date will add another month to the current range. --- app/Support/Calendar/Calculator.php | 81 ++++++++++++ .../Calendar/Exceptions/IntervalException.php | 28 ++++ tests/Support/Calendar/CalculatorProvider.php | 120 ++++++++++++++++++ tests/Support/Calendar/CalculatorTest.php | 97 ++++++++++++++ 4 files changed, 326 insertions(+) create mode 100644 app/Support/Calendar/Calculator.php create mode 100644 app/Support/Calendar/Exceptions/IntervalException.php create mode 100644 tests/Support/Calendar/CalculatorProvider.php create mode 100644 tests/Support/Calendar/CalculatorTest.php diff --git a/app/Support/Calendar/Calculator.php b/app/Support/Calendar/Calculator.php new file mode 100644 index 0000000000..a26fb5ea18 --- /dev/null +++ b/app/Support/Calendar/Calculator.php @@ -0,0 +1,81 @@ +. + */ + +namespace FireflyIII\Support\Calendar; + +use Carbon\Carbon; +use FireflyIII\Support\Calendar\Exceptions\IntervalException; + +class Calculator +{ + const DEFAULT_INTERVAL = 1; + private static array $intervals = []; + private static ?\SplObjectStorage $intervalMap = null; + + private static function loadIntervalMap(): \SplObjectStorage + { + if (self::$intervalMap != null) { + return self::$intervalMap; + } + self::$intervalMap = new \SplObjectStorage(); + foreach (Periodicity::cases() as $interval) { + $periodicityClass = __NAMESPACE__ . "\\Periodicity\\{$interval->name}"; + self::$intervals[] = $interval->name; + self::$intervalMap->attach($interval, new $periodicityClass()); + } + return self::$intervalMap; + } + + private static function containsInterval(Periodicity $periodicity): bool + { + return self::loadIntervalMap()->contains($periodicity); + } + + public function isAvailablePeriodicity(Periodicity $periodicity): bool + { + return self::containsInterval($periodicity); + } + + private function skipInterval(int $skip): int + { + return self::DEFAULT_INTERVAL + $skip; + } + + /** + * @param Carbon $epoch + * @param Periodicity $periodicity + * @param int $skipInterval + * @return Carbon + * @throws IntervalException + */ + public function nextDateByInterval(Carbon $epoch, Periodicity $periodicity, int $skipInterval = 0): Carbon + { + if (!self::isAvailablePeriodicity($periodicity)) { + throw IntervalException::unavailable($periodicity, self::$intervals); + } + + /** @var Periodicity\Interval $periodicity */ + $periodicity = self::$intervalMap->offsetGet($periodicity); + $interval = $this->skipInterval($skipInterval); + return $periodicity->nextDate($epoch->clone(), $interval); + } + +} diff --git a/app/Support/Calendar/Exceptions/IntervalException.php b/app/Support/Calendar/Exceptions/IntervalException.php new file mode 100644 index 0000000000..40daccd8f6 --- /dev/null +++ b/app/Support/Calendar/Exceptions/IntervalException.php @@ -0,0 +1,28 @@ +name, + join(', ', $instervals) + ); + + $exception = new IntervalException($message, $code, $previous); + $exception->periodicity = $periodicity; + $exception->availableIntervals = $instervals; + return $exception; + } +} diff --git a/tests/Support/Calendar/CalculatorProvider.php b/tests/Support/Calendar/CalculatorProvider.php new file mode 100644 index 0000000000..dcbad2c8d3 --- /dev/null +++ b/tests/Support/Calendar/CalculatorProvider.php @@ -0,0 +1,120 @@ +. + */ + +namespace Tests\Support\Calendar; + +use Carbon\Carbon; +use FireflyIII\Support\Calendar\Periodicity; +use Tests\Support\Calendar\Periodicity\IntervalProvider; + +readonly class CalculatorProvider +{ + public IntervalProvider $intervalProvider; + public Periodicity $periodicity; + public string $label; + public int $skip; + + private function __construct(IntervalProvider $intervalProvider, Periodicity $periodicity, int $skip = 0) + { + $this->skip = $skip; + $this->intervalProvider = $intervalProvider; + $this->periodicity = $periodicity; + $this->label = "{$periodicity->name} {$intervalProvider->label}"; + } + + public static function from(Periodicity $periodicity, IntervalProvider $interval, int $skip = 0): CalculatorProvider + { + return new self($interval, $periodicity, $skip); + } + + public function epoch(): Carbon + { + return $this->intervalProvider->epoch; + } + + public function expected(): Carbon + { + return $this->intervalProvider->expected; + } + + public static function providePeriodicityWithSkippedIntervals(): \Generator + { + $intervals = [ + CalculatorProvider::from(Periodicity::Daily, new IntervalProvider(Carbon::now(), Carbon::now()->addDays(2)), 1), + CalculatorProvider::from(Periodicity::Daily, new IntervalProvider(Carbon::now(), Carbon::now()->addDays(3)), 2), + CalculatorProvider::from(Periodicity::Daily, new IntervalProvider(Carbon::parse('2023-01-31'), Carbon::parse('2023-02-11')), 10), + + CalculatorProvider::from(Periodicity::Weekly, new IntervalProvider(Carbon::now(), Carbon::now()->addWeeks(3)), 2), + CalculatorProvider::from(Periodicity::Weekly, new IntervalProvider(Carbon::parse('2023-01-31'), Carbon::parse('2023-02-14')), 1), + + CalculatorProvider::from(Periodicity::Fortnightly, new IntervalProvider(Carbon::now(), Carbon::now()->addWeeks(4)), 1), + CalculatorProvider::from(Periodicity::Fortnightly, new IntervalProvider(Carbon::parse('2023-01-29'), Carbon::parse('2023-02-26')), 1), + CalculatorProvider::from(Periodicity::Fortnightly, new IntervalProvider(Carbon::parse('2023-01-30'), Carbon::parse('2023-02-27')), 1), + CalculatorProvider::from(Periodicity::Fortnightly, new IntervalProvider(Carbon::parse('2023-01-31'), Carbon::parse('2023-02-28')), 1), + + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::now(), Carbon::now()->addMonthsNoOverflow(2)), 1), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2019-12-30'), Carbon::parse('2020-02-29')), 1), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2019-12-31'), Carbon::parse('2020-02-29')), 1), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2020-01-29'), Carbon::parse('2020-03-29')), 1), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2020-01-31'), Carbon::parse('2020-09-30')), 7), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2020-12-29'), Carbon::parse('2021-02-28')), 1), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2020-12-30'), Carbon::parse('2021-02-28')), 1), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2020-12-31'), Carbon::parse('2021-02-28')), 1), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2023-03-31'), Carbon::parse('2023-11-30')), 7), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2023-05-31'), Carbon::parse('2023-08-31')), 2), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2023-07-31'), Carbon::parse('2023-09-30')), 1), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2023-10-30'), Carbon::parse('2024-02-29')), 3), + CalculatorProvider::from(Periodicity::Monthly, new IntervalProvider(Carbon::parse('2023-10-31'), Carbon::parse('2024-02-29')), 3), + + CalculatorProvider::from(Periodicity::Quarterly, new IntervalProvider(Carbon::now(), Carbon::now()->addMonthsNoOverflow(9)), 2), + CalculatorProvider::from(Periodicity::Quarterly, new IntervalProvider(Carbon::parse('2019-05-29'), Carbon::parse('2020-02-29')), 2), + CalculatorProvider::from(Periodicity::Quarterly, new IntervalProvider(Carbon::parse('2019-05-30'), Carbon::parse('2020-02-29')), 2), + CalculatorProvider::from(Periodicity::Quarterly, new IntervalProvider(Carbon::parse('2019-05-31'), Carbon::parse('2020-02-29')), 2), + CalculatorProvider::from(Periodicity::Quarterly, new IntervalProvider(Carbon::parse('2020-02-29'), Carbon::parse('2021-02-28')), 3), + CalculatorProvider::from(Periodicity::Quarterly, new IntervalProvider(Carbon::parse('2020-08-29'), Carbon::parse('2021-02-28')), 1), + CalculatorProvider::from(Periodicity::Quarterly, new IntervalProvider(Carbon::parse('2020-08-30'), Carbon::parse('2021-02-28')), 1), + CalculatorProvider::from(Periodicity::Quarterly, new IntervalProvider(Carbon::parse('2020-08-31'), Carbon::parse('2021-02-28')), 1), + + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::now(), Carbon::now()->addMonthsNoOverflow(12)), 1), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::now(), Carbon::now()->addMonthsNoOverflow(18)), 2), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::now(), Carbon::now()->addMonthsNoOverflow(24)), 3), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2018-08-29'), Carbon::parse('2020-02-29')), 2), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2018-08-30'), Carbon::parse('2020-02-29')), 2), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2018-08-31'), Carbon::parse('2020-02-29')), 2), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2019-01-31'), Carbon::parse('2021-01-31')), 3), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2019-02-28'), Carbon::parse('2021-08-28')), 4), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2020-01-31'), Carbon::parse('2021-01-31')), 1), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2020-02-29'), Carbon::parse('2021-02-28')), 1), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2020-08-29'), Carbon::parse('2022-02-28')), 2), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2020-08-30'), Carbon::parse('2022-02-28')), 2), + CalculatorProvider::from(Periodicity::HalfYearly, new IntervalProvider(Carbon::parse('2020-08-31'), Carbon::parse('2022-02-28')), 2), + + CalculatorProvider::from(Periodicity::Yearly, new IntervalProvider(Carbon::now(), Carbon::now()->addYearsNoOverflow(3)), 2), + CalculatorProvider::from(Periodicity::Yearly, new IntervalProvider(Carbon::parse('2019-01-29'), Carbon::parse('2025-01-29')), 5), + CalculatorProvider::from(Periodicity::Yearly, new IntervalProvider(Carbon::parse('2020-02-29'), Carbon::parse('2031-02-28')), 10), + ]; + + /** @var IntervalProvider $interval */ + foreach ($intervals as $index => $interval) { + yield "#{$index} {$interval->label}" => [$interval]; + } + } +} diff --git a/tests/Support/Calendar/CalculatorTest.php b/tests/Support/Calendar/CalculatorTest.php new file mode 100644 index 0000000000..c4fb07c6e8 --- /dev/null +++ b/tests/Support/Calendar/CalculatorTest.php @@ -0,0 +1,97 @@ +. + */ + +namespace Tests\Support\Calendar; + +use Carbon\Carbon; +use FireflyIII\Api\V1\Controllers\Insight\Income\PeriodController; +use FireflyIII\Support\Calendar\Calculator; +use FireflyIII\Support\Calendar\Exceptions\IntervalException; +use FireflyIII\Support\Calendar\Periodicity; +use FireflyIII\Support\Navigation; +use Tests\Support\Calendar\Periodicity\DailyTest; +use Tests\Support\Calendar\Periodicity\FortnightlyTest; +use Tests\Support\Calendar\Periodicity\HalfYearlyTest; +use Tests\Support\Calendar\Periodicity\IntervalProvider; +use Tests\Support\Calendar\Periodicity\MonthlyTest; +use Tests\Support\Calendar\Periodicity\QuarterlyTest; +use Tests\Support\Calendar\Periodicity\WeeklyTest; +use Tests\Support\Calendar\Periodicity\YearlyTest; +use Tests\TestCase; + +class CalculatorTest extends TestCase +{ + private static function convert(Periodicity $periodicity, array $intervals): array + { + $periodicityIntervals = []; + /** @var IntervalProvider $interval */ + foreach ($intervals as $index => $interval) { + $calculator = CalculatorProvider::from($periodicity, $interval); + + $periodicityIntervals["#{$index} {$calculator->label}"] = [$calculator]; + } + return $periodicityIntervals; + } + + public static function provideAllPeriodicity(): \Generator + { + $intervals = []; + $intervals = array_merge($intervals, self::convert(Periodicity::Daily, DailyTest::provideIntervals())); + $intervals = array_merge($intervals, self::convert(Periodicity::Weekly, WeeklyTest::provideIntervals())); + $intervals = array_merge($intervals, self::convert(Periodicity::Fortnightly, FortnightlyTest::provideIntervals())); + $intervals = array_merge($intervals, self::convert(Periodicity::Monthly, MonthlyTest::provideIntervals())); + $intervals = array_merge($intervals, self::convert(Periodicity::Quarterly, QuarterlyTest::provideIntervals())); + $intervals = array_merge($intervals, self::convert(Periodicity::HalfYearly, HalfYearlyTest::provideIntervals())); + $intervals = array_merge($intervals, self::convert(Periodicity::Yearly, YearlyTest::provideIntervals())); + + /** @var IntervalProvider $interval */ + foreach ($intervals as $label => $interval) { + yield $label => $interval; + } + } + + /** + * @dataProvider provideAllPeriodicity + * @throws IntervalException + */ + public function testGivenADailyPeriodicityWhenCallTheNextDateByIntervalMethodThenReturnsTheExpectedDateSuccessful(CalculatorProvider $provider) + { + $calculator = new Calculator(); + $period = $calculator->nextDateByInterval($provider->epoch(), $provider->periodicity); + $this->assertEquals($provider->expected()->toDateString(), $period->toDateString()); + } + + public static function provideSkippedIntervals(): \Generator + { + return CalculatorProvider::providePeriodicityWithSkippedIntervals(); + } + + /** + * @dataProvider provideSkippedIntervals + * @throws IntervalException + */ + public function testGivenAnEpochWithSkipIntervalNumberWhenCallTheNextDateBySkippedIntervalMethodThenReturnsTheExpectedDateSuccessful(CalculatorProvider $provider) + { + $calculator = new Calculator(); + $period = $calculator->nextDateByInterval($provider->epoch(), $provider->periodicity, $provider->skip); + $this->assertEquals($provider->expected()->toDateString(), $period->toDateString()); + } +}