fix: allow booking the free part of a partly booked day

This commit is contained in:
fromme committed 2026-09-13 19:11:51 +02:00
1 parent a88ff7ff24
commit 78839838f0
15 files changed
+541 -60

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Tests\Booking;
use App\Booking\Spot;
use App\Booking\SpotState;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
class SpotTest extends TestCase
{
public function testAResourceWithNothingBookedIsFree(): void
{
$spot = $this->spot([]);
self::assertSame(SpotState::FREE, $spot->state);
self::assertTrue($spot->isBookable());
self::assertNull($spot->own);
}
/**
* The reported bug: somebody else holds 09:00-13:00, the board shows the whole day, and the
* afternoon is still bookable. This used to come out as TAKEN and carried no link at all.
*/
public function testAResourceBookedForPartOfTheDayIsStillBookable(): void
{
$spot = $this->spot([$this->booking($this->someoneElse(), 540, 780)]);
self::assertSame(SpotState::PARTLY, $spot->state);
self::assertTrue($spot->isBookable());
self::assertTrue($spot->isActionable());
}
public function testTheSuggestedWindowIsTheLongestFreeOne(): void
{
// Taken 09:00-13:00 leaves 00:00-09:00 (9h) and 13:00-24:00 (11h).
$spot = $this->spot([$this->booking($this->someoneElse(), 540, 780)]);
self::assertSame('13:00–24:00', (string) $spot->suggested());
}
public function testTheSuggestedWindowIsAlwaysBookable(): void
{
$taken = $this->booking($this->someoneElse(), 540, 780);
$spot = $this->spot([$taken]);
// The whole point of suggesting a gap rather than the board's span: submitting it must
// not collide with what is already there.
self::assertFalse($spot->suggested()->overlaps($taken->getTimeRange()));
}
public function testAResourceBookedAllDayIsTaken(): void
{
$spot = $this->spot([$this->booking($this->someoneElse(), 0, 1440)]);
self::assertSame(SpotState::TAKEN, $spot->state);
self::assertFalse($spot->isBookable());
self::assertFalse($spot->isActionable());
self::assertNull($spot->suggested());
}
public function testSeveralBookingsCanLeaveNothingFree(): void
{
$spot = $this->spot([
$this->booking($this->someoneElse(), 0, 720),
$this->booking($this->someoneElse(), 720, 1440),
]);
self::assertSame(SpotState::TAKEN, $spot->state);
}
public function testSeveralBookingsCanStillLeaveAGapBetweenThem(): void
{
$spot = $this->spot([
$this->booking($this->someoneElse(), 0, 540),
$this->booking($this->someoneElse(), 780, 1440),
]);
self::assertSame(SpotState::PARTLY, $spot->state);
self::assertSame('09:00–13:00', (string) $spot->suggested());
}
public function testYourOwnBookingWinsOverEverythingElse(): void
{
$viewer = $this->viewer();
$spot = Spot::for(
$this->resource(),
[$this->booking($viewer, 540, 780)],
TimeRange::wholeDay(),
$viewer,
);
self::assertSame(SpotState::MINE, $spot->state);
// Clicking opens the edit modal rather than the create one, so it is actionable but not
// offered as a new booking.
self::assertTrue($spot->isActionable());
self::assertNotNull($spot->own);
}
/**
* A narrowed board only asks about its own window, so what happens outside it is irrelevant.
*/
public function testStateIsJudgedWithinTheBoardsSpanOnly(): void
{
$spot = Spot::for(
$this->resource(),
[],
TimeRange::fromMinutes(780, 1020),
$this->viewer(),
);
self::assertSame(SpotState::FREE, $spot->state);
self::assertSame('13:00–17:00', (string) $spot->suggested());
}
/**
* @param list<Booking> $bookings
*/
private function spot(array $bookings): Spot
{
return Spot::for($this->resource(), $bookings, TimeRange::wholeDay(), $this->viewer());
}
private function booking(User $user, int $start, int $end): Booking
{
return new Booking($user, $this->resource(), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes($start, $end));
}
private function resource(): Resource
{
return new Resource(ResourceType::DESK, 'D1', 'desk-01');
}
private function viewer(): User
{
return new UserWithId('[email protected]', 'Anna', 1);
}
private function someoneElse(): User
{
return new UserWithId('[email protected]', 'Bea', 2);
}
}
/**
* Spot compares users by id, and an unpersisted User has none -- which would make every booking
* look like everyone's. This gives the fixtures stable ids without touching the database.
*/
class UserWithId extends User
{
public function __construct(string $email, string $name, private readonly int $fixedId)
{
parent::__construct($email, $name);
}
public function getId(): ?int
{
return $this->fixedId;
}
}
+61
View File
@@ -83,6 +83,57 @@ class TimeRangeTest extends TestCase
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
@@ -97,4 +148,14 @@ class TimeRangeTest extends TestCase
self::assertSame('no', $twig->render('t', ['range' => TimeRange::fromMinutes(9 * 60, 13 * 60)]));
self::assertSame('yes', $twig->render('t', ['range' => 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);
}
}