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

+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260914091500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds resource groups (rooms of the office, areas of the car park)';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+2
View File
@@ -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')] ?? [],
];
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Booking;
use App\Entity\ResourceGroup;
/**
* The spots of one room, in draw order.
*
* The floor plan needs the resources of a room together so it can frame them, and it needs the
* room's own label and geometry key alongside them -- which is all this is. The grouping is done
* once in the presenter rather than in Twig, because deciding what goes where is not a
* presentation concern and a template cannot express it without a nested loop over every spot
* per room.
*/
final readonly class SpotGroup
{
/**
* @param list<Spot> $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<Spot> $spots
*
* @return list<self>
*/
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));
}
}
+63 -10
View File
@@ -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/_<type>.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/_<type>.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<string> $roles
*/
+18
View File
@@ -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;
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\ResourceGroupRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Where a bookable thing physically is: a room of the office, an area of the car park.
*
* A group belongs to exactly one ResourceType, because "Raum Nord" means nothing for parking.
* It is a description of the building, so it is configuration seeded alongside the resources
* rather than something a user creates.
*
* Note there is deliberately no method named `group`, `label` or `position` other than the plain
* accessors: Twig resolves `x.label` to a method of that literal name before trying getLabel(),
* and static or not it would win. See handover.md.
*/
#[ORM\Entity(repositoryClass: ResourceGroupRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_resource_group_label', columns: ['type', 'label'])]
class ResourceGroup
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(enumType: ResourceType::class)]
private ResourceType $type;
#[ORM\Column(length: 50)]
#[Assert\NotBlank]
private string $label;
/** Draw and list order within the type. */
#[ORM\Column]
private int $position = 0;
/**
* Id of the matching frame in the hand-authored floor plan SVG -- the same role svgElementId
* plays on Resource. Null for a group that is not drawn.
*/
#[ORM\Column(length: 50, nullable: true)]
private ?string $svgElementId = null;
public function __construct(ResourceType $type, string $label, int $position = 0, ?string $svgElementId = null)
{
$this->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;
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ResourceGroup;
use App\Entity\ResourceType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ResourceGroup>
*/
class ResourceGroupRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ResourceGroup::class);
}
/**
* @return list<ResourceGroup>
*/
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();
}
}
+10 -1
View File
@@ -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();
}
+30 -15
View File
@@ -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 } %}
<svg viewBox="0 0 520 486" class="w-full h-auto" role="group" aria-label="Raumplan Büro">
<rect x="10" y="18" width="500" height="448" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
<text x="20" y="12" class="fill-gray-400 text-xs font-semibold">Büro</text>
{% 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) }}
<svg viewBox="0 0 520 594" class="w-full h-auto" role="group" aria-label="Raumplan Büro">
{% 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 %}
</svg>
+12 -5
View File
@@ -16,10 +16,17 @@
{# The lane between the two rows. #}
<line x1="265" y1="36" x2="265" y2="310" class="stroke-gray-200" stroke-width="2" stroke-dasharray="6 6"></line>
{% 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 %}
</svg>
+21
View File
@@ -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) %}
<rect x="{{ geometry.x }}" y="{{ geometry.y }}"
width="{{ geometry.width }}" height="{{ geometry.height }}" rx="8"
class="fill-white stroke-gray-300" stroke-width="2"></rect>
<text x="{{ geometry.x + 10 }}" y="{{ geometry.y - 6 }}" class="fill-gray-400 text-xs font-semibold">
{{- label -}}
{%- if free > 0 %} <tspan class="fill-emerald-600">· {{ free }} frei</tspan>
{%- else %} <tspan class="fill-gray-400">· belegt</tspan>{% endif -%}
</text>
{% endmacro %}
{% macro spot(spot, geometry, width, height, selected_date, is_past, time_params) %}
{% set MAX_LINES = 3 %}
{% set state = spot.state.name %}
+5 -1
View File
@@ -14,7 +14,10 @@
{% for entry in series %}
<li class="flex items-center justify-between gap-4 py-3">
<div>
<div class="font-semibold">{{ entry.resource.label }}</div>
<div class="font-semibold">
{{ entry.resource.label }}
{%- if entry.resource.group %} <span class="font-normal text-gray-500">· {{ entry.resource.group.label }}</span>{% endif %}
</div>
<div class="text-sm text-gray-500">
{{ entry.describe }} · bis {{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}
</div>
@@ -47,6 +50,7 @@
</div>
<div class="text-sm text-gray-500">
{{ booking.resource.type.label }} · {{ booking.resource.label }}
{%- if booking.resource.group %} · {{ booking.resource.group.label }}{% endif %}
{% if booking.partOfSeries %}
<span class="badge bg-gray-100 text-gray-600 ml-1">Serie</span>
{% endif %}
+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