client = self::createClient(server: ['HTTPS' => true]); $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 testNoCardCarriesATooltip(): void { $desk = $this->desk(); $this->book($desk, $this->user('bea@ep-reisen.de', 'Bea Kranz'), 540, 780); $crawler = $this->board(); 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(); $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 are shortened so a card stays one line per booking at phone width. */ public function testALongNameIsShortenedToAFirstNameAndInitial(): void { $desk = $this->desk(); $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(); $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(); $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(); $this->book($desk, $this->user('bea@ep-reisen.de', 'Bea Kranz'), 0, 1440); self::assertSame(['Schreibtisch 01', 'Bea K.'], $this->linesOf($this->board())); } /** * 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(); $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 testAFreeCardShowsOnlyItsLabel(): void { $this->desk(); self::assertSame(['Schreibtisch 01'], $this->linesOf($this->board())); } /** * Free is otherwise only a colour, which a screen reader cannot see. */ public function testAFreeCardSaysSoToScreenReaders(): void { $this->desk(); self::assertSame('frei', $this->board()->filter('[data-spot] .sr-only')->text()); } public function testARoomIsDrawnWithItsNameAndHowManyAreFree(): void { $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())); } /** * 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'); $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())); } /** * 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'); $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())); 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'); $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 testRoomsAreListedInTheirConfiguredOrder(): void { $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 listed -- the plan simply gives it no * room heading. That is the path the car park runs on. */ public function testAnUngroupedDeskIsListedWithoutARoom(): void { $this->desk(); $crawler = $this->board(); self::assertSame([], $this->roomsOf($crawler)); self::assertSame(['Schreibtisch 01'], $this->linesOf($crawler)); } /** * The room headings. * * @return list */ private function roomsOf(\Symfony\Component\DomCrawler\Crawler $crawler): array { 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, int $position = 0): ResourceGroup { $group = new ResourceGroup(ResourceType::DESK, $label, $position); $this->em->persist($group); $this->em->flush(); return $group; } private function deskIn(ResourceGroup $group, string $label): Resource { $resource = new Resource(ResourceType::DESK, $label); $resource->setGroup($group); $this->em->persist($resource); $this->em->flush(); return $resource; } /** * The label, then one entry per listed line. * * @return list */ private function linesOf(\Symfony\Component\DomCrawler\Crawler $crawler): array { return $crawler->filter('[data-spot] > span:not(.sr-only), [data-spot] li')->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(): Resource { $resource = new Resource(ResourceType::DESK, 'Schreibtisch 01'); $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; } }