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 $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'); } private function viewer(): User { return new UserWithId('anna@ep-reisen.de', 'Anna', 1); } private function someoneElse(): User { return new UserWithId('bea@ep-reisen.de', '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; } }