From ad5d5acb00ad40c5bab43db3bbbdc9916dfb51d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 17 Sep 2026 16:07:49 +0200 Subject: [PATCH] feat: render the floor plan in html instead of svg --- migrations/Version20260917120000.php | 28 ++++ src/Command/SeedCommand.php | 32 ++--- src/Entity/Resource.php | 22 +--- src/Entity/ResourceGroup.php | 20 +-- src/Enum/ResourceType.php | 7 +- src/Model/SpotGroup.php | 10 +- src/Service/BoardContextFactory.php | 2 +- templates/board/_floorplan.html.twig | 34 ++++- templates/board/_spot.html.twig | 99 +++++++++++++++ templates/board/plan/_desk.html.twig | 47 ------- templates/board/plan/_parking.html.twig | 32 ----- templates/board/plan/_spot.html.twig | 120 ------------------ templates/booking/_modal_edit.html.twig | 2 +- .../Admin/BookingAdminControllerTest.php | 19 ++- tests/Controller/BookingControllerTest.php | 20 ++- tests/Controller/DashboardControllerTest.php | 8 +- tests/Controller/FloorPlanTest.php | 102 ++++++++------- tests/Model/SpotGroupTest.php | 4 +- tests/Model/SpotTest.php | 2 +- tests/Service/BookingManagerTest.php | 2 +- 20 files changed, 256 insertions(+), 356 deletions(-) create mode 100644 migrations/Version20260917120000.php create mode 100644 templates/board/_spot.html.twig delete mode 100644 templates/board/plan/_desk.html.twig delete mode 100644 templates/board/plan/_parking.html.twig delete mode 100644 templates/board/plan/_spot.html.twig diff --git a/migrations/Version20260917120000.php b/migrations/Version20260917120000.php new file mode 100644 index 0000000..b8018b5 --- /dev/null +++ b/migrations/Version20260917120000.php @@ -0,0 +1,28 @@ +addSql('ALTER TABLE resource DROP svg_element_id'); + $this->addSql('ALTER TABLE resource_group DROP svg_element_id'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE resource ADD svg_element_id VARCHAR(50) DEFAULT NULL'); + $this->addSql('ALTER TABLE resource_group ADD svg_element_id VARCHAR(50) DEFAULT NULL'); + } +} diff --git a/src/Command/SeedCommand.php b/src/Command/SeedCommand.php index 123df1a..f69f24a 100644 --- a/src/Command/SeedCommand.php +++ b/src/Command/SeedCommand.php @@ -24,12 +24,10 @@ use Symfony\Component\DependencyInjection\Attribute\When; * Seeds the office rooms, the desks, the parking spots and a handful of employees. * * 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. + * so it can be re-run after a migration without duplicating anything. * * 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. + * layout. */ #[When('dev')] #[AsCommand(name: 'app:seed', description: 'Seeds desks, parking spots and demo employees (dev only)')] @@ -50,15 +48,10 @@ final class SeedCommand extends Command $created = 0; - // Four rooms of three desks, in the order they are drawn down the plan. + // Four rooms of three desks, in the order they are listed on 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); + foreach (['Raum Nord', 'Raum Ost', 'Raum Süd', 'Besprechungsraum'] as $position => $label) { + [$room, $isNew] = $this->ensureGroup(ResourceType::DESK, $label, $position); $rooms[] = $room; $created += $isNew; } @@ -67,15 +60,14 @@ final class SeedCommand extends Command $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. + // the "a plan without rooms" path exercised by real data. for ($i = 1; $i <= 6; ++$i) { - $created += $this->ensureResource(ResourceType::PARKING, \sprintf('Parkplatz %02d', $i), \sprintf('parking-%02d', $i)); + $created += $this->ensureResource(ResourceType::PARKING, \sprintf('Parkplatz %02d', $i)); } // One administrator on purpose, so the dev environment exercises both roles that open @@ -97,13 +89,13 @@ final class SeedCommand extends Command return Command::SUCCESS; } - private function ensureResource(ResourceType $type, string $label, string $svgElementId, ?ResourceGroup $group = null): int + private function ensureResource(ResourceType $type, string $label, ?ResourceGroup $group = null): int { $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. + // be listed under one, but a group somebody moved it into by hand is left alone. if (null !== $group && null === $resource->getGroup()) { $resource->setGroup($group); } @@ -111,7 +103,7 @@ final class SeedCommand extends Command return 0; } - $resource = new Resource($type, $label, $svgElementId); + $resource = new Resource($type, $label); $resource->setGroup($group); $this->em->persist($resource); @@ -121,7 +113,7 @@ final class SeedCommand extends Command /** * @return array{ResourceGroup, int} the group, and 1 if it was created */ - private function ensureGroup(ResourceType $type, string $label, int $position, string $svgElementId): array + private function ensureGroup(ResourceType $type, string $label, int $position): array { $group = $this->groups->findOneBy(['type' => $type, 'label' => $label]); @@ -129,7 +121,7 @@ final class SeedCommand extends Command return [$group, 0]; } - $group = new ResourceGroup($type, $label, $position, $svgElementId); + $group = new ResourceGroup($type, $label, $position); $this->em->persist($group); return [$group, 1]; diff --git a/src/Entity/Resource.php b/src/Entity/Resource.php index 025f750..a67ae5c 100644 --- a/src/Entity/Resource.php +++ b/src/Entity/Resource.php @@ -28,16 +28,9 @@ class Resource #[Assert\NotBlank] private string $label; - /** - * Id of the matching element in the hand-authored floor plan SVG. Null for anything that is - * bookable but not drawn on the plan. - */ - #[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. + * not split across rooms simply has none, and the plan lists it without a room heading. */ #[ORM\ManyToOne] #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] @@ -50,11 +43,10 @@ class Resource #[ORM\Column] private bool $active = true; - public function __construct(ResourceType $type, string $label, ?string $svgElementId = null) + public function __construct(ResourceType $type, string $label) { $this->type = $type; $this->label = $label; - $this->svgElementId = $svgElementId; } public function getId(): ?int @@ -77,16 +69,6 @@ class Resource $this->label = $label; } - public function getSvgElementId(): ?string - { - return $this->svgElementId; - } - - public function setSvgElementId(?string $svgElementId): void - { - $this->svgElementId = $svgElementId; - } - public function getGroup(): ?ResourceGroup { return $this->group; diff --git a/src/Entity/ResourceGroup.php b/src/Entity/ResourceGroup.php index 0604b71..9dcaf3a 100644 --- a/src/Entity/ResourceGroup.php +++ b/src/Entity/ResourceGroup.php @@ -40,19 +40,11 @@ class ResourceGroup #[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) + public function __construct(ResourceType $type, string $label, int $position = 0) { $this->type = $type; $this->label = $label; $this->position = $position; - $this->svgElementId = $svgElementId; } public function getId(): ?int @@ -84,14 +76,4 @@ class ResourceGroup { $this->position = $position; } - - public function getSvgElementId(): ?string - { - return $this->svgElementId; - } - - public function setSvgElementId(?string $svgElementId): void - { - $this->svgElementId = $svgElementId; - } } diff --git a/src/Enum/ResourceType.php b/src/Enum/ResourceType.php index b0343b4..1c51d04 100644 --- a/src/Enum/ResourceType.php +++ b/src/Enum/ResourceType.php @@ -8,10 +8,9 @@ namespace App\Enum; * The kinds of thing that can be booked. * * **This enum is the extension point.** The dashboard builds one tile per case, the board route - * accepts one backing value per case, and the floor plan is looked up by convention from that - * same value. Adding a third bookable kind is therefore: a case here, a - * `templates/board/plan/_.html.twig` with its geometry, and rows in `app:seed`. Nothing - * else needs editing — in particular no template lists the kinds by hand. + * accepts one backing value per case, and the floor plan renders every kind the same way. + * Adding a third bookable kind is therefore: a case here and rows in `app:seed`. + * Nothing else needs editing — in particular no template lists the kinds by hand. * * The backing value is both what is persisted in `resource.type` and what appears in a URL, so * there is no slug mapping to keep in sync. The label, plural and description are the only diff --git a/src/Model/SpotGroup.php b/src/Model/SpotGroup.php index ac7dbea..c0a8eb3 100644 --- a/src/Model/SpotGroup.php +++ b/src/Model/SpotGroup.php @@ -8,10 +8,10 @@ use App\Entity\ResourceGroup; use App\Enum\SpotState; /** - * The spots of one room, in draw order. + * The spots of one room, in display 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 + * The floor plan needs the resources of a room together so it can list them under a heading, and + * it needs the room's own label 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. @@ -32,7 +32,7 @@ final readonly class SpotGroup * * 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. + * and the plan lists its spots without a heading instead. * * @param list $spots * @@ -56,7 +56,7 @@ final readonly class SpotGroup // 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. + // insertion order is already display order. $grouped[spl_object_id($group)][] = $spot; } diff --git a/src/Service/BoardContextFactory.php b/src/Service/BoardContextFactory.php index 0fb8dcc..798bf19 100644 --- a/src/Service/BoardContextFactory.php +++ b/src/Service/BoardContextFactory.php @@ -97,7 +97,7 @@ final readonly class BoardContextFactory 'is_past' => $selected < $today, 'resources' => $resources, 'spots' => $spots, - // The same spots again, partitioned into the rooms the plan frames them with. + // The same spots again, partitioned into the rooms the plan lists them under. 'spot_groups' => SpotGroup::over(array_values($spots)), 'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [], ]; diff --git a/templates/board/_floorplan.html.twig b/templates/board/_floorplan.html.twig index 151238c..0c962fb 100644 --- a/templates/board/_floorplan.html.twig +++ b/templates/board/_floorplan.html.twig @@ -1,7 +1,6 @@ {# - The frame around a plan: heading, legend, span picker. The drawing itself lives in - board/plan/_.html.twig and is included by convention, so a new ResourceType needs a new - partial there and nothing else. + The plan: heading, legend, span picker, then one section per room with a card per spot. + Every ResourceType renders the same way, so a new one needs no template of its own. Colouring needs no JavaScript at all: the plan is re-rendered server side for the selected day and span, so free/taken/yours is decided in Twig. Note "free" is always relative to the span @@ -28,6 +27,33 @@ {% if resources is empty %}

Für diesen Bereich ist noch nichts eingerichtet.

{% else %} - {% include 'board/plan/_' ~ type.value ~ '.html.twig' %} + {% import 'board/_spot.html.twig' as plan %} + {% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %} + +
+ {# + Rooms in their configured order, then anything without one. Ungrouped spots get no + heading: a kind of resource that is not split across rooms (the car park) has + nothing to say there that the page heading has not already said. + #} + {% for spot_group in spot_groups %} +
+ {% if spot_group.group %} + {% set free = spot_group.freeCount %} +

+ {{- spot_group.group.label -}} + {%- if free > 0 %} · {{ free }} frei + {%- else %} · belegt{% endif -%} +

+ {% endif %} + +
+ {% for spot in spot_group.spots %} + {{ plan.spot(spot, selected_date, is_past, time_params) }} + {% endfor %} +
+
+ {% endfor %} +
{% endif %} diff --git a/templates/board/_spot.html.twig b/templates/board/_spot.html.twig new file mode 100644 index 0000000..bf0f198 --- /dev/null +++ b/templates/board/_spot.html.twig @@ -0,0 +1,99 @@ +{# + One bookable thing on the plan: a card coloured by its state, with who holds it listed inside. + + The state comes from App\Model\Spot, not from counting bookings here. That distinction is + the fix for a real bug: a desk booked 09:00-13:00 used to render exactly like one booked all + day on an all-day board, and carried no hx-get either -- so a second person could not book the + afternoon at all unless they happened to narrow the span by hand first. + + Who holds what is listed INSIDE the card rather than in a title tooltip: a tooltip only + appears on hover, so it is useless on touch and invisible when scanning the plan, which is the + one thing a floor plan is for. + + 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 a card carries no confirm dialogs or inline CSRF tokens. +#} +{% macro spot(spot, selected_date, is_past, time_params) %} + {% set MAX_LINES = 3 %} + {% set state = spot.state.name %} + + {% if state == 'MINE' %} + {% set colours = 'bg-primary border-primary-dark text-white' %} + {% elseif state == 'TAKEN' %} + {% set colours = 'bg-gray-200 border-gray-400 text-gray-600' %} + {% elseif state == 'PARTLY' %} + {% set colours = 'bg-amber-50 border-amber-500 text-amber-900' %} + {% else %} + {% set colours = 'bg-emerald-50 border-emerald-500 text-emerald-900' %} + {% endif %} + + {% set interactive = not is_past and spot.actionable %} + {# An administrator may free somebody else's desk -- a colleague calls in sick and the spot + should go back into the pool. Colours are untouched: MINE still means mine, and only + clickability widens. Past days stay inert for admins too; there is no past-write path. + + Only a card that is not already actionable: a partly booked one still opens the create + modal, because taking the free window is the more useful act there and an admin can reach + that booking from /admin/bookings anyway. #} + {% set manageable = not is_past and not interactive and is_granted('ROLE_ADMIN') and spot.bookings is not empty %} + + {# One line per booking, then the window still free if there is one -- that last line is the + reason a partly booked card is worth clicking, so it earns its place. #} + {% set lines = [] %} + {% for booking in spot.bookings %} + {% if booking.user.id == app.user.id %} + {% set who = 'du' %} + {% else %} + {# "Anna Berger" -> "Anna B." : keeps a card on one line per booking at phone width. #} + {% set parts = booking.user.name|split(' ')|filter(part => part is not empty) %} + {% set who = parts|length > 1 ? parts|first ~ ' ' ~ parts|last|slice(0, 1) ~ '.' : booking.user.name %} + {% endif %} + {% set lines = lines|merge([who ~ (booking.timeRange.allDay ? '' : ' ' ~ booking.timeRange)]) %} + {% endfor %} + {% if state == 'PARTLY' %} + {% set lines = lines|merge(['frei ' ~ spot.suggested]) %} + {% endif %} + + {# Summarise rather than let one busy desk stretch its whole row. Two lines plus a counter + beats three lines and a silent omission. #} + {% if lines|length > MAX_LINES %} + {% set lines = lines|slice(0, MAX_LINES - 1)|merge(['+' ~ (lines|length - MAX_LINES + 1) ~ ' weitere']) %} + {% endif %} + + {% set tag = interactive or manageable ? 'button' : 'div' %} + <{{ tag }} data-spot + {%- if tag == 'button' %} type="button"{% endif %} + class="flex min-h-[5.5rem] w-full flex-col items-center rounded-md border-2 px-2 py-2 text-center {{ colours }} + {{- tag == 'button' ? ' cursor-pointer hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2' : ' cursor-default' }}" + {%- if interactive and state == 'MINE' %} + hx-get="{{ path('app_booking_edit', { id: spot.own.id }|merge(time_params)) }}" + hx-target="body" + hx-swap="beforeend" + {%- elseif interactive %} + {# The modal is appended to the end of ; closing it is Stimulus, not htmx. + It prefills the free window itself -- see Booking\CreateController. #} + hx-get="{{ path('app_booking_new', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}" + hx-target="body" + hx-swap="beforeend" + {%- elseif manageable %} + {# Not app_booking_edit: a card can hold several bookings on one day, and an admin + releases rather than reschedules. The modal lists them and frees one. #} + hx-get="{{ path('app_admin_spot', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}" + hx-target="body" + hx-swap="beforeend" + {%- endif %}> + {{ spot.resource.label }} + + {% if lines is empty %} + {# The colour already says "free" to anyone who can see it. #} + frei + {% else %} +
    + {% for line in lines %} +
  • {{ line }}
  • + {% endfor %} +
+ {% endif %} + +{% endmacro %} diff --git a/templates/board/plan/_desk.html.twig b/templates/board/plan/_desk.html.twig deleted file mode 100644 index d1feabc..0000000 --- a/templates/board/plan/_desk.html.twig +++ /dev/null @@ -1,47 +0,0 @@ -{# - 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: 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 } %} - - - {% 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 deleted file mode 100644 index 551eeea..0000000 --- a/templates/board/plan/_parking.html.twig +++ /dev/null @@ -1,32 +0,0 @@ -{# - The car park. See board/plan/_desk.html.twig for how geometry, Resource.svgElementId and the - box height relate; found by convention from ResourceType::PARKING->value. -#} -{% import 'board/plan/_spot.html.twig' as plan %} - -{% set geometry = { - 'parking-01': { x: 40, y: 50 }, 'parking-02': { x: 40, y: 140 }, 'parking-03': { x: 40, y: 230 }, - 'parking-04': { x: 280, y: 50 }, 'parking-05': { x: 280, y: 140 }, 'parking-06': { x: 280, y: 230 } -} %} -{% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %} - - - - Parkplatz - {# The lane between the two rows. #} - - - {# - 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 deleted file mode 100644 index 870b4d3..0000000 --- a/templates/board/plan/_spot.html.twig +++ /dev/null @@ -1,120 +0,0 @@ -{# - The shared shape a plan is drawn from. Imported by each board/plan/_.html.twig, so the - per-type partials carry geometry only and the states look identical everywhere. - - The state comes from App\Model\Spot, not from counting bookings here. That distinction is - the fix for a real bug: a desk booked 09:00-13:00 used to render exactly like one booked all - day on an all-day board, and carried no hx-get either -- so a second person could not book the - afternoon at all unless they happened to narrow the span by hand first. - - Who holds what is listed INSIDE the shape rather than in an SVG flyout: a tooltip only - appears on hover, so it is useless on touch and invisible when scanning the plan, which is the - one thing a floor plan is for. The box therefore has a fixed layout -- label on top, one line - per booking below -- and the per-type partials size their boxes to fit MAX_LINES of them. - - 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 %} - - {% if state == 'MINE' %} - {% set fill = 'fill-primary' %}{% set stroke = 'stroke-primary-dark' %}{% set text = 'fill-white' %} - {% elseif state == 'TAKEN' %} - {% set fill = 'fill-gray-200' %}{% set stroke = 'stroke-gray-400' %}{% set text = 'fill-gray-600' %} - {% elseif state == 'PARTLY' %} - {% set fill = 'fill-amber-50' %}{% set stroke = 'stroke-amber-500' %}{% set text = 'fill-amber-900' %} - {% else %} - {% set fill = 'fill-emerald-50' %}{% set stroke = 'stroke-emerald-500' %}{% set text = 'fill-emerald-900' %} - {% endif %} - - {% set interactive = not is_past and spot.actionable %} - {# An administrator may free somebody else's desk -- a colleague calls in sick and the spot - should go back into the pool. Colours are untouched: MINE still means mine, and only - clickability widens. Past days stay inert for admins too; there is no past-write path. - - Only a shape that is not already actionable: a partly booked one still opens the create - modal, because taking the free window is the more useful act there and an admin can reach - that booking from /admin/bookings anyway. #} - {% set manageable = not is_past and not interactive and is_granted('ROLE_ADMIN') and spot.bookings is not empty %} - - {# One line per booking, then the window still free if there is one -- that last line is the - reason a partly booked shape is worth clicking, so it earns its place. #} - {% set lines = [] %} - {% for booking in spot.bookings %} - {% if booking.user.id == app.user.id %} - {% set who = 'du' %} - {% else %} - {# "Anna Berger" -> "Anna B." : full names do not fit a 140px box at this size. #} - {% set parts = booking.user.name|split(' ')|filter(part => part is not empty) %} - {% set who = parts|length > 1 ? parts|first ~ ' ' ~ parts|last|slice(0, 1) ~ '.' : booking.user.name %} - {% endif %} - {% set lines = lines|merge([who ~ (booking.timeRange.allDay ? '' : ' ' ~ booking.timeRange)]) %} - {% endfor %} - {% if state == 'PARTLY' %} - {% set lines = lines|merge(['frei ' ~ spot.suggested]) %} - {% endif %} - - {# Overflow rather than spill out of the box. Two lines plus a counter beats three lines and - a silent omission. #} - {% if lines|length > MAX_LINES %} - {% set lines = lines|slice(0, MAX_LINES - 1)|merge(['+' ~ (lines|length - MAX_LINES + 1) ~ ' weitere']) %} - {% endif %} - - <g class="{{ interactive or manageable ? 'cursor-pointer' : 'cursor-default' }}" - {%- if interactive or manageable %} role="button"{% endif %} - aria-label="{{ spot.resource.label }}{% if lines is empty %} — frei{% else %} — {{ lines|join(', ') }}{% endif %}" - {%- if interactive and state == 'MINE' %} - hx-get="{{ path('app_booking_edit', { id: spot.own.id }|merge(time_params)) }}" - hx-target="body" - hx-swap="beforeend" - {%- elseif interactive %} - {# The modal is appended to the end of <body>; closing it is Stimulus, not htmx. - It prefills the free window itself -- see Booking\CreateController. #} - hx-get="{{ path('app_booking_new', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}" - hx-target="body" - hx-swap="beforeend" - {%- elseif manageable %} - {# Not app_booking_edit: a shape can hold several bookings on one day, and an admin - releases rather than reschedules. The modal lists them and frees one. #} - hx-get="{{ path('app_admin_spot', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}" - hx-target="body" - hx-swap="beforeend" - {%- endif %}> - <rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6" - class="{{ fill }} {{ stroke }}" stroke-width="2"></rect> - - {# Nothing to list: the colour already says "free", so the label sits centred as before. #} - <text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + (lines is empty ? height / 2 + 4 : 18) }}" - text-anchor="middle" class="{{ text }} text-xs font-semibold">{{ spot.resource.label }}</text> - - {% for line in lines %} - <text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + 34 + loop.index0 * 13 }}" - text-anchor="middle" class="{{ text }} text-[10px]">{{ line }}</text> - {% endfor %} - </g> -{% endmacro %} diff --git a/templates/booking/_modal_edit.html.twig b/templates/booking/_modal_edit.html.twig index dde448f..c14c4cc 100644 --- a/templates/booking/_modal_edit.html.twig +++ b/templates/booking/_modal_edit.html.twig @@ -3,7 +3,7 @@ this day moves, the rest of the series is untouched and the occurrence is flagged as differing from it. - Releasing lives here too rather than on the floor plan, so the SVG needs no confirm dialogs + Releasing lives here too rather than on the floor plan, so a card needs no confirm dialogs or inline CSRF tokens -- clicking your own desk always just opens this. The release button opens the shared confirmation modal over this one. #} diff --git a/tests/Controller/Admin/BookingAdminControllerTest.php b/tests/Controller/Admin/BookingAdminControllerTest.php index 8488906..336a254 100644 --- a/tests/Controller/Admin/BookingAdminControllerTest.php +++ b/tests/Controller/Admin/BookingAdminControllerTest.php @@ -150,22 +150,22 @@ class BookingAdminControllerTest extends WebTestCase */ public function testAnOccupiedDeskIsClickableForAnAdminOnly(): void { - $desk = $this->resource('D1', ResourceType::DESK, 'desk-01'); + $desk = $this->resource('D1'); $this->booking($this->user('bea@ep-reisen.de', 'Bea Berger'), $desk); $date = (new \DateTimeImmutable('today'))->format('Y-m-d'); $this->client->loginUser($this->user('anna@ep-reisen.de')); $crawler = $this->client->request('GET', '/book/desk?date='.$date); - self::assertCount(0, $crawler->filter('g[hx-get*="/admin/spot/"]')); + self::assertCount(0, $crawler->filter('[data-spot][hx-get*="/admin/spot/"]')); $this->client->loginUser($this->admin()); $crawler = $this->client->request('GET', '/book/desk?date='.$date); - self::assertCount(1, $crawler->filter(\sprintf('g[hx-get="/admin/spot/%d/%s"]', $desk->getId(), $date))); + self::assertCount(1, $crawler->filter(\sprintf('[data-spot][hx-get="/admin/spot/%d/%s"]', $desk->getId(), $date))); } public function testTheSpotModalListsTheHolderAndReleasesTheBooking(): void { - $desk = $this->resource('D1', ResourceType::DESK, 'desk-01'); + $desk = $this->resource('D1'); $booking = $this->booking($this->user('bea@ep-reisen.de', 'Bea Berger'), $desk); $this->client->loginUser($this->admin()); @@ -185,9 +185,9 @@ class BookingAdminControllerTest extends WebTestCase */ public function testTheSpotModalShowsOnlyThatResourcesBookings(): void { - $desk = $this->resource('D1', ResourceType::DESK, 'desk-01'); + $desk = $this->resource('D1'); $this->booking($this->user('bea@ep-reisen.de', 'Bea Berger'), $desk); - $this->booking($this->user('carl@ep-reisen.de', 'Carl Conrad'), $this->resource('D2', ResourceType::DESK, 'desk-02')); + $this->booking($this->user('carl@ep-reisen.de', 'Carl Conrad'), $this->resource('D2')); $this->client->loginUser($this->admin()); $crawler = $this->client->request( @@ -273,12 +273,9 @@ class BookingAdminControllerTest extends WebTestCase return $user; } - /** - * The floor plan only draws resources whose svgElementId appears in the geometry map. - */ - private function resource(string $label, ResourceType $type = ResourceType::DESK, ?string $svgElementId = null): Resource + private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource { - $resource = new Resource($type, $label, $svgElementId); + $resource = new Resource($type, $label); $this->em->persist($resource); $this->em->flush(); diff --git a/tests/Controller/BookingControllerTest.php b/tests/Controller/BookingControllerTest.php index 7e11b5b..e042cb4 100644 --- a/tests/Controller/BookingControllerTest.php +++ b/tests/Controller/BookingControllerTest.php @@ -178,7 +178,7 @@ class BookingControllerTest extends WebTestCase public function testReleasingOwnBookingSwapsTheBoard(): void { $anna = $this->user('anna@ep-reisen.de'); - $booking = new Booking($anna, $this->resource('D1', svgElementId: 'desk-01'), new \DateTimeImmutable('2026-10-01')); + $booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01')); $this->em->persist($booking); $this->em->flush(); $id = $booking->getId(); @@ -205,7 +205,7 @@ class BookingControllerTest extends WebTestCase public function testTheFloorPlanOpensTheEditModalForYourOwnBooking(): void { $anna = $this->user('anna@ep-reisen.de'); - $booking = new Booking($anna, $this->resource('D1', svgElementId: 'desk-01'), new \DateTimeImmutable('2026-10-01')); + $booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01')); $this->em->persist($booking); $this->em->flush(); @@ -225,7 +225,7 @@ class BookingControllerTest extends WebTestCase */ public function testASecondPersonCanBookTheRestOfADayFromTheDefaultBoard(): void { - $desk = $this->resource('D1', svgElementId: 'desk-01'); + $desk = $this->resource('D1'); $this->em->persist(new Booking($this->user('bea@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780))); $this->em->flush(); @@ -233,7 +233,7 @@ class BookingControllerTest extends WebTestCase // 1. The shape is clickable on the all-day board. $crawler = $this->client->request('GET', '/book/desk?date=2026-10-01'); - $opener = $crawler->filter(\sprintf('g[hx-get*="/bookings/new/%d/"]', $desk->getId())); + $opener = $crawler->filter(\sprintf('[data-spot][hx-get*="/bookings/new/%d/"]', $desk->getId())); self::assertCount(1, $opener, 'a partly booked desk must still open the booking modal'); // 2. The modal it opens is prefilled with a window that is actually free. @@ -255,14 +255,14 @@ class BookingControllerTest extends WebTestCase public function testAFullyBookedDeskIsNotClickable(): void { - $desk = $this->resource('D1', svgElementId: 'desk-01'); + $desk = $this->resource('D1'); $this->em->persist(new Booking($this->user('bea@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-01'))); $this->em->flush(); $this->client->loginUser($this->user('anna@ep-reisen.de')); $crawler = $this->client->request('GET', '/book/desk?date=2026-10-01'); - self::assertCount(0, $crawler->filter(\sprintf('g[hx-get*="/bookings/new/%d/"]', $desk->getId()))); + self::assertCount(0, $crawler->filter(\sprintf('[data-spot][hx-get*="/bookings/new/%d/"]', $desk->getId()))); } public function testBookingPartOfADay(): void @@ -606,13 +606,9 @@ class BookingControllerTest extends WebTestCase return $user; } - /** - * Note $svgElementId: the floor plan only draws resources whose id appears in the geometry - * map in _floorplan.html.twig, so a test that needs to click a shape must use a real one. - */ - private function resource(string $label, ResourceType $type = ResourceType::DESK, ?string $svgElementId = null): Resource + private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource { - $resource = new Resource($type, $label, $svgElementId); + $resource = new Resource($type, $label); $this->em->persist($resource); $this->em->flush(); diff --git a/tests/Controller/DashboardControllerTest.php b/tests/Controller/DashboardControllerTest.php index 69607a3..9485393 100644 --- a/tests/Controller/DashboardControllerTest.php +++ b/tests/Controller/DashboardControllerTest.php @@ -121,8 +121,8 @@ class DashboardControllerTest extends WebTestCase public function testABoardOnlyShowsItsOwnKindOfResource(): void { - $this->resource('D1', svgElementId: 'desk-01'); - $this->resource('P1', ResourceType::PARKING, 'parking-01'); + $this->resource('D1'); + $this->resource('P1', ResourceType::PARKING); $this->client->loginUser($this->user()); @@ -163,9 +163,9 @@ class DashboardControllerTest extends WebTestCase return $user; } - private function resource(string $label, ResourceType $type = ResourceType::DESK, ?string $svgElementId = null): Resource + private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource { - $resource = new Resource($type, $label, $svgElementId); + $resource = new Resource($type, $label); $this->em->persist($resource); $this->em->flush(); diff --git a/tests/Controller/FloorPlanTest.php b/tests/Controller/FloorPlanTest.php index ef2de8a..bb19078 100644 --- a/tests/Controller/FloorPlanTest.php +++ b/tests/Controller/FloorPlanTest.php @@ -15,10 +15,10 @@ use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; /** - * What a shape on the floor plan says. + * What a card on the floor plan says. * - * Who holds what is listed inside the box rather than in an SVG <title>: a tooltip only shows on - * hover, which is useless on touch and invisible when scanning the plan. + * Who holds what is listed inside the card rather than in a title tooltip: a tooltip only shows + * on hover, which is useless on touch and invisible when scanning the plan. */ class FloorPlanTest extends WebTestCase { @@ -39,20 +39,20 @@ class FloorPlanTest extends WebTestCase parent::tearDown(); } - public function testNoShapeCarriesATooltip(): void + public function testNoCardCarriesATooltip(): void { - $desk = $this->desk('desk-01'); + $desk = $this->desk(); $this->book($desk, $this->user('bea@ep-reisen.de', 'Bea Kranz'), 540, 780); $crawler = $this->board(); - self::assertCount(0, $crawler->filter('svg title')); - self::assertCount(0, $crawler->filter('svg g title')); + self::assertCount(1, $crawler->filter('[data-spot]')); + self::assertCount(0, $crawler->filter('[data-spot][title], [data-spot] [title]')); } public function testABookingIsListedInsideTheShape(): void { - $desk = $this->desk('desk-01'); + $desk = $this->desk(); $this->book($desk, $this->user('bea@ep-reisen.de', 'Bea Kranz'), 540, 780); self::assertSame( @@ -62,11 +62,11 @@ class FloorPlanTest extends WebTestCase } /** - * Full names do not fit a 140px box at this size, so they are shortened. + * Full names are shortened so a card stays one line per booking at phone width. */ public function testALongNameIsShortenedToAFirstNameAndInitial(): void { - $desk = $this->desk('desk-01'); + $desk = $this->desk(); $this->book($desk, $this->user('x@ep-reisen.de', 'Maximilian Wolkenstein'), 0, 1440); self::assertContains('Maximilian W.', $this->linesOf($this->board())); @@ -74,7 +74,7 @@ class FloorPlanTest extends WebTestCase public function testASingleWordNameIsLeftAlone(): void { - $desk = $this->desk('desk-01'); + $desk = $this->desk(); $this->book($desk, $this->user('x@ep-reisen.de', 'Cher'), 0, 1440); self::assertContains('Cher', $this->linesOf($this->board())); @@ -82,7 +82,7 @@ class FloorPlanTest extends WebTestCase public function testYourOwnBookingReadsAsDu(): void { - $desk = $this->desk('desk-01'); + $desk = $this->desk(); $me = $this->user('anna@ep-reisen.de', 'Anna Berger'); $this->book($desk, $me, 540, 780); @@ -91,19 +91,19 @@ class FloorPlanTest extends WebTestCase public function testAnAllDayBookingOmitsTheTimes(): void { - $desk = $this->desk('desk-01'); + $desk = $this->desk(); $this->book($desk, $this->user('bea@ep-reisen.de', 'Bea Kranz'), 0, 1440); self::assertSame(['Schreibtisch 01', 'Bea K.'], $this->linesOf($this->board())); } /** - * A box fits three lines. More than that is summarised rather than allowed to spill out -- + * A card lists three lines. More than that is summarised rather than allowed to spill out -- * and the counter has to account for the line the counter itself occupies. */ public function testTooManyBookingsAreSummarised(): void { - $desk = $this->desk('desk-01'); + $desk = $this->desk(); $this->book($desk, $this->user('a@ep-reisen.de', 'Ann Alpha'), 0, 360); $this->book($desk, $this->user('b@ep-reisen.de', 'Bea Beta'), 360, 720); $this->book($desk, $this->user('c@ep-reisen.de', 'Cem Gamma'), 720, 1080); @@ -119,33 +119,29 @@ class FloorPlanTest extends WebTestCase self::assertLessThanOrEqual(4, \count($lines)); } - public function testAFreeShapeShowsOnlyItsLabel(): void + public function testAFreeCardShowsOnlyItsLabel(): void { - $this->desk('desk-01'); + $this->desk(); self::assertSame(['Schreibtisch 01'], $this->linesOf($this->board())); } /** - * Screen readers get the same information the box shows, because role="img" on the <svg> - * would otherwise hide every one of those <text> elements. + * Free is otherwise only a colour, which a screen reader cannot see. */ - public function testTheShapeCarriesAnAccessibleLabel(): void + public function testAFreeCardSaysSoToScreenReaders(): void { - $desk = $this->desk('desk-01'); - $this->book($desk, $this->user('bea@ep-reisen.de', 'Bea Kranz'), 540, 780); + $this->desk(); - $label = $this->board()->filter('svg g[aria-label]')->attr('aria-label'); - - self::assertSame('Schreibtisch 01 — Bea K. 09:00–13:00, frei 13:00–24:00', $label); + self::assertSame('frei', $this->board()->filter('[data-spot] .sr-only')->text()); } 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'); + $room = $this->room('Raum Nord'); + $this->deskIn($room, 'Schreibtisch 01'); + $this->deskIn($room, 'Schreibtisch 02'); + $this->deskIn($room, 'Schreibtisch 03'); self::assertSame(['Raum Nord · 3 frei'], $this->roomsOf($this->board())); } @@ -157,9 +153,9 @@ class FloorPlanTest extends WebTestCase */ 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'); + $room = $this->room('Raum Nord'); + $partly = $this->deskIn($room, 'Schreibtisch 01'); + $this->deskIn($room, '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())); @@ -171,8 +167,8 @@ class FloorPlanTest extends WebTestCase */ public function testTheFreeCountFollowsTheSelectedSpan(): void { - $room = $this->room('Raum Nord', 'room-01'); - $desk = $this->deskIn($room, 'desk-01', 'Schreibtisch 01'); + $room = $this->room('Raum Nord'); + $desk = $this->deskIn($room, 'Schreibtisch 01'); $this->book($desk, $this->user('bea@ep-reisen.de', 'Bea Kranz'), 540, 780); self::assertSame(['Raum Nord · belegt'], $this->roomsOf($this->board())); @@ -181,28 +177,28 @@ class FloorPlanTest extends WebTestCase public function testARoomWithNothingLeftSaysSoInsteadOfShowingAZero(): void { - $room = $this->room('Raum Nord', 'room-01'); - $desk = $this->deskIn($room, 'desk-01', 'Schreibtisch 01'); + $room = $this->room('Raum Nord'); + $desk = $this->deskIn($room, '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 + public function testRoomsAreListedInTheirConfiguredOrder(): 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'); + $this->deskIn($this->room('Zweiter Raum', 1), 'Schreibtisch 04'); + $this->deskIn($this->room('Erster Raum', 0), '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. + * A resource with no group is still bookable and still listed -- the plan simply gives it no + * room heading. That is the path the car park runs on. */ - public function testAnUngroupedDeskIsDrawnWithoutARoom(): void + public function testAnUngroupedDeskIsListedWithoutARoom(): void { - $this->desk('desk-01'); + $this->desk(); $crawler = $this->board(); @@ -211,29 +207,29 @@ class FloorPlanTest extends WebTestCase } /** - * The room captions, which sit directly under the <svg> rather than inside a shape's <g>. + * The room headings. * * @return list<string> */ private function roomsOf(\Symfony\Component\DomCrawler\Crawler $crawler): array { - return $crawler->filter('svg > text')->each( + return $crawler->filter('#board-floorplan section > h3')->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 + private function room(string $label, int $position = 0): ResourceGroup { - $group = new ResourceGroup(ResourceType::DESK, $label, $position, $svgElementId); + $group = new ResourceGroup(ResourceType::DESK, $label, $position); $this->em->persist($group); $this->em->flush(); return $group; } - private function deskIn(ResourceGroup $group, string $svgElementId, string $label): Resource + private function deskIn(ResourceGroup $group, string $label): Resource { - $resource = new Resource(ResourceType::DESK, $label, $svgElementId); + $resource = new Resource(ResourceType::DESK, $label); $resource->setGroup($group); $this->em->persist($resource); $this->em->flush(); @@ -242,11 +238,13 @@ class FloorPlanTest extends WebTestCase } /** + * The label, then one entry per listed line. + * * @return list<string> */ private function linesOf(\Symfony\Component\DomCrawler\Crawler $crawler): array { - return $crawler->filter('svg g text')->each( + return $crawler->filter('[data-spot] > span:not(.sr-only), [data-spot] li')->each( static fn (\Symfony\Component\DomCrawler\Crawler $node): string => trim($node->text()), ); } @@ -258,9 +256,9 @@ class FloorPlanTest extends WebTestCase return $this->client->request('GET', '/book/desk?date=2026-10-01'.$query); } - private function desk(string $svgElementId): Resource + private function desk(): Resource { - $resource = new Resource(ResourceType::DESK, 'Schreibtisch 01', $svgElementId); + $resource = new Resource(ResourceType::DESK, 'Schreibtisch 01'); $this->em->persist($resource); $this->em->flush(); diff --git a/tests/Model/SpotGroupTest.php b/tests/Model/SpotGroupTest.php index be537e7..0f370ae 100644 --- a/tests/Model/SpotGroupTest.php +++ b/tests/Model/SpotGroupTest.php @@ -109,7 +109,7 @@ class SpotGroupTest extends TestCase */ private function spot(string $label, ?ResourceGroup $group, array $bookings = []): Spot { - $resource = new Resource(ResourceType::DESK, $label, strtolower($label)); + $resource = new Resource(ResourceType::DESK, $label); $resource->setGroup($group); return Spot::for($resource, $bookings, TimeRange::wholeDay(), $this->viewer()); @@ -119,7 +119,7 @@ class SpotGroupTest extends TestCase { return new Booking( new UserWithId('bea@ep-reisen.de', 'Bea', 2), - new Resource(ResourceType::DESK, 'X', 'x'), + new Resource(ResourceType::DESK, 'X'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes($start, $end), ); diff --git a/tests/Model/SpotTest.php b/tests/Model/SpotTest.php index 0d561cb..e58b224 100644 --- a/tests/Model/SpotTest.php +++ b/tests/Model/SpotTest.php @@ -134,7 +134,7 @@ class SpotTest extends TestCase private function resource(): Resource { - return new Resource(ResourceType::DESK, 'D1', 'desk-01'); + return new Resource(ResourceType::DESK, 'D1'); } private function viewer(): User diff --git a/tests/Service/BookingManagerTest.php b/tests/Service/BookingManagerTest.php index 4131127..1755a10 100644 --- a/tests/Service/BookingManagerTest.php +++ b/tests/Service/BookingManagerTest.php @@ -316,7 +316,7 @@ class BookingManagerTest extends KernelTestCase private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource { - $resource = new Resource($type, $label, strtolower($label)); + $resource = new Resource($type, $label); $this->em->persist($resource); $this->em->flush();