feat: group resources into rooms on the floor plan

This commit is contained in:
fromme committed 2026-09-14 08:25:05 +02:00
1 parent faef4d70a8
commit 8caf618950
14 files changed
+653 -34

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Tests\Booking;
use App\Booking\Spot;
use App\Booking\SpotGroup;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceGroup;
use App\Entity\ResourceType;
use App\Entity\User;
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);
}
}
+104 -2
View File
@@ -7,6 +7,7 @@ namespace App\Tests\Controller;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceGroup;
use App\Entity\ResourceType;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
@@ -139,6 +140,107 @@ class FloorPlanTest extends WebTestCase
self::assertSame('Schreibtisch 01 — Bea K. 09:00–13:00, frei 13:00–24:00', $label);
}
public function testARoomIsDrawnWithItsNameAndHowManyAreFree(): void
{
$room = $this->room('Raum Nord', 'room-01');
$this->deskIn($room, 'desk-01', 'Schreibtisch 01');
$this->deskIn($room, 'desk-02', 'Schreibtisch 02');
$this->deskIn($room, 'desk-03', 'Schreibtisch 03');
self::assertSame(['Raum Nord · 3 frei'], $this->roomsOf($this->board()));
}
/**
* The badge counts what can still be had for the whole span, so a desk that is free only in
* the afternoon does not count -- it is already amber on the plan, and counting it would
* make the number disagree with the colour beside it.
*/
public function testAPartlyBookedDeskIsNotCountedAsFree(): void
{
$room = $this->room('Raum Nord', 'room-01');
$partly = $this->deskIn($room, 'desk-01', 'Schreibtisch 01');
$this->deskIn($room, 'desk-02', 'Schreibtisch 02');
$this->book($partly, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
self::assertSame(['Raum Nord · 1 frei'], $this->roomsOf($this->board()));
}
/**
* The count means the same thing as the colours beside it, so it is relative to the board's
* span: the desk that is unavailable for the whole day is free for an afternoon board.
*/
public function testTheFreeCountFollowsTheSelectedSpan(): void
{
$room = $this->room('Raum Nord', 'room-01');
$desk = $this->deskIn($room, 'desk-01', 'Schreibtisch 01');
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
self::assertSame(['Raum Nord · belegt'], $this->roomsOf($this->board()));
self::assertSame(['Raum Nord · 1 frei'], $this->roomsOf($this->board(query: '&from=14:00&to=18:00')));
}
public function testARoomWithNothingLeftSaysSoInsteadOfShowingAZero(): void
{
$room = $this->room('Raum Nord', 'room-01');
$desk = $this->deskIn($room, 'desk-01', 'Schreibtisch 01');
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 0, 1440);
self::assertSame(['Raum Nord · belegt'], $this->roomsOf($this->board()));
}
public function testRoomsAreDrawnInTheirConfiguredOrder(): void
{
$this->deskIn($this->room('Zweiter Raum', 'room-02', 1), 'desk-04', 'Schreibtisch 04');
$this->deskIn($this->room('Erster Raum', 'room-01', 0), 'desk-01', 'Schreibtisch 01');
self::assertSame(['Erster Raum · 1 frei', 'Zweiter Raum · 1 frei'], $this->roomsOf($this->board()));
}
/**
* A resource with no group is still bookable and still drawn -- the plan simply does not
* frame it. That is the path the car park runs on.
*/
public function testAnUngroupedDeskIsDrawnWithoutARoom(): void
{
$this->desk('desk-01');
$crawler = $this->board();
self::assertSame([], $this->roomsOf($crawler));
self::assertSame(['Schreibtisch 01'], $this->linesOf($crawler));
}
/**
* The room captions, which sit directly under the <svg> rather than inside a shape's <g>.
*
* @return list<string>
*/
private function roomsOf(\Symfony\Component\DomCrawler\Crawler $crawler): array
{
return $crawler->filter('svg > text')->each(
static fn (\Symfony\Component\DomCrawler\Crawler $node): string => trim(preg_replace('/\s+/', ' ', $node->text()) ?? ''),
);
}
private function room(string $label, string $svgElementId, int $position = 0): ResourceGroup
{
$group = new ResourceGroup(ResourceType::DESK, $label, $position, $svgElementId);
$this->em->persist($group);
$this->em->flush();
return $group;
}
private function deskIn(ResourceGroup $group, string $svgElementId, string $label): Resource
{
$resource = new Resource(ResourceType::DESK, $label, $svgElementId);
$resource->setGroup($group);
$this->em->persist($resource);
$this->em->flush();
return $resource;
}
/**
* @return list<string>
*/
@@ -149,11 +251,11 @@ class FloorPlanTest extends WebTestCase
);
}
private function board(?User $as = null): \Symfony\Component\DomCrawler\Crawler
private function board(?User $as = null, string $query = ''): \Symfony\Component\DomCrawler\Crawler
{
$this->client->loginUser($as ?? $this->user('[email protected]', 'Anna Berger'));
return $this->client->request('GET', '/book/desk?date=2026-10-01');
return $this->client->request('GET', '/book/desk?date=2026-10-01'.$query);
}
private function desk(string $svgElementId): Resource