170 lines
6.2 KiB
PHP
170 lines
6.2 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Tests\Model;
|
||
|
||
use App\Model\TimeRange;
|
||
use PHPUnit\Framework\Attributes\DataProvider;
|
||
use PHPUnit\Framework\TestCase;
|
||
use Twig\Environment;
|
||
use Twig\Loader\ArrayLoader;
|
||
|
||
class TimeRangeTest extends TestCase
|
||
{
|
||
public function testAWholeDayRunsFromMidnightToMidnight(): void
|
||
{
|
||
$range = TimeRange::wholeDay();
|
||
|
||
self::assertTrue($range->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<string, array{?string, ?string, ?string}>
|
||
*/
|
||
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<TimeRange> $ranges
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private static function render(array $ranges): array
|
||
{
|
||
return array_map(static fn (TimeRange $range): string => $range->start().'–'.$range->end(), $ranges);
|
||
}
|
||
}
|