diff --git a/.vscode/settings.json b/.vscode/settings.json index 6af9e8b..b7ad7ef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,10 +8,8 @@ // php-cs-fixer settings "php-cs-fixer.config": ".php-cs-fixer.dist.php", "php-cs-fixer.executablePath": "${workspaceFolder}/vendor/friendsofphp/php-cs-fixer/php-cs-fixer", - // HACK: PHP8.4 に PHP-CS-Fixer が対応していないが強制的に実行するための設定 - // TODO: PHP-CS-Fixer が PHP8.4 に対応したら削除すること!!!! - "php-cs-fixer.ignorePHPVersion": true, // PHPStan settings + "phpstan.enabled": true, "phpstan.binPath": "vendor/bin/phpstan", "phpstan.configFile": "phpstan.neon.dist", "phpstan.singleFileMode": true, diff --git a/examples/DateTime/TestLocalDateRange.php b/examples/DateTime/TestLocalDateRange.php index 0ab6917..f2d6ee6 100644 --- a/examples/DateTime/TestLocalDateRange.php +++ b/examples/DateTime/TestLocalDateRange.php @@ -6,6 +6,7 @@ use WizDevelop\PhpValueObject\DateTime\LocalDate; use WizDevelop\PhpValueObject\DateTime\LocalDateRange; +use WizDevelop\PhpValueObject\DateTime\RangeType; require_once __DIR__ . '/../../vendor/autoload.php'; @@ -13,20 +14,22 @@ echo "=== 基本的な使用例 ===\n"; $startOfMonth = LocalDate::of(2024, 1, 1); $endOfMonth = LocalDate::of(2024, 1, 31); -$january = LocalDateRange::closed($startOfMonth, $endOfMonth); +$january = LocalDateRange::from($startOfMonth, $endOfMonth); echo "1月の期間: {$january->toISOString()}\n"; echo "日数: {$january->days()} 日\n\n"; // 2. 開区間と閉区間の違い echo "=== 開区間と閉区間の違い ===\n"; -$closedWeek = LocalDateRange::closed( +$closedWeek = LocalDateRange::from( LocalDate::of(2024, 1, 1), - LocalDate::of(2024, 1, 7) + LocalDate::of(2024, 1, 7), + RangeType::CLOSED, ); -$openWeek = LocalDateRange::open( +$openWeek = LocalDateRange::from( LocalDate::of(2024, 1, 1), - LocalDate::of(2024, 1, 7) + LocalDate::of(2024, 1, 7), + RangeType::OPEN, ); echo "閉区間(両端含む): {$closedWeek->toISOString()} = {$closedWeek->days()} 日\n"; @@ -35,9 +38,10 @@ // 3. 半開区間の使用例(一般的な日付範囲の表現) echo "=== 半開区間の使用例 ===\n"; // 月初から月末まで(月末を含まない一般的なパターン) -$month = LocalDateRange::halfOpenRight( +$month = LocalDateRange::from( LocalDate::of(2024, 1, 1), - LocalDate::of(2024, 2, 1) + LocalDate::of(2024, 2, 1), + RangeType::HALF_OPEN_RIGHT, ); echo "1月(右半開区間): {$month->toISOString()}\n"; @@ -46,30 +50,34 @@ // 4. 日付の反復処理 echo "=== 日付の反復処理 ===\n"; -$weekRange = LocalDateRange::closed( +$weekRange = LocalDateRange::from( LocalDate::of(2024, 1, 1), - LocalDate::of(2024, 1, 7) + LocalDate::of(2024, 1, 7), + RangeType::CLOSED, ); echo "1週間の日付:\n"; -foreach ($weekRange->iterate() as $date) { +foreach ($weekRange->getIterator() as $date) { echo " - {$date->toISOString()}\n"; } echo "\n"; // 5. 期間の重なり判定 echo "=== 期間の重なり判定 ===\n"; -$q1 = LocalDateRange::closed( +$q1 = LocalDateRange::from( LocalDate::of(2024, 1, 1), - LocalDate::of(2024, 3, 31) + LocalDate::of(2024, 3, 31), + RangeType::CLOSED, ); -$q2 = LocalDateRange::closed( +$q2 = LocalDateRange::from( LocalDate::of(2024, 4, 1), - LocalDate::of(2024, 6, 30) + LocalDate::of(2024, 6, 30), + RangeType::CLOSED, ); -$marchToMay = LocalDateRange::closed( +$marchToMay = LocalDateRange::from( LocalDate::of(2024, 3, 1), - LocalDate::of(2024, 5, 31) + LocalDate::of(2024, 5, 31), + RangeType::CLOSED, ); echo "第1四半期: {$q1->toISOString()}\n"; @@ -81,9 +89,9 @@ // 6. 特定の日付が期間内かチェック echo "=== 期間内チェック ===\n"; -$vacation = LocalDateRange::closed( +$vacation = LocalDateRange::from( LocalDate::of(2024, 8, 10), - LocalDate::of(2024, 8, 20) + LocalDate::of(2024, 8, 20), ); $checkDate = LocalDate::of(2024, 8, 15); @@ -94,7 +102,8 @@ echo "=== エラーハンドリング ===\n"; $invalidResult = LocalDateRange::tryFrom( LocalDate::of(2024, 12, 31), - LocalDate::of(2024, 1, 1) + LocalDate::of(2024, 1, 1), + RangeType::CLOSED, ); if ($invalidResult->isErr()) { @@ -108,16 +117,12 @@ $startDate = LocalDate::of(2024, 1, 1); $endDate = null; -$optionRange = LocalDateRange::fromNullable($startDate, $endDate); -if ($optionRange->isNone()) { - echo "範囲を作成できませんでした(いずれかの値がnullです)\n"; -} - // 9. 年間カレンダーの例 echo "\n=== 年間カレンダーの例 ===\n"; -$year2024 = LocalDateRange::closed( +$year2024 = LocalDateRange::from( LocalDate::of(2024, 1, 1), - LocalDate::of(2024, 12, 31) + LocalDate::of(2024, 12, 31), + RangeType::CLOSED, ); echo "2024年: {$year2024->toISOString()}\n"; diff --git a/src/DateTime/LocalDate.php b/src/DateTime/LocalDate.php index 3ed6de3..6771c94 100644 --- a/src/DateTime/LocalDate.php +++ b/src/DateTime/LocalDate.php @@ -16,6 +16,10 @@ use WizDevelop\PhpValueObject\ValueObjectMeta; /** + * @phpstan-type Year int + * @phpstan-type Month int<1, 12> + * @phpstan-type Day int<1, 31> + * * ローカル日付を表す値オブジェクト */ #[ValueObjectMeta(name: 'ローカル日付')] @@ -43,9 +47,9 @@ /** * Avoid new() operator. - * @param int $year the year to represent, validated from MIN_YEAR to MAX_YEAR - * @param int<1, 12> $month the month, from 1 to 12 - * @param int<1, 31> $day the day, from 1 to 31 + * @param int $year the year to represent, validated from MIN_YEAR to MAX_YEAR + * @param Month $month the month, from 1 to 12 + * @param Day $day the day, from 1 to 31 */ final private function __construct( private int $year, @@ -85,9 +89,9 @@ final public function jsonSerialize(): string // MARK: factory methods // ------------------------------------------------------------------------- /** - * @param int $year the year to represent, validated from MIN_YEAR to MAX_YEAR - * @param int<1, 12> $month the month, from 1 to 12 - * @param int<1, 31> $day the day, from 1 to 31 + * @param int $year the year to represent, validated from MIN_YEAR to MAX_YEAR + * @param Month $month the month, from 1 to 12 + * @param Day $day the day, from 1 to 31 */ final public static function of(int $year, int $month, int $day): static { @@ -146,7 +150,7 @@ final public static function tryFromNullable(?DateTimeInterface $value): Result return static::tryFrom($value)->map(static fn ($result) => Option\some($result)); } - final public static function now(DateTimeZone $timeZone): static + final public static function now(DateTimeZone $timeZone = new DateTimeZone('Asia/Tokyo')): static { $value = new DateTimeImmutable('now', $timeZone); @@ -155,6 +159,11 @@ final public static function now(DateTimeZone $timeZone): static return static::of($year, $month, $day); } + final public static function max(): static + { + return static::of(self::MAX_YEAR, 12, 31); + } + /** * Obtains an instance of `LocalDate` from the epoch day count. * @@ -186,10 +195,10 @@ final public static function ofEpochDay(int $epochDay): static // Convert march-based values back to January-based. $marchMonth0 = intdiv($marchDoy0 * 5 + 2, 153); - /** @var int<1, 12> $month */ + /** @var Month $month */ $month = ($marchMonth0 + 2) % 12 + 1; - /** @var int<1, 31> $dom */ + /** @var Day $dom */ $dom = $marchDoy0 - intdiv($marchMonth0 * 306 + 5, 10) + 1; $yearEst += intdiv($marchMonth0, 10); @@ -260,16 +269,14 @@ className: static::class, ); } - - return Result\ok(true); } /** * 有効な日かどうかを判定 * @param int $year 年 - * @param int<1, 12> $monthOfYear 月 - * @param int<1, 31> $dayOfMonth 日 + * @param Month $monthOfYear 月 + * @param Day $dayOfMonth 日 * @return Result */ final protected static function isValidDate(int $year, int $monthOfYear, int $dayOfMonth): Result @@ -337,7 +344,7 @@ final public function toISOString(): string } /** - * @return int + * @return Year */ final public function getYear(): int { @@ -346,7 +353,7 @@ final public function getYear(): int } /** - * @return int<1, 12> + * @return Month */ final public function getMonth(): int { @@ -354,7 +361,7 @@ final public function getMonth(): int } /** - * @return int<1, 31> + * @return Day */ final public function getDay(): int { @@ -409,6 +416,11 @@ final public function compareTo(self $that): int return 0; } + final public function is(self $that): bool + { + return $this->compareTo($that) === 0; + } + final public function isBefore(self $that): bool { return $this->compareTo($that) === -1; @@ -463,7 +475,7 @@ final public function addMonths(int $months): static $yearDiff = Math::floorDiv($month, 12); - /** @var int<1, 12> $month */ + /** @var Month $month */ $month = Math::floorMod($month, 12) + 1; $year = $this->year + $yearDiff; @@ -591,17 +603,17 @@ final public function toEpochDay(): int // MARK: private methods // ------------------------------------------------------------------------- /** - * @return array{0:int, 1:int<1, 12>, 2:int<1, 31>} + * @return array{0:Year, 1:Month, 2:Day} */ private static function extractDate(DateTimeInterface $value): array { - /** @var int */ + /** @var Year */ $year = (int)$value->format('Y'); - /** @var int<1, 12> */ + /** @var Month */ $month = (int)$value->format('n'); - /** @var int<1, 31> */ + /** @var Day */ $day = (int)$value->format('j'); return [$year, $month, $day]; @@ -610,9 +622,9 @@ private static function extractDate(DateTimeInterface $value): array /** * Resolves the date, resolving days past the end of month. * - * @param int $year the year to represent, validated from MIN_YEAR to MAX_YEAR - * @param int<1, 12> $month the month-of-year to represent - * @param int<1, 31> $day the day-of-month to represent, validated from 1 to 31 + * @param int $year the year to represent, validated from MIN_YEAR to MAX_YEAR + * @param Month $month the month-of-year to represent + * @param Day $day the day-of-month to represent, validated from 1 to 31 */ private static function resolvePreviousValid(int $year, int $month, int $day): static { @@ -632,7 +644,7 @@ private static function isLeapYear(int $year): bool } /** - * @param int<1, 12> $month + * @param Month $month * @return int<28, 31> */ private static function lengthOfMonth(int $year, int $month): int @@ -646,8 +658,8 @@ private static function lengthOfMonth(int $year, int $month): int /** * Returns whether this date is the last day of the month. - * @param int<1, 12> $month - * @param int<1, 31> $day + * @param Month $month + * @param Day $day */ private static function isEndOfMonth(int $year, int $month, int $day): bool { diff --git a/src/DateTime/LocalDateRange.php b/src/DateTime/LocalDateRange.php index 0d98be5..ff062a6 100644 --- a/src/DateTime/LocalDateRange.php +++ b/src/DateTime/LocalDateRange.php @@ -4,34 +4,34 @@ namespace WizDevelop\PhpValueObject\DateTime; +use Countable; use Generator; +use IteratorAggregate; use Override; use Stringable; -use WizDevelop\PhpMonad\Option; use WizDevelop\PhpMonad\Result; use WizDevelop\PhpValueObject\Error\ValueObjectError; use WizDevelop\PhpValueObject\IValueObject; -use WizDevelop\PhpValueObject\ValueObjectMeta; /** + * @template TStart of LocalDate + * @template TEnd of LocalDate + * + * @implements IteratorAggregate + * * ローカル日付範囲を表す値オブジェクト */ -#[ValueObjectMeta(name: 'ローカル日付範囲')] -readonly class LocalDateRange implements IValueObject, Stringable +readonly class LocalDateRange implements IValueObject, Stringable, IteratorAggregate, Countable { - /** - * 最大日付(9999-12-31) - */ - private const MAX_DATE_YEAR = 9999; - private const MAX_DATE_MONTH = 12; - private const MAX_DATE_DAY = 31; - /** * Avoid new() operator. + * + * @param TStart $from 開始日付 + * @param TEnd $to 終了日付 */ final private function __construct( - private LocalDate $from, - private LocalDate $to, + private mixed $from, + private mixed $to, private RangeType $rangeType ) { // NOTE: 不変条件(invariant) @@ -44,7 +44,6 @@ final private function __construct( #[Override] final public function equals(IValueObject $other): bool { - return $this->from->equals($other->from) && $this->to->equals($other->to) && $this->rangeType === $other->rangeType; @@ -67,103 +66,47 @@ final public function jsonSerialize(): string // ------------------------------------------------------------------------- /** * 指定された開始日付、終了日付、範囲タイプでインスタンスを生成 + * + * @param TStart $from 開始日付 + * @param TEnd $to 終了日付 + * + * @return static */ final public static function from( - LocalDate $from, - ?LocalDate $to = null, - RangeType $rangeType = RangeType::CLOSED + mixed $from, + mixed $to, + RangeType $rangeType = RangeType::HALF_OPEN_RIGHT ): static { - $to ??= LocalDate::of(self::MAX_DATE_YEAR, self::MAX_DATE_MONTH, self::MAX_DATE_DAY); - return new static($from, $to, $rangeType); } /** - * 閉区間でインスタンスを生成 - */ - final public static function closed(LocalDate $from, ?LocalDate $to = null): static - { - return static::from($from, $to, RangeType::CLOSED); - } - - /** - * 開区間でインスタンスを生成 - */ - final public static function open(LocalDate $from, ?LocalDate $to = null): static - { - return static::from($from, $to, RangeType::OPEN); - } - - /** - * 左開区間でインスタンスを生成 - */ - final public static function halfOpenLeft(LocalDate $from, ?LocalDate $to = null): static - { - return static::from($from, $to, RangeType::HALF_OPEN_LEFT); - } - - /** - * 右開区間でインスタンスを生成 - */ - final public static function halfOpenRight(LocalDate $from, ?LocalDate $to = null): static - { - return static::from($from, $to, RangeType::HALF_OPEN_RIGHT); - } - - /** - * @return Result + * @param TStart $from 開始日付 + * @param TEnd $to 終了日付 + * + * @return Result,ValueObjectError> */ final public static function tryFrom( - LocalDate $from, - ?LocalDate $to = null, - RangeType $rangeType = RangeType::CLOSED + mixed $from, + mixed $to, + RangeType $rangeType = RangeType::HALF_OPEN_RIGHT ): Result { - $to ??= LocalDate::of(self::MAX_DATE_YEAR, self::MAX_DATE_MONTH, self::MAX_DATE_DAY); - return static::isValid($from, $to) ->andThen(static fn () => Result\ok(static::from($from, $to, $rangeType))); } - /** - * @return Option - */ - final public static function fromNullable( - ?LocalDate $from, - ?LocalDate $to = null, - RangeType $rangeType = RangeType::CLOSED - ): Option { - if ($from === null) { - return Option\none(); - } - - return Option\some(static::from($from, $to, $rangeType)); - } - - /** - * @return Result,ValueObjectError> - */ - final public static function tryFromNullable( - ?LocalDate $from, - ?LocalDate $to = null, - RangeType $rangeType = RangeType::CLOSED - ): Result { - if ($from === null) { - // @phpstan-ignore return.type - return Result\ok(Option\none()); - } - - // @phpstan-ignore return.type - return static::tryFrom($from, $to, $rangeType)->map(static fn ($result) => Option\some($result)); - } - // ------------------------------------------------------------------------- // MARK: validation methods // ------------------------------------------------------------------------- /** * 有効な値かどうか + * + * @param TStart $from 開始日付 + * @param TEnd $to 終了日付 + * * @return Result */ - protected static function isValid(LocalDate $from, LocalDate $to): Result + protected static function isValid(mixed $from, mixed $to): Result { if ($from->isAfter($to)) { return Result\err(ValueObjectError::of( @@ -211,6 +154,46 @@ final public function getRangeType(): RangeType return $this->rangeType; } + /** + * @param TStart $from 開始日付 + * + * @return static + */ + final public function withFrom(mixed $from): static + { + return static::from($from, $this->to, $this->rangeType); + } + + /** + * @param TEnd $to 終了日付 + * + * @return static + */ + final public function withTo(mixed $to): static + { + return static::from($this->from, $to, $this->rangeType); + } + + /** + * @param TStart $from 開始日付 + * + * @return Result,ValueObjectError> + */ + final public function tryWithFrom(mixed $from): Result + { + return static::tryFrom($from, $this->to, $this->rangeType); + } + + /** + * @param TEnd $to 終了日付 + * + * @return Result,ValueObjectError> + */ + final public function tryWithTo(mixed $to): Result + { + return static::tryFrom($this->from, $to, $this->rangeType); + } + /** * 指定された日付が範囲内に含まれるかを判定 */ @@ -231,25 +214,26 @@ final public function contains(LocalDate $date): bool /** * 他の範囲と重なりがあるかを判定 + * + * @param self $other */ final public function overlaps(self $other): bool { // 一方の範囲の終了が他方の開始より前の場合、重なりなし - if ($this->strictlyBefore($other) || $other->strictlyBefore($this)) { - return false; - } - + return !($this->strictlyBefore($other) || $other->strictlyBefore($this)); // 境界での接触を考慮 - return $this->hasOverlapAt($other); + // return $this->hasOverlapAt($other); } /** * この範囲が他の範囲より完全に前にあるかを判定 + * + * @param self $other */ - private function strictlyBefore(self $other): bool + final public function strictlyBefore(self $other): bool { return $this->to->isBefore($other->from) || ( - $this->to->equals($other->from) && ( + $this->to->is($other->from) && ( $this->rangeType === RangeType::OPEN || $this->rangeType === RangeType::HALF_OPEN_RIGHT || $other->rangeType === RangeType::OPEN @@ -258,24 +242,6 @@ private function strictlyBefore(self $other): bool ); } - /** - * 境界での重なりを考慮した判定 - */ - private function hasOverlapAt(self $other): bool - { - // 開始点での重なり判定 - $startOverlap = $this->contains($other->from) || $other->contains($this->from); - - // 終了点での重なり判定 - $endOverlap = $this->contains($other->to) || $other->contains($this->to); - - // 一方が他方を完全に含む場合 - $containment = ($this->from->isBeforeOrEqualTo($other->from) && $this->to->isAfterOrEqualTo($other->to)) - || ($other->from->isBeforeOrEqualTo($this->from) && $other->to->isAfterOrEqualTo($this->to)); - - return $startOverlap || $endOverlap || $containment; - } - /** * 範囲の日数を返す * 注意: 開区間の場合、実際の日数は計算結果より1日または2日少なくなる可能性があります @@ -299,7 +265,8 @@ final public function days(): int * 範囲に含まれる各日付を順に返すイテレータを取得 * @return Generator */ - final public function iterate(): Generator + #[Override] + final public function getIterator(): Generator { $current = match ($this->rangeType) { RangeType::CLOSED, RangeType::HALF_OPEN_RIGHT => $this->from, @@ -316,4 +283,17 @@ final public function iterate(): Generator $current = $current->addDays(1); } } + + /** + * Returns the number of days in this range. + */ + #[Override] + final public function count(): int + { + $count = $this->to->toEpochDay() - $this->from->toEpochDay() + 1; + + assert($count >= 0, 'Count must be non-negative'); + + return $count; + } } diff --git a/src/DateTime/LocalDateTime.php b/src/DateTime/LocalDateTime.php index bb8d879..6e8a31e 100644 --- a/src/DateTime/LocalDateTime.php +++ b/src/DateTime/LocalDateTime.php @@ -16,6 +16,15 @@ use WizDevelop\PhpValueObject\ValueObjectMeta; /** + * @phpstan-import-type Hour from LocalTime + * @phpstan-import-type Minute from LocalTime + * @phpstan-import-type Second from LocalTime + * @phpstan-import-type Micro from LocalTime + * + * @phpstan-import-type Year from LocalDate + * @phpstan-import-type Month from LocalDate + * @phpstan-import-type Day from LocalDate + * * ローカル日時を表す値オブジェクト */ #[ValueObjectMeta(name: 'ローカル日時')] @@ -161,7 +170,7 @@ final public function getTime(): LocalTime } /** - * @return int<-9999, 9999> + * @return Year */ final public function getYear(): int { @@ -169,7 +178,7 @@ final public function getYear(): int } /** - * @return int<1, 12> + * @return Month */ final public function getMonth(): int { @@ -177,7 +186,7 @@ final public function getMonth(): int } /** - * @return int<1, 31> + * @return Day */ final public function getDay(): int { @@ -185,7 +194,7 @@ final public function getDay(): int } /** - * @return int<0, 23> + * @return Hour */ final public function getHour(): int { @@ -193,7 +202,7 @@ final public function getHour(): int } /** - * @return int<0, 59> + * @return Minute */ final public function getMinute(): int { @@ -201,7 +210,7 @@ final public function getMinute(): int } /** - * @return int<0, 59> + * @return Second */ final public function getSecond(): int { @@ -209,7 +218,7 @@ final public function getSecond(): int } /** - * @return int<0, 999999> + * @return Micro */ final public function getMicro(): int { @@ -517,7 +526,7 @@ private function addWithOverflow(int $hours, int $minutes, int $seconds, int $mi $totMicros = $micros * $sign + $this->time->getMicro(); $totSeconds += Math::floorDiv($totMicros, LocalTime::MICROS_PER_SECOND); - /** @var int<0, 999999> */ + /** @var Micro */ $newMicro = Math::floorMod($totMicros, LocalTime::MICROS_PER_SECOND); $totDays += Math::floorDiv($totSeconds, LocalTime::SECONDS_PER_DAY); diff --git a/src/DateTime/LocalTime.php b/src/DateTime/LocalTime.php index cd754e7..a41e100 100644 --- a/src/DateTime/LocalTime.php +++ b/src/DateTime/LocalTime.php @@ -16,6 +16,11 @@ use WizDevelop\PhpValueObject\ValueObjectMeta; /** + * @phpstan-type Hour int<0, 23> + * @phpstan-type Minute int<0, 59> + * @phpstan-type Second int<0, 59> + * @phpstan-type Micro int<0, 999999> + * * ローカル時刻を表す値オブジェクト */ #[ValueObjectMeta(name: 'ローカル時刻')] @@ -33,10 +38,10 @@ /** * Avoid new() operator. - * @param int<0,23> $hour the hour, from 0 to 23 - * @param int<0,59> $minute the minute, from 0 to 59 - * @param int<0,59> $second the second, from 0 to 59 - * @param int<0,999999> $micro the micro-of-second, from 0 to 999,999 + * @param Hour $hour the hour, from 0 to 23 + * @param Minute $minute the minute, from 0 to 59 + * @param Second $second the second, from 0 to 59 + * @param Micro $micro the micro-of-second, from 0 to 999,999 */ final private function __construct( private int $hour, @@ -77,10 +82,10 @@ final public function jsonSerialize(): string // MARK: factory methods // ------------------------------------------------------------------------- /** - * @param int<0, 23> $hour the hour, from 0 to 23 - * @param int<0, 59> $minute the minute, from 0 to 59 - * @param int<0, 59> $second the second, from 0 to 59 - * @param int<0, 999999> $micro the micro-of-second, from 0 to 999,999 + * @param Hour $hour the hour, from 0 to 23 + * @param Minute $minute the minute, from 0 to 59 + * @param Second $second the second, from 0 to 59 + * @param Micro $micro the micro-of-second, from 0 to 999,999 */ final public static function of(int $hour, int $minute, int $second = 0, int $micro = 0): static { @@ -90,8 +95,8 @@ final public static function of(int $hour, int $minute, int $second = 0, int $mi /** * Creates a LocalTime instance from a number of seconds since midnight. * - * @param int<0, 86399> $secondOfDay the second-of-day, from 0 to 86,399 - * @param int<0, 999999> $microOfSecond the micro-of-second, from 0 to 999,999 + * @param int<0, 86399> $secondOfDay the second-of-day, from 0 to 86,399 + * @param Micro $microOfSecond the micro-of-second, from 0 to 999,999 */ final public static function ofSecondOfDay(int $secondOfDay, int $microOfSecond = 0): static { @@ -99,16 +104,16 @@ final public static function ofSecondOfDay(int $secondOfDay, int $microOfSecond // @phpstan-ignore-next-line assert($secondOfDay >= 0 && $secondOfDay < self::SECONDS_PER_DAY); - /** @var int<0, 23> */ + /** @var Hour */ $hours = intdiv($secondOfDay, self::SECONDS_PER_HOUR); /** @var int<0, 3599> */ $remainingSeconds = $secondOfDay - ($hours * self::SECONDS_PER_HOUR); - /** @var int<0, 59> */ + /** @var Minute */ $minutes = intdiv($remainingSeconds, self::SECONDS_PER_MINUTE); - /** @var int<0, 59> */ + /** @var Second */ $seconds = $remainingSeconds - ($minutes * self::SECONDS_PER_MINUTE); return new static($hours, $minutes, $seconds, $microOfSecond); @@ -307,7 +312,7 @@ final public function toISOString(): string } /** - * @return int<0,23> + * @return Hour */ final public function getHour(): int { @@ -315,7 +320,7 @@ final public function getHour(): int } /** - * @return int<0,59> + * @return Minute */ final public function getMinute(): int { @@ -323,7 +328,7 @@ final public function getMinute(): int } /** - * @return int<0,59> + * @return Second */ final public function getSecond(): int { @@ -331,7 +336,7 @@ final public function getSecond(): int } /** - * @return int<0,999999> + * @return Micro */ final public function getMicro(): int { @@ -448,7 +453,7 @@ final public function addMinutes(int $minutes): static return $this; } - /** @var int<0, 23> */ + /** @var Hour */ $hour = intdiv($newMofd, self::MINUTES_PER_HOUR); $minute = $newMofd % self::MINUTES_PER_HOUR; @@ -475,10 +480,10 @@ final public function addSeconds(int $seconds): static return $this; } - /** @var int<0, 23> */ + /** @var Hour */ $hour = intdiv($newSofd, self::SECONDS_PER_HOUR); - /** @var int<0, 59> */ + /** @var Minute */ $minute = intdiv($newSofd, self::SECONDS_PER_MINUTE) % self::MINUTES_PER_HOUR; $second = $newSofd % self::SECONDS_PER_MINUTE; @@ -567,20 +572,20 @@ final public function toDateTimeImmutable(): DateTimeImmutable // MARK: private methods // ------------------------------------------------------------------------- /** - * @return array{0:int<0,23>, 1:int<0,59>, 2:int<0,59>, 3:int<0,999999>} + * @return array{0:Hour, 1:Minute, 2:Second, 3:Micro} */ private static function extractTime(DateTimeInterface $value): array { - /** @var int<0,23> */ + /** @var Hour */ $hour = (int)$value->format('G'); - /** @var int<0,59> */ + /** @var Minute */ $minute = (int)$value->format('i'); - /** @var int<0,59> */ + /** @var Second */ $second = (int)$value->format('s'); - /** @var int<0,999999> */ + /** @var Micro */ $micro = (int)$value->format('u'); // @phpstan-ignore varTag.type return [$hour, $minute, $second, $micro]; diff --git a/tests/Unit/DateTime/LocalDateRangeTest.php b/tests/Unit/DateTime/LocalDateRangeTest.php index b28a2e1..b4db2b9 100644 --- a/tests/Unit/DateTime/LocalDateRangeTest.php +++ b/tests/Unit/DateTime/LocalDateRangeTest.php @@ -4,12 +4,27 @@ namespace WizDevelop\PhpValueObject\Tests\Unit\DateTime; +use AssertionError; use PHPUnit\Framework\TestCase; use WizDevelop\PhpValueObject\DateTime\LocalDate; use WizDevelop\PhpValueObject\DateTime\LocalDateRange; use WizDevelop\PhpValueObject\DateTime\RangeType; use WizDevelop\PhpValueObject\Error\ValueObjectError; +/** + * @phpstan-import-type Year from LocalDate + * @phpstan-import-type Month from LocalDate + * @phpstan-import-type Day from LocalDate + * + * @phpstan-type RangeData array{from: array{Year, Month, Day}, to: array{Year, Month, Day}, type: RangeType} + * + * @phpstan-type OverlapsTestCase array{ + * range1Data: RangeData, + * range2Data: RangeData, + * expectedOverlap: bool, + * description: string + * } + */ final class LocalDateRangeTest extends TestCase { public function test_閉区間で有効な範囲を作成できる(): void @@ -19,7 +34,7 @@ public function test_閉区間で有効な範囲を作成できる(): void $to = LocalDate::of(2024, 1, 31); // Act - $range = LocalDateRange::closed($from, $to); + $range = LocalDateRange::from($from, $to, RangeType::CLOSED); // Assert $this->assertSame($from, $range->getFrom()); @@ -35,7 +50,7 @@ public function test_開区間で有効な範囲を作成できる(): void $to = LocalDate::of(2024, 1, 31); // Act - $range = LocalDateRange::open($from, $to); + $range = LocalDateRange::from($from, $to, RangeType::OPEN); // Assert $this->assertSame(RangeType::OPEN, $range->getRangeType()); @@ -49,8 +64,8 @@ public function test_半開区間で有効な範囲を作成できる(): void $to = LocalDate::of(2024, 1, 31); // Act - $rangeLeft = LocalDateRange::halfOpenLeft($from, $to); - $rangeRight = LocalDateRange::halfOpenRight($from, $to); + $rangeLeft = LocalDateRange::from($from, $to, RangeType::HALF_OPEN_LEFT); + $rangeRight = LocalDateRange::from($from, $to, RangeType::HALF_OPEN_RIGHT); // Assert $this->assertSame(RangeType::HALF_OPEN_LEFT, $rangeLeft->getRangeType()); @@ -81,7 +96,7 @@ public function test_contains_閉区間の境界値を含む(): void // Arrange $from = LocalDate::of(2024, 1, 1); $to = LocalDate::of(2024, 1, 31); - $range = LocalDateRange::closed($from, $to); + $range = LocalDateRange::from($from, $to, RangeType::CLOSED); // Act & Assert $this->assertTrue($range->contains($from)); // 開始境界 @@ -96,7 +111,7 @@ public function test_contains_開区間の境界値を含まない(): void // Arrange $from = LocalDate::of(2024, 1, 1); $to = LocalDate::of(2024, 1, 31); - $range = LocalDateRange::open($from, $to); + $range = LocalDateRange::from($from, $to, RangeType::OPEN); // Act & Assert $this->assertFalse($range->contains($from)); // 開始境界 @@ -112,82 +127,347 @@ public function test_contains_半開区間の境界値(): void // Act & Assert // 左開区間 - $rangeLeft = LocalDateRange::halfOpenLeft($from, $to); + $rangeLeft = LocalDateRange::from($from, $to, RangeType::HALF_OPEN_LEFT); $this->assertFalse($rangeLeft->contains($from)); // 開始境界(含まない) $this->assertTrue($rangeLeft->contains($to)); // 終了境界(含む) // 右開区間 - $rangeRight = LocalDateRange::halfOpenRight($from, $to); + $rangeRight = LocalDateRange::from($from, $to, RangeType::HALF_OPEN_RIGHT); $this->assertTrue($rangeRight->contains($from)); // 開始境界(含む) $this->assertFalse($rangeRight->contains($to)); // 終了境界(含まない) } - public function test_overlaps_重なりがある範囲(): void - { - // Arrange - $range1 = LocalDateRange::closed( - LocalDate::of(2024, 1, 1), - LocalDate::of(2024, 1, 15) - ); - $range2 = LocalDateRange::closed( - LocalDate::of(2024, 1, 10), - LocalDate::of(2024, 1, 20) - ); - - // Act & Assert - $this->assertTrue($range1->overlaps($range2)); - $this->assertTrue($range2->overlaps($range1)); - } - - public function test_overlaps_重なりがない範囲(): void - { - // Arrange - $range1 = LocalDateRange::closed( - LocalDate::of(2024, 1, 1), - LocalDate::of(2024, 1, 10) - ); - $range2 = LocalDateRange::closed( - LocalDate::of(2024, 1, 20), - LocalDate::of(2024, 1, 31) - ); - - // Act & Assert - $this->assertFalse($range1->overlaps($range2)); - $this->assertFalse($range2->overlaps($range1)); - } - - public function test_overlaps_境界で接する範囲_閉区間(): void - { + /** + * overlapsメソッドの包括的なテストケース(DataProvider使用) + * RangeTypeの全組み合わせ(4×4 = 16パターン)と範囲の位置関係を網羅 + * + * @dataProvider provideOverlaps_comprehensiveCases + * @param RangeData $range1Data + * @param RangeData $range2Data + */ + public function test_overlaps_comprehensive( + array $range1Data, + array $range2Data, + bool $expectedOverlap, + string $description + ): void { // Arrange - $range1 = LocalDateRange::closed( - LocalDate::of(2024, 1, 1), - LocalDate::of(2024, 1, 15) + $range1 = LocalDateRange::from( + LocalDate::of($range1Data['from'][0], $range1Data['from'][1], $range1Data['from'][2]), + LocalDate::of($range1Data['to'][0], $range1Data['to'][1], $range1Data['to'][2]), + $range1Data['type'] ); - $range2 = LocalDateRange::closed( - LocalDate::of(2024, 1, 15), - LocalDate::of(2024, 1, 31) + $range2 = LocalDateRange::from( + LocalDate::of($range2Data['from'][0], $range2Data['from'][1], $range2Data['from'][2]), + LocalDate::of($range2Data['to'][0], $range2Data['to'][1], $range2Data['to'][2]), + $range2Data['type'] ); // Act & Assert - $this->assertTrue($range1->overlaps($range2)); // 境界で接触 - $this->assertTrue($range2->overlaps($range1)); + $this->assertSame($expectedOverlap, $range1->overlaps($range2), $description . ' (range1->overlaps(range2))'); + $this->assertSame($expectedOverlap, $range2->overlaps($range1), $description . ' (range2->overlaps(range1))'); } - public function test_overlaps_境界で接する範囲_開区間(): void + /** + * overlapsメソッドのテストデータプロバイダー + * + * @return array + */ + public static function provideOverlaps_comprehensiveCases(): iterable { - // Arrange - $range1 = LocalDateRange::open( - LocalDate::of(2024, 1, 1), - LocalDate::of(2024, 1, 15) - ); - $range2 = LocalDateRange::open( - LocalDate::of(2024, 1, 15), - LocalDate::of(2024, 1, 31) - ); - - // Act & Assert - $this->assertFalse($range1->overlaps($range2)); // 開区間では境界での接触は重なりとみなさない - $this->assertFalse($range2->overlaps($range1)); + return [ + // ========================================================================= + // 1. 完全に離れている範囲のテストケース(全RangeType組み合わせ) + // ========================================================================= + 'separated_CLOSED_CLOSED' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 10], 'type' => RangeType::CLOSED], + 'range2Data' => ['from' => [2024, 1, 20], 'to' => [2024, 1, 30], 'type' => RangeType::CLOSED], + 'expectedOverlap' => false, + 'description' => '完全に離れている範囲 (CLOSED vs CLOSED)', + ], + 'separated_CLOSED_OPEN' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 10], 'type' => RangeType::CLOSED], + 'range2Data' => ['from' => [2024, 1, 20], 'to' => [2024, 1, 30], 'type' => RangeType::OPEN], + 'expectedOverlap' => false, + 'description' => '完全に離れている範囲 (CLOSED vs OPEN)', + ], + 'separated_CLOSED_HALF_OPEN_LEFT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 10], 'type' => RangeType::CLOSED], + 'range2Data' => ['from' => [2024, 1, 20], 'to' => [2024, 1, 30], 'type' => RangeType::HALF_OPEN_LEFT], + 'expectedOverlap' => false, + 'description' => '完全に離れている範囲 (CLOSED vs HALF_OPEN_LEFT)', + ], + 'separated_CLOSED_HALF_OPEN_RIGHT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 10], 'type' => RangeType::CLOSED], + 'range2Data' => ['from' => [2024, 1, 20], 'to' => [2024, 1, 30], 'type' => RangeType::HALF_OPEN_RIGHT], + 'expectedOverlap' => false, + 'description' => '完全に離れている範囲 (CLOSED vs HALF_OPEN_RIGHT)', + ], + 'separated_OPEN_CLOSED' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 10], 'type' => RangeType::OPEN], + 'range2Data' => ['from' => [2024, 1, 20], 'to' => [2024, 1, 30], 'type' => RangeType::CLOSED], + 'expectedOverlap' => false, + 'description' => '完全に離れている範囲 (OPEN vs CLOSED)', + ], + 'separated_OPEN_OPEN' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 10], 'type' => RangeType::OPEN], + 'range2Data' => ['from' => [2024, 1, 20], 'to' => [2024, 1, 30], 'type' => RangeType::OPEN], + 'expectedOverlap' => false, + 'description' => '完全に離れている範囲 (OPEN vs OPEN)', + ], + 'separated_OPEN_HALF_OPEN_LEFT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 10], 'type' => RangeType::OPEN], + 'range2Data' => ['from' => [2024, 1, 20], 'to' => [2024, 1, 30], 'type' => RangeType::HALF_OPEN_LEFT], + 'expectedOverlap' => false, + 'description' => '完全に離れている範囲 (OPEN vs HALF_OPEN_LEFT)', + ], + 'separated_OPEN_HALF_OPEN_RIGHT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 10], 'type' => RangeType::OPEN], + 'range2Data' => ['from' => [2024, 1, 20], 'to' => [2024, 1, 30], 'type' => RangeType::HALF_OPEN_RIGHT], + 'expectedOverlap' => false, + 'description' => '完全に離れている範囲 (OPEN vs HALF_OPEN_RIGHT)', + ], + 'separated_HALF_OPEN_LEFT_CLOSED' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 10], 'type' => RangeType::HALF_OPEN_LEFT], + 'range2Data' => ['from' => [2024, 1, 20], 'to' => [2024, 1, 30], 'type' => RangeType::CLOSED], + 'expectedOverlap' => false, + 'description' => '完全に離れている範囲 (HALF_OPEN_LEFT vs CLOSED)', + ], + 'separated_HALF_OPEN_LEFT_OPEN' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 10], 'type' => RangeType::HALF_OPEN_LEFT], + 'range2Data' => ['from' => [2024, 1, 20], 'to' => [2024, 1, 30], 'type' => RangeType::OPEN], + 'expectedOverlap' => false, + 'description' => '完全に離れている範囲 (HALF_OPEN_LEFT vs OPEN)', + ], + 'separated_HALF_OPEN_LEFT_HALF_OPEN_LEFT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 10], 'type' => RangeType::HALF_OPEN_LEFT], + 'range2Data' => ['from' => [2024, 1, 20], 'to' => [2024, 1, 30], 'type' => RangeType::HALF_OPEN_LEFT], + 'expectedOverlap' => false, + 'description' => '完全に離れている範囲 (HALF_OPEN_LEFT vs HALF_OPEN_LEFT)', + ], + 'separated_HALF_OPEN_LEFT_HALF_OPEN_RIGHT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 10], 'type' => RangeType::HALF_OPEN_LEFT], + 'range2Data' => ['from' => [2024, 1, 20], 'to' => [2024, 1, 30], 'type' => RangeType::HALF_OPEN_RIGHT], + 'expectedOverlap' => false, + 'description' => '完全に離れている範囲 (HALF_OPEN_LEFT vs HALF_OPEN_RIGHT)', + ], + 'separated_HALF_OPEN_RIGHT_CLOSED' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 10], 'type' => RangeType::HALF_OPEN_RIGHT], + 'range2Data' => ['from' => [2024, 1, 20], 'to' => [2024, 1, 30], 'type' => RangeType::CLOSED], + 'expectedOverlap' => false, + 'description' => '完全に離れている範囲 (HALF_OPEN_RIGHT vs CLOSED)', + ], + 'separated_HALF_OPEN_RIGHT_OPEN' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 10], 'type' => RangeType::HALF_OPEN_RIGHT], + 'range2Data' => ['from' => [2024, 1, 20], 'to' => [2024, 1, 30], 'type' => RangeType::OPEN], + 'expectedOverlap' => false, + 'description' => '完全に離れている範囲 (HALF_OPEN_RIGHT vs OPEN)', + ], + 'separated_HALF_OPEN_RIGHT_HALF_OPEN_LEFT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 10], 'type' => RangeType::HALF_OPEN_RIGHT], + 'range2Data' => ['from' => [2024, 1, 20], 'to' => [2024, 1, 30], 'type' => RangeType::HALF_OPEN_LEFT], + 'expectedOverlap' => false, + 'description' => '完全に離れている範囲 (HALF_OPEN_RIGHT vs HALF_OPEN_LEFT)', + ], + 'separated_HALF_OPEN_RIGHT_HALF_OPEN_RIGHT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 10], 'type' => RangeType::HALF_OPEN_RIGHT], + 'range2Data' => ['from' => [2024, 1, 20], 'to' => [2024, 1, 30], 'type' => RangeType::HALF_OPEN_RIGHT], + 'expectedOverlap' => false, + 'description' => '完全に離れている範囲 (HALF_OPEN_RIGHT vs HALF_OPEN_RIGHT)', + ], + // ========================================================================= + // 2. 境界で接触する範囲のテストケース(全RangeType組み合わせ) + // ========================================================================= + 'touching_CLOSED_CLOSED' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::CLOSED], + 'range2Data' => ['from' => [2024, 1, 15], 'to' => [2024, 1, 30], 'type' => RangeType::CLOSED], + 'expectedOverlap' => true, + 'description' => '境界で接触(両方の境界値を含む) (CLOSED vs CLOSED)', + ], + 'touching_CLOSED_OPEN' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::CLOSED], + 'range2Data' => ['from' => [2024, 1, 15], 'to' => [2024, 1, 30], 'type' => RangeType::OPEN], + 'expectedOverlap' => false, + 'description' => '境界で接触(第1範囲は境界値を含み、第2範囲は含まない) (CLOSED vs OPEN)', + ], + 'touching_CLOSED_HALF_OPEN_LEFT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::CLOSED], + 'range2Data' => ['from' => [2024, 1, 15], 'to' => [2024, 1, 30], 'type' => RangeType::HALF_OPEN_LEFT], + 'expectedOverlap' => false, + 'description' => '境界で接触(第1範囲は境界値を含み、第2範囲は含まない) (CLOSED vs HALF_OPEN_LEFT)', + ], + 'touching_CLOSED_HALF_OPEN_RIGHT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::CLOSED], + 'range2Data' => ['from' => [2024, 1, 15], 'to' => [2024, 1, 30], 'type' => RangeType::HALF_OPEN_RIGHT], + 'expectedOverlap' => true, + 'description' => '境界で接触(両方の範囲が境界値を含む) (CLOSED vs HALF_OPEN_RIGHT)', + ], + 'touching_OPEN_CLOSED' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::OPEN], + 'range2Data' => ['from' => [2024, 1, 15], 'to' => [2024, 1, 30], 'type' => RangeType::CLOSED], + 'expectedOverlap' => false, + 'description' => '境界で接触(第1範囲は境界値を含まず、第2範囲は含む) (OPEN vs CLOSED)', + ], + 'touching_OPEN_OPEN' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::OPEN], + 'range2Data' => ['from' => [2024, 1, 15], 'to' => [2024, 1, 30], 'type' => RangeType::OPEN], + 'expectedOverlap' => false, + 'description' => '境界で接触(両方とも境界値を含まない) (OPEN vs OPEN)', + ], + 'touching_OPEN_HALF_OPEN_LEFT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::OPEN], + 'range2Data' => ['from' => [2024, 1, 15], 'to' => [2024, 1, 30], 'type' => RangeType::HALF_OPEN_LEFT], + 'expectedOverlap' => false, + 'description' => '境界で接触(両方とも境界値を含まない) (OPEN vs HALF_OPEN_LEFT)', + ], + 'touching_OPEN_HALF_OPEN_RIGHT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::OPEN], + 'range2Data' => ['from' => [2024, 1, 15], 'to' => [2024, 1, 30], 'type' => RangeType::HALF_OPEN_RIGHT], + 'expectedOverlap' => false, + 'description' => '境界で接触(第1範囲は境界値を含まず、第2範囲は含む) (OPEN vs HALF_OPEN_RIGHT)', + ], + 'touching_HALF_OPEN_LEFT_CLOSED' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::HALF_OPEN_LEFT], + 'range2Data' => ['from' => [2024, 1, 15], 'to' => [2024, 1, 30], 'type' => RangeType::CLOSED], + 'expectedOverlap' => true, + 'description' => '境界で接触(両方の範囲が境界値を含む) (HALF_OPEN_LEFT vs CLOSED)', + ], + 'touching_HALF_OPEN_LEFT_OPEN' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::HALF_OPEN_LEFT], + 'range2Data' => ['from' => [2024, 1, 15], 'to' => [2024, 1, 30], 'type' => RangeType::OPEN], + 'expectedOverlap' => false, + 'description' => '境界で接触(第1範囲は境界値を含み、第2範囲は含まない) (HALF_OPEN_LEFT vs OPEN)', + ], + 'touching_HALF_OPEN_LEFT_HALF_OPEN_LEFT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::HALF_OPEN_LEFT], + 'range2Data' => ['from' => [2024, 1, 15], 'to' => [2024, 1, 30], 'type' => RangeType::HALF_OPEN_LEFT], + 'expectedOverlap' => false, + 'description' => '境界で接触(第1範囲は境界値を含み、第2範囲は含まない) (HALF_OPEN_LEFT vs HALF_OPEN_LEFT)', + ], + 'touching_HALF_OPEN_LEFT_HALF_OPEN_RIGHT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::HALF_OPEN_LEFT], + 'range2Data' => ['from' => [2024, 1, 15], 'to' => [2024, 1, 30], 'type' => RangeType::HALF_OPEN_RIGHT], + 'expectedOverlap' => true, + 'description' => '境界で接触(両方の範囲が境界値を含む) (HALF_OPEN_LEFT vs HALF_OPEN_RIGHT)', + ], + 'touching_HALF_OPEN_RIGHT_CLOSED' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::HALF_OPEN_RIGHT], + 'range2Data' => ['from' => [2024, 1, 15], 'to' => [2024, 1, 30], 'type' => RangeType::CLOSED], + 'expectedOverlap' => false, + 'description' => '境界で接触(第1範囲は境界値を含まず、第2範囲は含む) (HALF_OPEN_RIGHT vs CLOSED)', + ], + 'touching_HALF_OPEN_RIGHT_OPEN' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::HALF_OPEN_RIGHT], + 'range2Data' => ['from' => [2024, 1, 15], 'to' => [2024, 1, 30], 'type' => RangeType::OPEN], + 'expectedOverlap' => false, + 'description' => '境界で接触(両方とも境界値を含まない) (HALF_OPEN_RIGHT vs OPEN)', + ], + 'touching_HALF_OPEN_RIGHT_HALF_OPEN_LEFT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::HALF_OPEN_RIGHT], + 'range2Data' => ['from' => [2024, 1, 15], 'to' => [2024, 1, 30], 'type' => RangeType::HALF_OPEN_LEFT], + 'expectedOverlap' => false, + 'description' => '境界で接触(両方とも境界値を含まない) (HALF_OPEN_RIGHT vs HALF_OPEN_LEFT)', + ], + 'touching_HALF_OPEN_RIGHT_HALF_OPEN_RIGHT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::HALF_OPEN_RIGHT], + 'range2Data' => ['from' => [2024, 1, 15], 'to' => [2024, 1, 30], 'type' => RangeType::HALF_OPEN_RIGHT], + 'expectedOverlap' => false, + 'description' => '境界で接触(第1範囲は境界値を含まず、第2範囲は含む) (HALF_OPEN_RIGHT vs HALF_OPEN_RIGHT)', + ], + // ========================================================================= + // 3. 重なっている範囲のテストケース(代表的なRangeType組み合わせ) + // ========================================================================= + 'overlapping_CLOSED_CLOSED' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::CLOSED], + 'range2Data' => ['from' => [2024, 1, 10], 'to' => [2024, 1, 20], 'type' => RangeType::CLOSED], + 'expectedOverlap' => true, + 'description' => '部分的に重なっている範囲 (CLOSED vs CLOSED)', + ], + 'overlapping_OPEN_OPEN' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::OPEN], + 'range2Data' => ['from' => [2024, 1, 10], 'to' => [2024, 1, 20], 'type' => RangeType::OPEN], + 'expectedOverlap' => true, + 'description' => '部分的に重なっている範囲 (OPEN vs OPEN)', + ], + 'overlapping_HALF_OPEN_LEFT_HALF_OPEN_RIGHT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::HALF_OPEN_LEFT], + 'range2Data' => ['from' => [2024, 1, 10], 'to' => [2024, 1, 20], 'type' => RangeType::HALF_OPEN_RIGHT], + 'expectedOverlap' => true, + 'description' => '部分的に重なっている範囲 (HALF_OPEN_LEFT vs HALF_OPEN_RIGHT)', + ], + 'overlapping_HALF_OPEN_RIGHT_HALF_OPEN_LEFT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 15], 'type' => RangeType::HALF_OPEN_RIGHT], + 'range2Data' => ['from' => [2024, 1, 10], 'to' => [2024, 1, 20], 'type' => RangeType::HALF_OPEN_LEFT], + 'expectedOverlap' => true, + 'description' => '部分的に重なっている範囲 (HALF_OPEN_RIGHT vs HALF_OPEN_LEFT)', + ], + // ========================================================================= + // 4. 一方が他方を完全に含む範囲のテストケース(代表的なRangeType組み合わせ) + // ========================================================================= + 'contains_CLOSED_CLOSED' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 31], 'type' => RangeType::CLOSED], + 'range2Data' => ['from' => [2024, 1, 10], 'to' => [2024, 1, 20], 'type' => RangeType::CLOSED], + 'expectedOverlap' => true, + 'description' => '第1範囲が第2範囲を完全に含む (CLOSED vs CLOSED)', + ], + 'contains_OPEN_OPEN' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 31], 'type' => RangeType::OPEN], + 'range2Data' => ['from' => [2024, 1, 10], 'to' => [2024, 1, 20], 'type' => RangeType::OPEN], + 'expectedOverlap' => true, + 'description' => '第1範囲が第2範囲を完全に含む (OPEN vs OPEN)', + ], + 'contains_CLOSED_OPEN' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 31], 'type' => RangeType::CLOSED], + 'range2Data' => ['from' => [2024, 1, 10], 'to' => [2024, 1, 20], 'type' => RangeType::OPEN], + 'expectedOverlap' => true, + 'description' => '第1範囲(閉区間)が第2範囲(開区間)を完全に含む (CLOSED vs OPEN)', + ], + 'contains_OPEN_CLOSED' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 31], 'type' => RangeType::OPEN], + 'range2Data' => ['from' => [2024, 1, 10], 'to' => [2024, 1, 20], 'type' => RangeType::CLOSED], + 'expectedOverlap' => true, + 'description' => '第1範囲(開区間)が第2範囲(閉区間)を完全に含む (OPEN vs CLOSED)', + ], + // ========================================================================= + // 5. 同一範囲のテストケース(全RangeType組み合わせ) + // ========================================================================= + 'identical_CLOSED_CLOSED' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 31], 'type' => RangeType::CLOSED], + 'range2Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 31], 'type' => RangeType::CLOSED], + 'expectedOverlap' => true, + 'description' => '同一範囲 (CLOSED vs CLOSED)', + ], + 'identical_OPEN_OPEN' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 31], 'type' => RangeType::OPEN], + 'range2Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 31], 'type' => RangeType::OPEN], + 'expectedOverlap' => true, + 'description' => '同一範囲 (OPEN vs OPEN)', + ], + 'identical_HALF_OPEN_LEFT_HALF_OPEN_LEFT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 31], 'type' => RangeType::HALF_OPEN_LEFT], + 'range2Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 31], 'type' => RangeType::HALF_OPEN_LEFT], + 'expectedOverlap' => true, + 'description' => '同一範囲 (HALF_OPEN_LEFT vs HALF_OPEN_LEFT)', + ], + 'identical_HALF_OPEN_RIGHT_HALF_OPEN_RIGHT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 31], 'type' => RangeType::HALF_OPEN_RIGHT], + 'range2Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 31], 'type' => RangeType::HALF_OPEN_RIGHT], + 'expectedOverlap' => true, + 'description' => '同一範囲 (HALF_OPEN_RIGHT vs HALF_OPEN_RIGHT)', + ], + 'identical_mixed_CLOSED_OPEN' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 31], 'type' => RangeType::CLOSED], + 'range2Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 31], 'type' => RangeType::OPEN], + 'expectedOverlap' => true, + 'description' => '同じ期間だが異なる区間タイプ (CLOSED vs OPEN)', + ], + 'identical_mixed_HALF_OPEN_LEFT_HALF_OPEN_RIGHT' => [ + 'range1Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 31], 'type' => RangeType::HALF_OPEN_LEFT], + 'range2Data' => ['from' => [2024, 1, 1], 'to' => [2024, 1, 31], 'type' => RangeType::HALF_OPEN_RIGHT], + 'expectedOverlap' => true, + 'description' => '同じ期間だが異なる区間タイプ (HALF_OPEN_LEFT vs HALF_OPEN_RIGHT)', + ], + ]; } public function test_days_閉区間の日数計算(): void @@ -195,7 +475,7 @@ public function test_days_閉区間の日数計算(): void // Arrange $from = LocalDate::of(2024, 1, 1); $to = LocalDate::of(2024, 1, 5); - $range = LocalDateRange::closed($from, $to); + $range = LocalDateRange::from($from, $to, RangeType::CLOSED); // Act $days = $range->days(); @@ -209,7 +489,7 @@ public function test_days_開区間の日数計算(): void // Arrange $from = LocalDate::of(2024, 1, 1); $to = LocalDate::of(2024, 1, 5); - $range = LocalDateRange::open($from, $to); + $range = LocalDateRange::from($from, $to, RangeType::OPEN); // Act $days = $range->days(); @@ -225,8 +505,8 @@ public function test_days_半開区間の日数計算(): void $to = LocalDate::of(2024, 1, 5); // Act - $daysLeft = LocalDateRange::halfOpenLeft($from, $to)->days(); - $daysRight = LocalDateRange::halfOpenRight($from, $to)->days(); + $daysLeft = LocalDateRange::from($from, $to, RangeType::HALF_OPEN_LEFT)->days(); + $daysRight = LocalDateRange::from($from, $to, RangeType::HALF_OPEN_RIGHT)->days(); // Assert $this->assertSame(4, $daysLeft); // 1日を含まず、5日を含む = 4日間 @@ -238,11 +518,11 @@ public function test_iterate_閉区間での日付の反復(): void // Arrange $from = LocalDate::of(2024, 1, 1); $to = LocalDate::of(2024, 1, 3); - $range = LocalDateRange::closed($from, $to); + $range = LocalDateRange::from($from, $to, RangeType::CLOSED); // Act $dates = []; - foreach ($range->iterate() as $date) { + foreach ($range->getIterator() as $date) { $dates[] = $date->toISOString(); } @@ -255,11 +535,11 @@ public function test_iterate_開区間での日付の反復(): void // Arrange $from = LocalDate::of(2024, 1, 1); $to = LocalDate::of(2024, 1, 5); - $range = LocalDateRange::open($from, $to); + $range = LocalDateRange::from($from, $to, RangeType::OPEN); // Act $dates = []; - foreach ($range->iterate() as $date) { + foreach ($range->getIterator() as $date) { $dates[] = $date->toISOString(); } @@ -272,8 +552,8 @@ public function test_equals_同じ範囲(): void // Arrange $from = LocalDate::of(2024, 1, 1); $to = LocalDate::of(2024, 1, 31); - $range1 = LocalDateRange::closed($from, $to); - $range2 = LocalDateRange::closed($from, $to); + $range1 = LocalDateRange::from($from, $to, RangeType::CLOSED); + $range2 = LocalDateRange::from($from, $to, RangeType::CLOSED); // Act & Assert $this->assertTrue($range1->equals($range2)); @@ -284,99 +564,421 @@ public function test_equals_異なる範囲(): void // Arrange $from = LocalDate::of(2024, 1, 1); $to = LocalDate::of(2024, 1, 31); - $range1 = LocalDateRange::closed($from, $to); - $range2 = LocalDateRange::open($from, $to); + $range1 = LocalDateRange::from($from, $to, RangeType::CLOSED); + $range2 = LocalDateRange::from($from, $to, RangeType::OPEN); // Act & Assert $this->assertFalse($range1->equals($range2)); // 範囲タイプが異なる } - public function test_fromNullable_両方の値がnullでない場合(): void + public function test_jsonSerialize(): void { // Arrange $from = LocalDate::of(2024, 1, 1); $to = LocalDate::of(2024, 1, 31); + $range = LocalDateRange::from($from, $to, RangeType::CLOSED); // Act - $option = LocalDateRange::fromNullable($from, $to); + $json = $range->jsonSerialize(); // Assert - $this->assertTrue($option->isSome()); - $range = $option->unwrap(); - $this->assertTrue($range->getFrom()->equals($from)); - $this->assertTrue($range->getTo()->equals($to)); + $this->assertSame('[2024-01-01, 2024-01-31]', $json); } - public function test_fromNullable_いずれかの値がnullの場合(): void + public function test_from_デフォルトは右開区間(): void { // Arrange $from = LocalDate::of(2024, 1, 1); + $to = LocalDate::of(2024, 1, 31); // Act - $option1 = LocalDateRange::fromNullable(null, $from); - $option2 = LocalDateRange::fromNullable($from, null); - $option3 = LocalDateRange::fromNullable(null, null); + $range = LocalDateRange::from($from, $to); // Assert - $this->assertTrue($option1->isNone()); - $this->assertTrue($option2->isSome()); // fromがnullでなければ、toは自動的に最大日付になる - $this->assertTrue($option3->isNone()); + $this->assertSame(RangeType::HALF_OPEN_RIGHT, $range->getRangeType()); + $this->assertSame('[2024-01-01, 2024-01-31)', $range->toISOString()); } - public function test_jsonSerialize(): void + public function test_withFrom_新しい開始日付で範囲を作成(): void { // Arrange $from = LocalDate::of(2024, 1, 1); $to = LocalDate::of(2024, 1, 31); - $range = LocalDateRange::closed($from, $to); + $newFrom = LocalDate::of(2024, 1, 15); + $range = LocalDateRange::from($from, $to, RangeType::CLOSED); // Act - $json = $range->jsonSerialize(); + $newRange = $range->withFrom($newFrom); // Assert - $this->assertSame('[2024-01-01, 2024-01-31]', $json); + $this->assertSame($newFrom, $newRange->getFrom()); + $this->assertSame($to, $newRange->getTo()); + $this->assertSame(RangeType::CLOSED, $newRange->getRangeType()); + $this->assertSame('[2024-01-15, 2024-01-31]', $newRange->toISOString()); + // 元の範囲は変更されていない + $this->assertSame($from, $range->getFrom()); + } + + public function test_withFrom_無効な範囲の場合例外が発生(): void + { + // Arrange + $from = LocalDate::of(2024, 1, 1); + $to = LocalDate::of(2024, 1, 15); + $newFrom = LocalDate::of(2024, 1, 20); // toより後の日付 + $range = LocalDateRange::from($from, $to); + + // Act & Assert + $this->expectException(AssertionError::class); + $range->withFrom($newFrom); } - public function test_from_デフォルトは開区間(): void + public function test_withTo_新しい終了日付で範囲を作成(): void { // Arrange $from = LocalDate::of(2024, 1, 1); $to = LocalDate::of(2024, 1, 31); + $newTo = LocalDate::of(2024, 1, 15); + $range = LocalDateRange::from($from, $to, RangeType::CLOSED); // Act + $newRange = $range->withTo($newTo); + + // Assert + $this->assertSame($from, $newRange->getFrom()); + $this->assertSame($newTo, $newRange->getTo()); + $this->assertSame(RangeType::CLOSED, $newRange->getRangeType()); + $this->assertSame('[2024-01-01, 2024-01-15]', $newRange->toISOString()); + // 元の範囲は変更されていない + $this->assertSame($to, $range->getTo()); + } + + public function test_withTo_無効な範囲の場合例外が発生(): void + { + // Arrange + $from = LocalDate::of(2024, 1, 20); + $to = LocalDate::of(2024, 1, 31); + $newTo = LocalDate::of(2024, 1, 15); // fromより前の日付 $range = LocalDateRange::from($from, $to); + // Act & Assert + $this->expectException(AssertionError::class); + $range->withTo($newTo); + } + + public function test_tryWithFrom_有効な開始日付の場合成功(): void + { + // Arrange + $from = LocalDate::of(2024, 1, 1); + $to = LocalDate::of(2024, 1, 31); + $newFrom = LocalDate::of(2024, 1, 15); + $range = LocalDateRange::from($from, $to, RangeType::CLOSED); + + // Act + $result = $range->tryWithFrom($newFrom); + // Assert - $this->assertSame(RangeType::CLOSED, $range->getRangeType()); - $this->assertSame('[2024-01-01, 2024-01-31]', $range->toISOString()); + $this->assertTrue($result->isOk()); + $newRange = $result->unwrap(); + $this->assertSame($newFrom, $newRange->getFrom()); + $this->assertSame($to, $newRange->getTo()); + $this->assertSame(RangeType::CLOSED, $newRange->getRangeType()); } - public function test_from_to引数省略時は最大日付になる(): void + public function test_tryWithFrom_無効な開始日付の場合エラー(): void { // Arrange $from = LocalDate::of(2024, 1, 1); + $to = LocalDate::of(2024, 1, 15); + $newFrom = LocalDate::of(2024, 1, 20); // toより後の日付 + $range = LocalDateRange::from($from, $to); // Act - $range = LocalDateRange::from($from); + $result = $range->tryWithFrom($newFrom); // Assert - $this->assertSame($from, $range->getFrom()); - $this->assertSame('9999-12-31', $range->getTo()->toISOString()); - $this->assertSame(RangeType::CLOSED, $range->getRangeType()); + $this->assertTrue($result->isErr()); + $error = $result->unwrapErr(); + $this->assertInstanceOf(ValueObjectError::class, $error); + $this->assertSame('value_object.date_range.invalid_range', $error->getCode()); + $this->assertSame('開始日付は終了日付以前である必要があります', $error->getMessage()); } - public function test_tryFrom_to引数省略時も正常に動作(): void + public function test_tryWithTo_有効な終了日付の場合成功(): void { // Arrange $from = LocalDate::of(2024, 1, 1); + $to = LocalDate::of(2024, 1, 31); + $newTo = LocalDate::of(2024, 1, 15); + $range = LocalDateRange::from($from, $to, RangeType::CLOSED); // Act - $result = LocalDateRange::tryFrom($from); + $result = $range->tryWithTo($newTo); // Assert $this->assertTrue($result->isOk()); - $range = $result->unwrap(); - $this->assertSame($from, $range->getFrom()); - $this->assertSame('9999-12-31', $range->getTo()->toISOString()); + $newRange = $result->unwrap(); + $this->assertSame($from, $newRange->getFrom()); + $this->assertSame($newTo, $newRange->getTo()); + $this->assertSame(RangeType::CLOSED, $newRange->getRangeType()); + } + + public function test_tryWithTo_無効な終了日付の場合エラー(): void + { + // Arrange + $from = LocalDate::of(2024, 1, 20); + $to = LocalDate::of(2024, 1, 31); + $newTo = LocalDate::of(2024, 1, 15); // fromより前の日付 + $range = LocalDateRange::from($from, $to); + + // Act + $result = $range->tryWithTo($newTo); + + // Assert + $this->assertTrue($result->isErr()); + $error = $result->unwrapErr(); + $this->assertInstanceOf(ValueObjectError::class, $error); + $this->assertSame('value_object.date_range.invalid_range', $error->getCode()); + $this->assertSame('開始日付は終了日付以前である必要があります', $error->getMessage()); + } + + public function test_strictlyBefore_完全に前にある範囲(): void + { + // Arrange + $range1 = LocalDateRange::from( + LocalDate::of(2024, 1, 1), + LocalDate::of(2024, 1, 10), + RangeType::CLOSED + ); + $range2 = LocalDateRange::from( + LocalDate::of(2024, 1, 20), + LocalDate::of(2024, 1, 31), + RangeType::CLOSED + ); + + // Act & Assert + $this->assertTrue($range1->strictlyBefore($range2)); + $this->assertFalse($range2->strictlyBefore($range1)); + } + + public function test_strictlyBefore_境界で接する範囲_閉区間(): void + { + // Arrange + $range1 = LocalDateRange::from( + LocalDate::of(2024, 1, 1), + LocalDate::of(2024, 1, 15), + RangeType::CLOSED + ); + $range2 = LocalDateRange::from( + LocalDate::of(2024, 1, 15), + LocalDate::of(2024, 1, 31), + RangeType::CLOSED + ); + + // Act & Assert + $this->assertFalse($range1->strictlyBefore($range2)); // 境界で接触しているため + $this->assertFalse($range2->strictlyBefore($range1)); + } + + public function test_strictlyBefore_境界で接する範囲_開区間(): void + { + // Arrange + $range1 = LocalDateRange::from( + LocalDate::of(2024, 1, 1), + LocalDate::of(2024, 1, 15), + RangeType::OPEN + ); + $range2 = LocalDateRange::from( + LocalDate::of(2024, 1, 15), + LocalDate::of(2024, 1, 31), + RangeType::OPEN + ); + + // Act & Assert + $this->assertTrue($range1->strictlyBefore($range2)); // 開区間では境界での接触も完全に前 + $this->assertFalse($range2->strictlyBefore($range1)); + } + + public function test_strictlyBefore_境界で接する範囲_右開区間と左開区間(): void + { + // Arrange + $range1 = LocalDateRange::from( + LocalDate::of(2024, 1, 1), + LocalDate::of(2024, 1, 15), + RangeType::HALF_OPEN_RIGHT + ); + $range2 = LocalDateRange::from( + LocalDate::of(2024, 1, 15), + LocalDate::of(2024, 1, 31), + RangeType::HALF_OPEN_LEFT + ); + + // Act & Assert + $this->assertTrue($range1->strictlyBefore($range2)); // 両方の境界が開いているため + $this->assertFalse($range2->strictlyBefore($range1)); + } + + public function test_strictlyBefore_重なる範囲(): void + { + // Arrange + $range1 = LocalDateRange::from( + LocalDate::of(2024, 1, 1), + LocalDate::of(2024, 1, 20), + RangeType::CLOSED + ); + $range2 = LocalDateRange::from( + LocalDate::of(2024, 1, 10), + LocalDate::of(2024, 1, 31), + RangeType::CLOSED + ); + + // Act & Assert + $this->assertFalse($range1->strictlyBefore($range2)); + $this->assertFalse($range2->strictlyBefore($range1)); + } + + public function test_strictlyBefore_境界で接する範囲_左開区間と右開区間の逆パターン(): void + { + // Arrange + $range1 = LocalDateRange::from( + LocalDate::of(2024, 1, 1), + LocalDate::of(2024, 1, 15), + RangeType::HALF_OPEN_LEFT + ); + $range2 = LocalDateRange::from( + LocalDate::of(2024, 1, 15), + LocalDate::of(2024, 1, 31), + RangeType::HALF_OPEN_RIGHT + ); + + // Act & Assert + $this->assertFalse($range1->strictlyBefore($range2)); // 左開区間の終了(含む)と右開区間の開始(含む)が接触 + $this->assertFalse($range2->strictlyBefore($range1)); + } + + public function test_strictlyBefore_閉区間と開区間の混在(): void + { + // Arrange + // 閉区間の後に開区間 + $range1 = LocalDateRange::from( + LocalDate::of(2024, 1, 1), + LocalDate::of(2024, 1, 15), + RangeType::CLOSED + ); + $range2 = LocalDateRange::from( + LocalDate::of(2024, 1, 15), + LocalDate::of(2024, 1, 31), + RangeType::OPEN + ); + + // Act & Assert + $this->assertTrue($range1->strictlyBefore($range2)); // 閉区間の終了(含む)と開区間の開始(含まない) + $this->assertFalse($range2->strictlyBefore($range1)); + + // 開区間の後に閉区間 + $range3 = LocalDateRange::from( + LocalDate::of(2024, 2, 1), + LocalDate::of(2024, 2, 15), + RangeType::OPEN + ); + $range4 = LocalDateRange::from( + LocalDate::of(2024, 2, 15), + LocalDate::of(2024, 2, 28), + RangeType::CLOSED + ); + + // Act & Assert + $this->assertTrue($range3->strictlyBefore($range4)); // 開区間の終了(含まない)と閉区間の開始(含む) + $this->assertFalse($range4->strictlyBefore($range3)); + } + + public function test_strictlyBefore_閉区間と半開区間の混在(): void + { + // Arrange + // 閉区間の後に左開区間 + $range1 = LocalDateRange::from( + LocalDate::of(2024, 1, 1), + LocalDate::of(2024, 1, 15), + RangeType::CLOSED + ); + $range2 = LocalDateRange::from( + LocalDate::of(2024, 1, 15), + LocalDate::of(2024, 1, 31), + RangeType::HALF_OPEN_LEFT + ); + + // Act & Assert + $this->assertTrue($range1->strictlyBefore($range2)); // 閉区間の終了(含む)と左開区間の開始(含まない) + $this->assertFalse($range2->strictlyBefore($range1)); + + // 右開区間の後に閉区間 + $range3 = LocalDateRange::from( + LocalDate::of(2024, 2, 1), + LocalDate::of(2024, 2, 15), + RangeType::HALF_OPEN_RIGHT + ); + $range4 = LocalDateRange::from( + LocalDate::of(2024, 2, 15), + LocalDate::of(2024, 2, 28), + RangeType::CLOSED + ); + + // Act & Assert + $this->assertTrue($range3->strictlyBefore($range4)); // 右開区間の終了(含まない)と閉区間の開始(含む) + $this->assertFalse($range4->strictlyBefore($range3)); + } + + public function test_count_閉区間の要素数(): void + { + // Arrange + $from = LocalDate::of(2024, 1, 1); + $to = LocalDate::of(2024, 1, 5); + $range = LocalDateRange::from($from, $to, RangeType::CLOSED); + + // Act + $count = $range->count(); + + // Assert + $this->assertSame(5, $count); // 1日から5日まで(両端含む)= 5要素 + } + + public function test_count_同じ日付の範囲(): void + { + // Arrange + $date = LocalDate::of(2024, 1, 1); + $range = LocalDateRange::from($date, $date, RangeType::CLOSED); + + // Act + $count = $range->count(); + + // Assert + $this->assertSame(1, $count); // 単一の日付 = 1要素 + } + + public function test_count_年をまたぐ範囲(): void + { + // Arrange + $from = LocalDate::of(2023, 12, 30); + $to = LocalDate::of(2024, 1, 2); + $range = LocalDateRange::from($from, $to, RangeType::CLOSED); + + // Act + $count = $range->count(); + + // Assert + $this->assertSame(4, $count); // 12/30, 12/31, 1/1, 1/2 = 4要素 + } + + public function test_count_大きな範囲(): void + { + // Arrange + $from = LocalDate::of(2024, 1, 1); + $to = LocalDate::of(2024, 12, 31); + $range = LocalDateRange::from($from, $to, RangeType::CLOSED); + + // Act + $count = $range->count(); + + // Assert + $this->assertSame(366, $count); // 2024年は閏年で366日 } }