chore: code cleanup
This commit is contained in:
1 parent
1a3640b15c
commit
5a01054b0e
65 files changed
+1046
-795
No files matched your search
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Model;
|
||||
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceGroup;
|
||||
use App\Entity\User;
|
||||
use App\Enum\ResourceType;
|
||||
use App\Model\Spot;
|
||||
use App\Model\SpotGroup;
|
||||
use App\Model\TimeRange;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SpotGroupTest extends TestCase
|
||||
{
|
||||
public function testSpotsArePartitionedByTheirRoom(): void
|
||||
{
|
||||
$north = $this->room('Raum Nord');
|
||||
$south = $this->room('Raum Süd');
|
||||
|
||||
$groups = SpotGroup::over([
|
||||
$this->spot('D1', $north),
|
||||
$this->spot('D2', $north),
|
||||
$this->spot('D3', $south),
|
||||
]);
|
||||
|
||||
self::assertCount(2, $groups);
|
||||
self::assertSame($north, $groups[0]->group);
|
||||
self::assertCount(2, $groups[0]->spots);
|
||||
self::assertSame($south, $groups[1]->group);
|
||||
self::assertCount(1, $groups[1]->spots);
|
||||
}
|
||||
|
||||
/**
|
||||
* The repository orders by group position, so the order spots arrive in is already the order
|
||||
* they should be drawn in -- grouping must not reshuffle it.
|
||||
*/
|
||||
public function testTheOrderSpotsArriveInIsKept(): void
|
||||
{
|
||||
$first = $this->room('Erster Raum');
|
||||
$second = $this->room('Zweiter Raum');
|
||||
|
||||
$groups = SpotGroup::over([$this->spot('D1', $second), $this->spot('D2', $first)]);
|
||||
|
||||
self::assertSame(['Zweiter Raum', 'Erster Raum'], array_map(
|
||||
static fn (SpotGroup $group): string => $group->group?->getLabel() ?? '',
|
||||
$groups,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Two unpersisted groups both have a null id, so grouping cannot key on it.
|
||||
*/
|
||||
public function testTwoUnsavedRoomsDoNotCollapseIntoOne(): void
|
||||
{
|
||||
$groups = SpotGroup::over([$this->spot('D1', $this->room('A')), $this->spot('D2', $this->room('B'))]);
|
||||
|
||||
self::assertCount(2, $groups);
|
||||
}
|
||||
|
||||
public function testUngroupedSpotsCollectIntoOneTrailingGroup(): void
|
||||
{
|
||||
$room = $this->room('Raum Nord');
|
||||
|
||||
$groups = SpotGroup::over([$this->spot('D1', null), $this->spot('D2', $room), $this->spot('D3', null)]);
|
||||
|
||||
self::assertCount(2, $groups);
|
||||
self::assertSame($room, $groups[0]->group);
|
||||
self::assertNull($groups[1]->group);
|
||||
self::assertCount(2, $groups[1]->spots);
|
||||
}
|
||||
|
||||
public function testAPlanWithNoGroupsAtAllIsOneNullGroup(): void
|
||||
{
|
||||
$groups = SpotGroup::over([$this->spot('P1', null), $this->spot('P2', null)]);
|
||||
|
||||
self::assertCount(1, $groups);
|
||||
self::assertNull($groups[0]->group);
|
||||
}
|
||||
|
||||
public function testNoSpotsMeansNoGroups(): void
|
||||
{
|
||||
self::assertSame([], SpotGroup::over([]));
|
||||
}
|
||||
|
||||
/**
|
||||
* FREE only, deliberately: a partly booked desk is already amber on the plan, and counting
|
||||
* it here would make the badge disagree with the colours beside it.
|
||||
*/
|
||||
public function testOnlyFullyFreeSpotsAreCounted(): void
|
||||
{
|
||||
$room = $this->room('Raum Nord');
|
||||
|
||||
$group = SpotGroup::over([
|
||||
$this->spot('D1', $room),
|
||||
$this->spot('D2', $room, [$this->booking(540, 780)]), // PARTLY
|
||||
$this->spot('D3', $room, [$this->booking(0, 1440)]), // TAKEN
|
||||
])[0];
|
||||
|
||||
self::assertCount(3, $group->spots);
|
||||
self::assertSame(1, $group->freeCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Booking> $bookings
|
||||
*/
|
||||
private function spot(string $label, ?ResourceGroup $group, array $bookings = []): Spot
|
||||
{
|
||||
$resource = new Resource(ResourceType::DESK, $label, strtolower($label));
|
||||
$resource->setGroup($group);
|
||||
|
||||
return Spot::for($resource, $bookings, TimeRange::wholeDay(), $this->viewer());
|
||||
}
|
||||
|
||||
private function booking(int $start, int $end): Booking
|
||||
{
|
||||
return new Booking(
|
||||
new UserWithId('[email protected]', 'Bea', 2),
|
||||
new Resource(ResourceType::DESK, 'X', 'x'),
|
||||
new \DateTimeImmutable('2026-10-01'),
|
||||
TimeRange::fromMinutes($start, $end),
|
||||
);
|
||||
}
|
||||
|
||||
private function room(string $label): ResourceGroup
|
||||
{
|
||||
return new ResourceGroup(ResourceType::DESK, $label);
|
||||
}
|
||||
|
||||
private function viewer(): User
|
||||
{
|
||||
return new UserWithId('[email protected]', 'Anna', 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?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('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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?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()]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user