291 lines
10 KiB
PHP
291 lines
10 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Tests\Controller;
|
||
|
||
use App\Booking\TimeRange;
|
||
use App\Entity\Booking;
|
||
use App\Entity\Resource;
|
||
use App\Entity\ResourceGroup;
|
||
use App\Entity\ResourceType;
|
||
use App\Entity\User;
|
||
use Doctrine\ORM\EntityManagerInterface;
|
||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||
|
||
/**
|
||
* What a shape 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.
|
||
*/
|
||
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('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', 'Cher'), 0, 1440);
|
||
|
||
self::assertContains('Cher', $this->linesOf($this->board()));
|
||
}
|
||
|
||
public function testYourOwnBookingReadsAsDu(): void
|
||
{
|
||
$desk = $this->desk('desk-01');
|
||
$me = $this->user('[email protected]', '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('[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 --
|
||
* 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('[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);
|
||
$this->book($desk, $this->user('[email protected]', '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 <svg>
|
||
* would otherwise hide every one of those <text> elements.
|
||
*/
|
||
public function testTheShapeCarriesAnAccessibleLabel(): void
|
||
{
|
||
$desk = $this->desk('desk-01');
|
||
$this->book($desk, $this->user('[email protected]', '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('[email protected]', '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('[email protected]', '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('[email protected]', '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 <svg> rather than inside a shape's <g>.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
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<string>
|
||
*/
|
||
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('[email protected]', '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;
|
||
}
|
||
}
|