From 8caf618950baf885edc3618fd93686b792626c18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 14 Sep 2026 08:25:05 +0200 Subject: [PATCH] feat: group resources into rooms on the floor plan --- migrations/Version20260914091500.php | 35 ++++++ src/Booking/BoardPresenter.php | 2 + src/Booking/SpotGroup.php | 85 +++++++++++++ src/Command/SeedCommand.php | 73 +++++++++-- src/Entity/Resource.php | 18 +++ src/Entity/ResourceGroup.php | 96 +++++++++++++++ src/Repository/ResourceGroupRepository.php | 35 ++++++ src/Repository/ResourceRepository.php | 11 +- templates/board/plan/_desk.html.twig | 45 ++++--- templates/board/plan/_parking.html.twig | 17 ++- templates/board/plan/_spot.html.twig | 21 ++++ templates/booking/index.html.twig | 6 +- tests/Booking/SpotGroupTest.php | 137 +++++++++++++++++++++ tests/Controller/FloorPlanTest.php | 106 +++++++++++++++- 14 files changed, 653 insertions(+), 34 deletions(-) create mode 100644 migrations/Version20260914091500.php create mode 100644 src/Booking/SpotGroup.php create mode 100644 src/Entity/ResourceGroup.php create mode 100644 src/Repository/ResourceGroupRepository.php create mode 100644 tests/Booking/SpotGroupTest.php diff --git a/migrations/Version20260914091500.php b/migrations/Version20260914091500.php new file mode 100644 index 0000000..8b498fb --- /dev/null +++ b/migrations/Version20260914091500.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE resource_group (id INT AUTO_INCREMENT NOT NULL, type VARCHAR(255) NOT NULL, label VARCHAR(50) NOT NULL, position INT NOT NULL, svg_element_id VARCHAR(50) DEFAULT NULL, UNIQUE INDEX uniq_resource_group_label (type, label), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE resource ADD group_id INT DEFAULT NULL'); + // SET NULL rather than RESTRICT: a group is configuration describing the building, and + // removing a room must leave its desks bookable rather than blocking the delete. The + // plan falls back to drawing them without a frame. + $this->addSql('ALTER TABLE resource ADD CONSTRAINT FK_BC91F416FE54D947 FOREIGN KEY (group_id) REFERENCES resource_group (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_BC91F416FE54D947 ON resource (group_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE resource DROP FOREIGN KEY FK_BC91F416FE54D947'); + $this->addSql('DROP INDEX IDX_BC91F416FE54D947 ON resource'); + $this->addSql('ALTER TABLE resource DROP group_id'); + $this->addSql('DROP TABLE resource_group'); + } +} diff --git a/src/Booking/BoardPresenter.php b/src/Booking/BoardPresenter.php index 81ef02a..7ff2d0a 100644 --- a/src/Booking/BoardPresenter.php +++ b/src/Booking/BoardPresenter.php @@ -94,6 +94,8 @@ final readonly class BoardPresenter 'is_past' => $selected < $today, 'resources' => $resources, 'spots' => $spots, + // The same spots again, partitioned into the rooms the plan frames them with. + 'spot_groups' => SpotGroup::over(array_values($spots)), 'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [], ]; } diff --git a/src/Booking/SpotGroup.php b/src/Booking/SpotGroup.php new file mode 100644 index 0000000..0199f8e --- /dev/null +++ b/src/Booking/SpotGroup.php @@ -0,0 +1,85 @@ + $spots + */ + private function __construct( + public ?ResourceGroup $group, + public array $spots, + ) { + } + + /** + * Partitions spots by their resource's group, preserving the order they arrive in. + * + * Ungrouped spots collect into a single trailing entry whose group is null. That entry is + * not an error case: a kind of resource that is not split across rooms has no groups at all, + * and its plan draws its own frame instead. + * + * @param list $spots + * + * @return list + */ + public static function over(array $spots): array + { + $grouped = []; + $ungrouped = []; + + foreach ($spots as $spot) { + $group = $spot->resource->getGroup(); + + if (null === $group) { + $ungrouped[] = $spot; + + continue; + } + + // Keyed by object rather than by id: Doctrine's identity map already guarantees one + // instance per row, so this groups exactly as an id would -- and it also works for a + // group that has not been persisted yet, where every id is still null and every + // group would otherwise collapse into one. The repository orders by group, so + // insertion order is already draw order. + $grouped[spl_object_id($group)][] = $spot; + } + + $result = []; + foreach ($grouped as $spotsOfGroup) { + $result[] = new self($spotsOfGroup[0]->resource->getGroup(), $spotsOfGroup); + } + + if ([] !== $ungrouped) { + $result[] = new self(null, $ungrouped); + } + + return $result; + } + + /** + * How many of these can still be booked for the whole of the board's span. + * + * Deliberately FREE only, not isBookable(): the badge reads "3 frei", and a partly booked + * desk is already amber on the plan. Counting those too would make the number disagree with + * the colours next to it. + */ + public function freeCount(): int + { + return \count(array_filter($this->spots, static fn (Spot $spot): bool => SpotState::FREE === $spot->state)); + } +} diff --git a/src/Command/SeedCommand.php b/src/Command/SeedCommand.php index e7ca84b..8b61cc1 100644 --- a/src/Command/SeedCommand.php +++ b/src/Command/SeedCommand.php @@ -5,8 +5,10 @@ declare(strict_types=1); namespace App\Command; use App\Entity\Resource; +use App\Entity\ResourceGroup; use App\Entity\ResourceType; use App\Entity\User; +use App\Repository\ResourceGroupRepository; use App\Repository\ResourceRepository; use App\Repository\UserRepository; use App\Security\Role; @@ -19,12 +21,15 @@ use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\DependencyInjection\Attribute\When; /** - * Seeds the desks, parking spots and a handful of employees. + * Seeds the office rooms, the desks, the parking spots and a handful of employees. * - * Dev only, and idempotent: it matches on the natural key (resource label, user email) so it can - * be re-run after a migration without duplicating anything. The resource svgElementId values are - * the contract with templates/board/plan/_.html.twig -- change one and the other stops - * drawing that resource. + * Dev only, and idempotent: it matches on the natural key (group and resource label, user email) + * so it can be re-run after a migration without duplicating anything. The svgElementId values -- + * on both resources and groups -- are the contract with + * templates/board/plan/_.html.twig: change one and the other stops drawing it. + * + * The room names and their desk membership are placeholders standing in for the real office + * layout, and they only make sense together with the geometry in _desk.html.twig. */ #[When('dev')] #[AsCommand(name: 'app:seed', description: 'Seeds desks, parking spots and demo employees (dev only)')] @@ -33,6 +38,7 @@ final class SeedCommand extends Command public function __construct( private readonly EntityManagerInterface $em, private readonly ResourceRepository $resources, + private readonly ResourceGroupRepository $groups, private readonly UserRepository $users, ) { parent::__construct(); @@ -44,10 +50,30 @@ final class SeedCommand extends Command $created = 0; - for ($i = 1; $i <= 12; ++$i) { - $created += $this->ensureResource(ResourceType::DESK, \sprintf('Schreibtisch %02d', $i), \sprintf('desk-%02d', $i)); + // Four rooms of three desks, in the order they are drawn down the plan. + $rooms = []; + foreach ([ + ['Raum Nord', 'room-01'], + ['Raum Ost', 'room-02'], + ['Raum Süd', 'room-03'], + ['Besprechungsraum', 'room-04'], + ] as $position => [$label, $svgElementId]) { + [$room, $isNew] = $this->ensureGroup(ResourceType::DESK, $label, $position, $svgElementId); + $rooms[] = $room; + $created += $isNew; } + for ($i = 1; $i <= 12; ++$i) { + $created += $this->ensureResource( + ResourceType::DESK, + \sprintf('Schreibtisch %02d', $i), + \sprintf('desk-%02d', $i), + $rooms[intdiv($i - 1, 3)], + ); + } + + // Parking is deliberately left ungrouped: the car park is one area, and it is what keeps + // the "a plan without groups draws its own frame" path exercised by real data. for ($i = 1; $i <= 6; ++$i) { $created += $this->ensureResource(ResourceType::PARKING, \sprintf('Parkplatz %02d', $i), \sprintf('parking-%02d', $i)); } @@ -71,17 +97,44 @@ final class SeedCommand extends Command return Command::SUCCESS; } - private function ensureResource(ResourceType $type, string $label, string $svgElementId): int + private function ensureResource(ResourceType $type, string $label, string $svgElementId, ?ResourceGroup $group = null): int { - if (null !== $this->resources->findOneBy(['type' => $type, 'label' => $label])) { + $resource = $this->resources->findOneBy(['type' => $type, 'label' => $label]); + + if (null !== $resource) { + // Backfill only. A resource seeded before groups existed has no room and would never + // be drawn inside one, but a group somebody moved it into by hand is left alone. + if (null !== $group && null === $resource->getGroup()) { + $resource->setGroup($group); + } + return 0; } - $this->em->persist(new Resource($type, $label, $svgElementId)); + $resource = new Resource($type, $label, $svgElementId); + $resource->setGroup($group); + $this->em->persist($resource); return 1; } + /** + * @return array{ResourceGroup, int} the group, and 1 if it was created + */ + private function ensureGroup(ResourceType $type, string $label, int $position, string $svgElementId): array + { + $group = $this->groups->findOneBy(['type' => $type, 'label' => $label]); + + if (null !== $group) { + return [$group, 0]; + } + + $group = new ResourceGroup($type, $label, $position, $svgElementId); + $this->em->persist($group); + + return [$group, 1]; + } + /** * @param list $roles */ diff --git a/src/Entity/Resource.php b/src/Entity/Resource.php index 6d4e1b0..bb804bd 100644 --- a/src/Entity/Resource.php +++ b/src/Entity/Resource.php @@ -34,6 +34,14 @@ class Resource #[ORM\Column(length: 50, nullable: true)] private ?string $svgElementId = null; + /** + * Where it physically is. Nullable because a group is optional: a kind of resource that is + * not split across rooms simply has none, and its plan draws its own single frame. + */ + #[ORM\ManyToOne] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + private ?ResourceGroup $group = null; + /** * Retired resources stay in the table so historical bookings keep resolving; they are just * not offered any more. @@ -78,6 +86,16 @@ class Resource $this->svgElementId = $svgElementId; } + public function getGroup(): ?ResourceGroup + { + return $this->group; + } + + public function setGroup(?ResourceGroup $group): void + { + $this->group = $group; + } + public function isActive(): bool { return $this->active; diff --git a/src/Entity/ResourceGroup.php b/src/Entity/ResourceGroup.php new file mode 100644 index 0000000..3a301e7 --- /dev/null +++ b/src/Entity/ResourceGroup.php @@ -0,0 +1,96 @@ +type = $type; + $this->label = $label; + $this->position = $position; + $this->svgElementId = $svgElementId; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getType(): ResourceType + { + return $this->type; + } + + public function getLabel(): string + { + return $this->label; + } + + public function setLabel(string $label): void + { + $this->label = $label; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): void + { + $this->position = $position; + } + + public function getSvgElementId(): ?string + { + return $this->svgElementId; + } + + public function setSvgElementId(?string $svgElementId): void + { + $this->svgElementId = $svgElementId; + } +} diff --git a/src/Repository/ResourceGroupRepository.php b/src/Repository/ResourceGroupRepository.php new file mode 100644 index 0000000..c81cd1d --- /dev/null +++ b/src/Repository/ResourceGroupRepository.php @@ -0,0 +1,35 @@ + + */ +class ResourceGroupRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ResourceGroup::class); + } + + /** + * @return list + */ + public function findByType(ResourceType $type): array + { + return $this->createQueryBuilder('g') + ->andWhere('g.type = :type') + ->setParameter('type', $type) + ->orderBy('g.position', 'ASC') + ->addOrderBy('g.label', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Repository/ResourceRepository.php b/src/Repository/ResourceRepository.php index 910e27f..50609a2 100644 --- a/src/Repository/ResourceRepository.php +++ b/src/Repository/ResourceRepository.php @@ -24,11 +24,20 @@ class ResourceRepository extends ServiceEntityRepository */ public function findActiveByType(ResourceType $type): array { + // The group is selected, not just joined: the floor plan reads resource.group.label once + // per shape, so a lazy association would be one query per desk -- the same N+1 that + // findForDate() eager-loads user and resource to avoid. return $this->createQueryBuilder('r') + ->leftJoin('r.group', 'g') + ->addSelect('g') ->andWhere('r.active = true') ->andWhere('r.type = :type') ->setParameter('type', $type) - ->orderBy('r.label', 'ASC') + // Ungrouped resources sort last: MariaDB orders NULL first ascending, and a stray + // resource with no room should not open the plan. + ->orderBy('CASE WHEN g.id IS NULL THEN 1 ELSE 0 END', 'ASC') + ->addOrderBy('g.position', 'ASC') + ->addOrderBy('r.label', 'ASC') ->getQuery() ->getResult(); } diff --git a/templates/board/plan/_desk.html.twig b/templates/board/plan/_desk.html.twig index ab1f29d..d1feabc 100644 --- a/templates/board/plan/_desk.html.twig +++ b/templates/board/plan/_desk.html.twig @@ -1,32 +1,47 @@ {# - The office. Hand-authored geometry: `svgElementId` on each Resource is the key that ties a - database row to a shape here, so a resource whose id is missing from the map is simply not - drawn (and vice versa) -- app:seed and this map have to agree. + The office. Hand-authored geometry in two maps: `rooms` is keyed by ResourceGroup.svgElementId + and `geometry` by Resource.svgElementId, so a row whose id is missing from its map is simply + not drawn (and vice versa) -- app:seed and these maps have to agree. Box height is set by what goes inside it, not by taste: _spot.html.twig puts the label at +18 and up to three lines at +34, +47 and +60, so 86 leaves a comfortable margin below the last one. Changing MAX_LINES there means changing the height here. + A room band is 118 high: 16 of margin, an 86-high desk, 16 again. The 30 between two bands is + what the next room's caption sits in, since room() draws it above the rect. Moving a desk + between rooms therefore means moving its y here as well as its group in the database -- the + drawing is artwork, and no amount of indirection changes that. + Found by convention from ResourceType::DESK->value, see board/_floorplan.html.twig. #} {% import 'board/plan/_spot.html.twig' as plan %} +{% set rooms = { + 'room-01': { x: 10, y: 22, width: 500, height: 118 }, + 'room-02': { x: 10, y: 170, width: 500, height: 118 }, + 'room-03': { x: 10, y: 318, width: 500, height: 118 }, + 'room-04': { x: 10, y: 466, width: 500, height: 118 } +} %} + {% set geometry = { - 'desk-01': { x: 40, y: 50 }, 'desk-02': { x: 200, y: 50 }, 'desk-03': { x: 360, y: 50 }, - 'desk-04': { x: 40, y: 155 }, 'desk-05': { x: 200, y: 155 }, 'desk-06': { x: 360, y: 155 }, - 'desk-07': { x: 40, y: 260 }, 'desk-08': { x: 200, y: 260 }, 'desk-09': { x: 360, y: 260 }, - 'desk-10': { x: 40, y: 365 }, 'desk-11': { x: 200, y: 365 }, 'desk-12': { x: 360, y: 365 } + 'desk-01': { x: 40, y: 38 }, 'desk-02': { x: 200, y: 38 }, 'desk-03': { x: 360, y: 38 }, + 'desk-04': { x: 40, y: 186 }, 'desk-05': { x: 200, y: 186 }, 'desk-06': { x: 360, y: 186 }, + 'desk-07': { x: 40, y: 334 }, 'desk-08': { x: 200, y: 334 }, 'desk-09': { x: 360, y: 334 }, + 'desk-10': { x: 40, y: 482 }, 'desk-11': { x: 200, y: 482 }, 'desk-12': { x: 360, y: 482 } } %} {% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %} - - - Büro - - {% for resource in resources %} - {% if resource.svgElementId and geometry[resource.svgElementId] is defined %} - {{ plan.spot(spots[resource.id], geometry[resource.svgElementId], 140, 86, - selected_date, is_past, time_params) }} + + {% for spot_group in spot_groups %} + {% if spot_group.group and rooms[spot_group.group.svgElementId] is defined %} + {{ plan.room(spot_group.group.label, rooms[spot_group.group.svgElementId], spot_group.freeCount) }} {% endif %} + + {% for spot in spot_group.spots %} + {% if spot.resource.svgElementId and geometry[spot.resource.svgElementId] is defined %} + {{ plan.spot(spot, geometry[spot.resource.svgElementId], 140, 86, + selected_date, is_past, time_params) }} + {% endif %} + {% endfor %} {% endfor %} diff --git a/templates/board/plan/_parking.html.twig b/templates/board/plan/_parking.html.twig index ba99765..551eeea 100644 --- a/templates/board/plan/_parking.html.twig +++ b/templates/board/plan/_parking.html.twig @@ -16,10 +16,17 @@ {# The lane between the two rows. #} - {% for resource in resources %} - {% if resource.svgElementId and geometry[resource.svgElementId] is defined %} - {{ plan.spot(spots[resource.id], geometry[resource.svgElementId], 200, 78, - selected_date, is_past, time_params) }} - {% endif %} + {# + The car park is one area, so nothing here is grouped and every spot arrives in the single + null-group entry -- which is why this plan keeps drawing its own frame above instead of + letting room() do it. See board/plan/_desk.html.twig for the grouped case. + #} + {% for spot_group in spot_groups %} + {% for spot in spot_group.spots %} + {% if spot.resource.svgElementId and geometry[spot.resource.svgElementId] is defined %} + {{ plan.spot(spot, geometry[spot.resource.svgElementId], 200, 78, + selected_date, is_past, time_params) }} + {% endif %} + {% endfor %} {% endfor %} diff --git a/templates/board/plan/_spot.html.twig b/templates/board/plan/_spot.html.twig index 943e725..bb2ee3f 100644 --- a/templates/board/plan/_spot.html.twig +++ b/templates/board/plan/_spot.html.twig @@ -15,8 +15,29 @@ Clicking always opens a modal: the create modal when anything is still free, the edit modal when it is yours (which is where changing the times and releasing live). Nothing else is clickable, so the SVG carries no confirm dialogs or inline CSRF tokens. + + room() lives here for the same reason spot() does: a plan that frames its shapes into rooms + and one that does not must otherwise draw their borders in two places and drift apart. #} +{# + The frame around one room, captioned with its name and how many of its spots are still free + for the whole of the board's span. + + The caption sits above the rect rather than inside it, so it never collides with a spot box, + and the geometry only has to say where the room is. +#} +{% macro room(label, geometry, free) %} + + + {{- label -}} + {%- if free > 0 %} · {{ free }} frei + {%- else %} · belegt{% endif -%} + +{% endmacro %} + {% macro spot(spot, geometry, width, height, selected_date, is_past, time_params) %} {% set MAX_LINES = 3 %} {% set state = spot.state.name %} diff --git a/templates/booking/index.html.twig b/templates/booking/index.html.twig index 640dfe9..591e56c 100644 --- a/templates/booking/index.html.twig +++ b/templates/booking/index.html.twig @@ -14,7 +14,10 @@ {% for entry in series %}
  • -
    {{ entry.resource.label }}
    +
    + {{ entry.resource.label }} + {%- if entry.resource.group %} · {{ entry.resource.group.label }}{% endif %} +
    {{ entry.describe }} · bis {{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}
    @@ -47,6 +50,7 @@
    {{ booking.resource.type.label }} · {{ booking.resource.label }} + {%- if booking.resource.group %} · {{ booking.resource.group.label }}{% endif %} {% if booking.partOfSeries %} Serie {% endif %} diff --git a/tests/Booking/SpotGroupTest.php b/tests/Booking/SpotGroupTest.php new file mode 100644 index 0000000..782b625 --- /dev/null +++ b/tests/Booking/SpotGroupTest.php @@ -0,0 +1,137 @@ +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 $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('bea@ep-reisen.de', '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('anna@ep-reisen.de', 'Anna', 1); + } +} diff --git a/tests/Controller/FloorPlanTest.php b/tests/Controller/FloorPlanTest.php index 3f57af6..34b8229 100644 --- a/tests/Controller/FloorPlanTest.php +++ b/tests/Controller/FloorPlanTest.php @@ -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('bea@ep-reisen.de', '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('bea@ep-reisen.de', '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('bea@ep-reisen.de', '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 rather than inside a shape's . + * + * @return list + */ + 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 */ @@ -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('anna@ep-reisen.de', '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