isAllDay()); self::assertSame(0, $range->startMinute); self::assertSame(1440, $range->endMinute); } public function testTheEndMustComeAfterTheStart(): void { $this->expectException(\InvalidArgumentException::class); TimeRange::fromMinutes(13 * 60, 9 * 60); } public function testARangeCannotLeaveTheDay(): void { $this->expectException(\InvalidArgumentException::class); TimeRange::fromMinutes(0, 1441); } public function testTouchingRangesDoNotOverlap(): void { $morning = TimeRange::fromMinutes(9 * 60, 13 * 60); $afternoon = TimeRange::fromMinutes(13 * 60, 17 * 60); // Half-open, so handing a desk over at 13:00 is not a collision. self::assertFalse($morning->overlaps($afternoon)); self::assertFalse($afternoon->overlaps($morning)); } public function testPartlyCoveringRangesOverlapBothWays(): void { $a = TimeRange::fromMinutes(9 * 60, 13 * 60); $b = TimeRange::fromMinutes(12 * 60, 17 * 60); self::assertTrue($a->overlaps($b)); self::assertTrue($b->overlaps($a)); } public function testAWholeDayOverlapsEverything(): void { self::assertTrue(TimeRange::wholeDay()->overlaps(TimeRange::fromMinutes(0, 15))); } #[DataProvider('urlPairs')] public function testParsingTheBoardsQueryParameters(?string $from, ?string $to, ?string $expected): void { $range = TimeRange::parse($from, $to); self::assertSame($expected, null === $range ? null : $range->start().'-'.$range->end()); } /** * @return iterable */ public static function urlPairs(): iterable { yield 'both given' => ['09:00', '13:00', '09:00-13:00']; yield 'only a start is open to the end of the day' => ['09:00', null, '09:00-24:00']; yield 'only an end is open from midnight' => [null, '13:00', '00:00-13:00']; yield 'neither means the whole day, i.e. no filter' => [null, null, null]; yield 'empty strings are the same as absent' => ['', '', null]; yield 'reversed is rejected' => ['13:00', '09:00', null]; yield 'equal is rejected' => ['09:00', '09:00', null]; yield 'nonsense is rejected' => ['halb zehn', '13:00', null]; yield 'out of range is rejected' => ['25:00', '26:00', null]; } public function testRemainingIsTheWholeSpanWhenNothingIsTaken(): void { $free = TimeRange::remaining(TimeRange::wholeDay(), []); self::assertSame(['00:00–24:00'], self::render($free)); } public function testRemainingSplitsAroundABookingInTheMiddle(): void { $free = TimeRange::remaining(TimeRange::wholeDay(), [TimeRange::fromMinutes(540, 780)]); self::assertSame(['00:00–09:00', '13:00–24:00'], self::render($free)); } public function testRemainingIsEmptyWhenTheSpanIsFullyCovered(): void { self::assertSame([], TimeRange::remaining(TimeRange::wholeDay(), [TimeRange::wholeDay()])); } public function testRemainingHandlesOverlappingAndUnsortedInput(): void { // Bookings on one resource cannot overlap, but the span may be covered by several that // touch, and nothing guarantees the order they arrive in. $free = TimeRange::remaining(TimeRange::wholeDay(), [ TimeRange::fromMinutes(780, 1020), TimeRange::fromMinutes(540, 900), ]); self::assertSame(['00:00–09:00', '17:00–24:00'], self::render($free)); } public function testRemainingIgnoresWhatFallsOutsideTheSpan(): void { $free = TimeRange::remaining(TimeRange::fromMinutes(780, 1020), [TimeRange::fromMinutes(0, 540)]); self::assertSame(['13:00–17:00'], self::render($free)); } public function testRemainingClipsABookingThatOverhangsTheSpan(): void { $free = TimeRange::remaining(TimeRange::fromMinutes(480, 1080), [TimeRange::fromMinutes(0, 600)]); self::assertSame(['10:00–18:00'], self::render($free)); } public function testDurationIsInMinutes(): void { self::assertSame(240, TimeRange::fromMinutes(540, 780)->durationInMinutes()); self::assertSame(1440, TimeRange::wholeDay()->durationInMinutes()); } /** * Regression guard for a trap that cost real debugging time: Twig resolves `range.allDay` by * looking for a method literally named `allDay` BEFORE it tries `isAllDay()`, and it happily * calls a static one. While the factory was called allDay(), every template read back a * freshly built all-day range -- always truthy -- so the whole board silently ignored the * selected span. Renaming the factory to wholeDay() is what fixes it; this keeps it fixed. */ public function testTheAllDayAccessorIsNotShadowedInTwig(): void { $twig = new Environment(new ArrayLoader(['t' => '{{ range.allDay ? "yes" : "no" }}'])); self::assertSame('no', $twig->render('t', ['range' => TimeRange::fromMinutes(9 * 60, 13 * 60)])); self::assertSame('yes', $twig->render('t', ['range' => TimeRange::wholeDay()])); } public function testAOneSidedRangeReadsAsAbOrBis(): void { self::assertSame('bis 13:00', (string) TimeRange::fromMinutes(TimeRange::DAY_START, 13 * 60)); self::assertSame('ab 13:00', (string) TimeRange::fromMinutes(13 * 60, TimeRange::DAY_END)); self::assertSame('09:00–13:00', (string) TimeRange::fromMinutes(9 * 60, 13 * 60)); self::assertSame('ganztägig', (string) TimeRange::wholeDay()); } /** * @param list $ranges * * @return list */ private static function render(array $ranges): array { return array_map(static fn (TimeRange $range): string => $range->start().'–'.$range->end(), $ranges); } }