feat: render the floor plan in html instead of svg
This commit is contained in:
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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user