167 lines
5.0 KiB
PHP
167 lines
5.0 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Tests\Model;
|
||
|
||
use App\Entity\Booking;
|
||
use App\Entity\Resource;
|
||
use App\Entity\User;
|
||
use App\Enum\ResourceType;
|
||
use App\Enum\SpotState;
|
||
use App\Model\Spot;
|
||
use App\Model\TimeRange;
|
||
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('ab 13: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');
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|