feat: render the floor plan in html instead of svg

This commit is contained in:
fromme committed 2026-09-17 16:10:50 +02:00
1 parent 5a01054b0e
commit ad5d5acb00
20 files changed
+256 -356

No files matched your search

@@ -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('[email protected]', 'Bea Berger'), $desk);
$date = (new \DateTimeImmutable('today'))->format('Y-m-d');
$this->client->loginUser($this->user('[email protected]'));
$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('[email protected]', '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('[email protected]', 'Bea Berger'), $desk);
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2', ResourceType::DESK, 'desk-02'));
$this->booking($this->user('[email protected]', '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();
+8 -12
View File
@@ -178,7 +178,7 @@ class BookingControllerTest extends WebTestCase
public function testReleasingOwnBookingSwapsTheBoard(): void
{
$anna = $this->user('[email protected]');
$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('[email protected]');
$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('[email protected]'), $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('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01')));
$this->em->flush();
$this->client->loginUser($this->user('[email protected]'));
$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();
+4 -4
View File
@@ -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();
+50 -52
View File
@@ -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('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', 'Ann Alpha'), 0, 360);
$this->book($desk, $this->user('[email protected]', 'Bea Beta'), 360, 720);
$this->book($desk, $this->user('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', '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();