: a tooltip only shows on * hover, which is useless on touch and invisible when scanning the plan. */ class FloorPlanTest extends WebTestCase { private KernelBrowser $client; private EntityManagerInterface $em; protected function setUp(): void { $this->client = self::createClient(); $this->client->disableReboot(); $this->em = self::getContainer()->get(EntityManagerInterface::class); $this->em->getConnection()->beginTransaction(); } protected function tearDown(): void { $this->em->getConnection()->rollBack(); parent::tearDown(); } public function testNoShapeCarriesATooltip(): void { $desk = $this->desk('desk-01'); $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')); } public function testABookingIsListedInsideTheShape(): void { $desk = $this->desk('desk-01'); $this->book($desk, $this->user('bea@ep-reisen.de', 'Bea Kranz'), 540, 780); self::assertSame( ['Schreibtisch 01', 'Bea K. 09:00–13:00', 'frei 13:00–24:00'], $this->linesOf($this->board()), ); } /** * Full names do not fit a 140px box at this size, so they are shortened. */ public function testALongNameIsShortenedToAFirstNameAndInitial(): void { $desk = $this->desk('desk-01'); $this->book($desk, $this->user('x@ep-reisen.de', 'Maximilian Wolkenstein'), 0, 1440); self::assertContains('Maximilian W.', $this->linesOf($this->board())); } public function testASingleWordNameIsLeftAlone(): void { $desk = $this->desk('desk-01'); $this->book($desk, $this->user('x@ep-reisen.de', 'Cher'), 0, 1440); self::assertContains('Cher', $this->linesOf($this->board())); } public function testYourOwnBookingReadsAsDu(): void { $desk = $this->desk('desk-01'); $me = $this->user('anna@ep-reisen.de', 'Anna Berger'); $this->book($desk, $me, 540, 780); self::assertContains('du 09:00–13:00', $this->linesOf($this->board($me))); } public function testAnAllDayBookingOmitsTheTimes(): void { $desk = $this->desk('desk-01'); $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 -- * and the counter has to account for the line the counter itself occupies. */ public function testTooManyBookingsAreSummarised(): void { $desk = $this->desk('desk-01'); $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); $this->book($desk, $this->user('d@ep-reisen.de', 'Dia Delta'), 1080, 1440); $lines = $this->linesOf($this->board()); self::assertSame( ['Schreibtisch 01', 'Ann A. 00:00–06:00', 'Bea B. 06:00–12:00', '+2 weitere'], $lines, ); // Label plus MAX_LINES, never more. self::assertLessThanOrEqual(4, \count($lines)); } public function testAFreeShapeShowsOnlyItsLabel(): void { $this->desk('desk-01'); self::assertSame(['Schreibtisch 01'], $this->linesOf($this->board())); } /** * Screen readers get the same information the box shows, because role="img" on the * would otherwise hide every one of those elements. */ public function testTheShapeCarriesAnAccessibleLabel(): void { $desk = $this->desk('desk-01'); $this->book($desk, $this->user('bea@ep-reisen.de', 'Bea Kranz'), 540, 780); $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); } public function testARoomIsDrawnWithItsNameAndHowManyAreFree(): void { $room = $this->room('Raum Nord', 'room-01'); $this->deskIn($room, 'desk-01', 'Schreibtisch 01'); $this->deskIn($room, 'desk-02', 'Schreibtisch 02'); $this->deskIn($room, 'desk-03', 'Schreibtisch 03'); self::assertSame(['Raum Nord · 3 frei'], $this->roomsOf($this->board())); } /** * The badge counts what can still be had for the whole span, so a desk that is free only in * the afternoon does not count -- it is already amber on the plan, and counting it would * make the number disagree with the colour beside it. */ public function testAPartlyBookedDeskIsNotCountedAsFree(): void { $room = $this->room('Raum Nord', 'room-01'); $partly = $this->deskIn($room, 'desk-01', 'Schreibtisch 01'); $this->deskIn($room, 'desk-02', 'Schreibtisch 02'); $this->book($partly, $this->user('bea@ep-reisen.de', 'Bea Kranz'), 540, 780); self::assertSame(['Raum Nord · 1 frei'], $this->roomsOf($this->board())); } /** * The count means the same thing as the colours beside it, so it is relative to the board's * span: the desk that is unavailable for the whole day is free for an afternoon board. */ public function testTheFreeCountFollowsTheSelectedSpan(): void { $room = $this->room('Raum Nord', 'room-01'); $desk = $this->deskIn($room, 'desk-01', 'Schreibtisch 01'); $this->book($desk, $this->user('bea@ep-reisen.de', 'Bea Kranz'), 540, 780); self::assertSame(['Raum Nord · belegt'], $this->roomsOf($this->board())); self::assertSame(['Raum Nord · 1 frei'], $this->roomsOf($this->board(query: '&from=14:00&to=18:00'))); } public function testARoomWithNothingLeftSaysSoInsteadOfShowingAZero(): void { $room = $this->room('Raum Nord', 'room-01'); $desk = $this->deskIn($room, 'desk-01', 'Schreibtisch 01'); $this->book($desk, $this->user('bea@ep-reisen.de', 'Bea Kranz'), 0, 1440); self::assertSame(['Raum Nord · belegt'], $this->roomsOf($this->board())); } public function testRoomsAreDrawnInTheirConfiguredOrder(): void { $this->deskIn($this->room('Zweiter Raum', 'room-02', 1), 'desk-04', 'Schreibtisch 04'); $this->deskIn($this->room('Erster Raum', 'room-01', 0), 'desk-01', 'Schreibtisch 01'); self::assertSame(['Erster Raum · 1 frei', 'Zweiter Raum · 1 frei'], $this->roomsOf($this->board())); } /** * A resource with no group is still bookable and still drawn -- the plan simply does not * frame it. That is the path the car park runs on. */ public function testAnUngroupedDeskIsDrawnWithoutARoom(): void { $this->desk('desk-01'); $crawler = $this->board(); self::assertSame([], $this->roomsOf($crawler)); self::assertSame(['Schreibtisch 01'], $this->linesOf($crawler)); } /** * The room captions, which sit directly under the rather than inside a shape's . * * @return list */ private function roomsOf(\Symfony\Component\DomCrawler\Crawler $crawler): array { return $crawler->filter('svg > text')->each( static fn (\Symfony\Component\DomCrawler\Crawler $node): string => trim(preg_replace('/\s+/', ' ', $node->text()) ?? ''), ); } private function room(string $label, string $svgElementId, int $position = 0): ResourceGroup { $group = new ResourceGroup(ResourceType::DESK, $label, $position, $svgElementId); $this->em->persist($group); $this->em->flush(); return $group; } private function deskIn(ResourceGroup $group, string $svgElementId, string $label): Resource { $resource = new Resource(ResourceType::DESK, $label, $svgElementId); $resource->setGroup($group); $this->em->persist($resource); $this->em->flush(); return $resource; } /** * @return list */ private function linesOf(\Symfony\Component\DomCrawler\Crawler $crawler): array { return $crawler->filter('svg g text')->each( static fn (\Symfony\Component\DomCrawler\Crawler $node): string => trim($node->text()), ); } private function board(?User $as = null, string $query = ''): \Symfony\Component\DomCrawler\Crawler { $this->client->loginUser($as ?? $this->user('anna@ep-reisen.de', 'Anna Berger')); return $this->client->request('GET', '/book/desk?date=2026-10-01'.$query); } private function desk(string $svgElementId): Resource { $resource = new Resource(ResourceType::DESK, 'Schreibtisch 01', $svgElementId); $this->em->persist($resource); $this->em->flush(); return $resource; } private function book(Resource $resource, User $user, int $start, int $end): void { $this->em->persist(new Booking($user, $resource, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes($start, $end))); $this->em->flush(); } private function user(string $email, string $name): User { $existing = $this->em->getRepository(User::class)->findOneBy(['email' => $email]); if (null !== $existing) { return $existing; } $user = new User($email, $name); $user->setRoles(['ROLE_EMPLOYEE']); $this->em->persist($user); $this->em->flush(); return $user; } }