diff --git a/src/Form/AssignmentType.php b/src/Form/AssignmentType.php index a352877..3088503 100644 --- a/src/Form/AssignmentType.php +++ b/src/Form/AssignmentType.php @@ -7,6 +7,7 @@ use App\Entity\Destination; use App\Entity\Fee; use App\Entity\JobProfile; use App\Entity\User; +use App\Service\Common\PickupOptions; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Symfony\Bridge\Doctrine\Form\Type\EntityType; @@ -130,6 +131,11 @@ class AssignmentType extends AbstractType 'label' => 'Busbegleitung ab', 'required' => false, 'placeholder' => 'Keine Busbegleitung', + 'expanded' => true, + 'block_prefix' => 'pickup_choice', + // there is nothing to offer until a destination is picked, and an empty + // select looks like a destination without boarding points + 'attr' => ['data-empty-text' => 'Bitte erst eine Destination wählen …'], ]) ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) { $form = $event->getForm(); @@ -234,15 +240,16 @@ class AssignmentType extends AbstractType private function addPickupField(Destination $destination, FormInterface $form): void { - $choices = []; + $options = PickupOptions::inDepartureOrder($destination); + $labels = array_column($options, 'label', 'busProId'); + $attributes = []; - foreach ($destination->getPickups() as $pickup) { - if ($pickup['time']) { - $label = sprintf('%s, %s Uhr', trim($pickup['city']), trim($pickup['time'])); - } else { - $label = trim($pickup['city']); - } - $choices[$label] = $pickup['busProId']; + foreach ($options as $option) { + // read back by the pickup_choice_widget block in templates/forms.html.twig + $attributes[$option['busProId']] = [ + 'data-departure' => $option['departure'] ?? '', + 'data-street' => $option['street'] ?? '', + ]; } // Remove placeholder field first @@ -252,7 +259,14 @@ class AssignmentType extends AbstractType 'label' => 'Busbegleitung ab', 'required' => false, 'placeholder' => 'Keine Busbegleitung', - 'choices' => $choices, + 'choices' => array_column($options, 'busProId'), + 'choice_label' => static fn (int $busProId): string => $labels[$busProId], + 'choice_attr' => static fn (int $busProId): array => $attributes[$busProId], + // a radio list rather than a dropdown: the departure time and the street need + // their own place in the row, which a single option label cannot give them + 'expanded' => true, + 'block_prefix' => 'pickup_choice', + 'attr' => ['data-empty-text' => 'Für diese Destination sind keine Zustiege hinterlegt.'], ]); } diff --git a/src/Form/TeamerApplicationType.php b/src/Form/TeamerApplicationType.php index 55f7e1c..d979207 100644 --- a/src/Form/TeamerApplicationType.php +++ b/src/Form/TeamerApplicationType.php @@ -3,6 +3,7 @@ namespace App\Form; use App\Entity\Application; +use App\Service\Common\PickupOptions; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -44,14 +45,14 @@ class TeamerApplicationType extends AbstractType $teamer = $application->getTeamer(); if (0 === (int) $assignment->getPickup()) { - $pickupChoices = []; - foreach ($pickups as $pickup) { - $pickupChoices[$pickup['city']] = $pickup['busProId']; - } - ksort($pickupChoices); + // keyed by busProId, not by the town: two boarding points share a town + // on six destinations, and keying on the label dropped the second one + $options = PickupOptions::byTown($assignment->getDestination()); + $labels = array_column($options, 'label', 'busProId'); $form->add('pickup', ChoiceType::class, [ 'label' => 'Zustieg in', - 'choices' => $pickupChoices, + 'choices' => array_column($options, 'busProId'), + 'choice_label' => static fn (int $busProId): string => $labels[$busProId], ]); } elseif (false === in_array($assignment->getPickup(), $teamer->getPickups())) { $form->add('confirmPickup', CheckboxType::class, [ diff --git a/src/Form/TeamerDispositionType.php b/src/Form/TeamerDispositionType.php index f9a34c3..437d295 100644 --- a/src/Form/TeamerDispositionType.php +++ b/src/Form/TeamerDispositionType.php @@ -3,6 +3,7 @@ namespace App\Form; use App\Entity\Disposition; +use App\Service\Common\PickupOptions; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -24,14 +25,14 @@ class TeamerDispositionType extends AbstractType // Add pickup select field only to assignments that are not including bus chaperon if (0 === (int) $assignment->getPickup()) { - $pickupChoices = []; - foreach ($assignment->getDestination()->getPickups() as $pickup) { - $pickupChoices[$pickup['city']] = $pickup['busProId']; - } - ksort($pickupChoices); + // keyed by busProId, not by the town: two boarding points share a town + // on six destinations, and keying on the label dropped the second one + $options = PickupOptions::byTown($assignment->getDestination()); + $labels = array_column($options, 'label', 'busProId'); $event->getForm()->add('pickup', ChoiceType::class, [ 'label' => 'Zustieg in', - 'choices' => $pickupChoices, + 'choices' => array_column($options, 'busProId'), + 'choice_label' => static fn (int $busProId): string => $labels[$busProId], ]); } }); diff --git a/src/Service/Assignment/PickupDepartureOrder.php b/src/Service/Assignment/PickupDepartureOrder.php new file mode 100644 index 0000000..ff826dd --- /dev/null +++ b/src/Service/Assignment/PickupDepartureOrder.php @@ -0,0 +1,176 @@ +> $pickups as stored on Destination::$pickups + * + * @return list> + */ + public static function sort(array $pickups): array + { + $entries = []; + + foreach (array_values($pickups) as $index => $pickup) { + $entries[] = [ + 'index' => $index, + 'pickup' => $pickup, + 'timestamp' => self::absoluteTimestamp($pickup), + 'minutes' => self::clockMinutes($pickup), + ]; + } + + $anchor = self::anchor($entries); + + foreach ($entries as $i => $entry) { + if (null !== $entry['timestamp'] || null === $entry['minutes'] || null === $anchor) { + continue; + } + + $entries[$i]['timestamp'] = self::onDepartureDay($entry['minutes'], $anchor); + } + + usort($entries, static function (array $a, array $b): int { + $keyA = $a['timestamp'] ?? $a['minutes']; + $keyB = $b['timestamp'] ?? $b['minutes']; + + // boarding points without a readable time keep their place at the end rather + // than jumping to the front of the list + if (null === $keyA || null === $keyB) { + return (null === $keyA ? 1 : 0) <=> (null === $keyB ? 1 : 0); + } + + return $keyA <=> $keyB; + }); + + return array_column($entries, 'pickup'); + } + + /** + * The departure time as a plain 24 hour clock, for a label. + * + * BusProNet writes "08.03.2024 20:45:00" for some boarding points and "02:15" for the + * next one in the same travel, so rendering the stored value verbatim puts a full date + * in one label and a bare time beside it. Both shapes are read here, through the same + * parser the ordering uses, so a list cannot end up sorted by one reading of a time and + * labelled by another. + * + * @param array $pickup + */ + public static function clock(array $pickup): ?string + { + $minutes = self::clockMinutes($pickup); + + if (null !== $minutes) { + return sprintf('%02d:%02d', intdiv($minutes, 60), $minutes % 60); + } + + $timestamp = self::absoluteTimestamp($pickup); + + if (null === $timestamp) { + return null; + } + + return (new \DateTimeImmutable('@'.$timestamp)) + ->setTimezone(new \DateTimeZone(date_default_timezone_get())) + ->format('H:i') + ; + } + + /** + * The first dated departure, which is the moment the coach sets off. + * + * @param list> $entries + */ + private static function anchor(array $entries): ?int + { + $timestamps = array_filter(array_column($entries, 'timestamp'), static fn (?int $t) => null !== $t); + + return [] === $timestamps ? null : min($timestamps); + } + + private static function onDepartureDay(int $minutes, int $anchor): int + { + $day = (new \DateTimeImmutable('@'.$anchor))->setTimezone(new \DateTimeZone(date_default_timezone_get())); + $anchorMinutes = (int) $day->format('H') * 60 + (int) $day->format('i'); + + $placed = $day->setTime(intdiv($minutes, 60), $minutes % 60); + + if ($minutes < $anchorMinutes) { + $placed = $placed->modify('+1 day'); + } + + return $placed->getTimestamp(); + } + + /** + * @param array $pickup + */ + private static function absoluteTimestamp(array $pickup): ?int + { + // what MyE&P's travel export will carry: already resolved across midnight + $datetime = $pickup['datetime'] ?? null; + + if (is_string($datetime) && '' !== $datetime && false !== $parsed = strtotime($datetime)) { + return $parsed; + } + + $time = self::time($pickup); + + if (null === $time || 1 !== preg_match('/^(\d{2}\.\d{2}\.\d{4}) (\d{2}:\d{2})(?::\d{2})?$/', $time, $matches)) { + return null; + } + + $date = \DateTimeImmutable::createFromFormat('!d.m.Y H:i', $matches[1].' '.$matches[2]); + + return false === $date ? null : $date->getTimestamp(); + } + + /** + * @param array $pickup + */ + private static function clockMinutes(array $pickup): ?int + { + $time = self::time($pickup); + + if (null === $time || 1 !== preg_match('/^(\d{2}):(\d{2})$/', $time, $matches)) { + return null; + } + + return (int) $matches[1] * 60 + (int) $matches[2]; + } + + /** + * @param array $pickup + */ + private static function time(array $pickup): ?string + { + $time = trim((string) ($pickup['time'] ?? '')); + + return '' === $time ? null : $time; + } +} diff --git a/src/Service/Common/PickupOptions.php b/src/Service/Common/PickupOptions.php new file mode 100644 index 0000000..4666129 --- /dev/null +++ b/src/Service/Common/PickupOptions.php @@ -0,0 +1,93 @@ + + */ + public static function inDepartureOrder(Destination $destination): array + { + return self::rows(PickupDepartureOrder::sort($destination->getPickups())); + } + + /** + * Alphabetical by town, with the street folded into the label where a town repeats - + * otherwise the teamer is offered the same word twice with nothing to choose between. + * + * @return list + */ + public static function byTown(Destination $destination): array + { + $options = self::rows($destination->getPickups()); + $occurrences = array_count_values(array_column($options, 'label')); + + foreach ($options as $index => $option) { + if ($occurrences[$option['label']] < 2) { + continue; + } + + $options[$index]['label'] = match (true) { + null !== $option['street'] => sprintf('%s, %s', $option['label'], $option['street']), + null !== $option['departure'] => sprintf('%s, %s Uhr', $option['label'], $option['departure']), + default => sprintf('%s (Zustieg %d)', $option['label'], $option['busProId']), + }; + } + + usort($options, static fn (array $a, array $b): int => $a['label'] <=> $b['label']); + + return $options; + } + + /** + * @param array> $pickups + * + * @return list + */ + private static function rows(array $pickups): array + { + $options = []; + + foreach ($pickups as $pickup) { + $busProId = (int) ($pickup['busProId'] ?? 0); + + if (0 === $busProId) { + continue; + } + + $city = trim((string) ($pickup['city'] ?? '')); + $street = trim((string) ($pickup['street'] ?? '')); + + $options[] = [ + 'busProId' => $busProId, + // a boarding point missing from the base data has no address, but it is + // still a real departure and has to stay selectable. None exist today; they + // arrive with MyE&P's travel export, which reports the ones whose master + // data it could not resolve rather than dropping them. + 'label' => '' === $city ? 'Zustieg '.$busProId : $city, + 'departure' => PickupDepartureOrder::clock($pickup), + 'street' => '' === $street ? null : $street, + ]; + } + + return $options; + } +} diff --git a/templates/forms.html.twig b/templates/forms.html.twig index 117b35b..6ec71cf 100644 --- a/templates/forms.html.twig +++ b/templates/forms.html.twig @@ -126,6 +126,47 @@ {% endfor -%} {%- endblock choice_widget_expanded -%} +{# + # The boarding points of a destination, as a radio list. Expanded rather than a dropdown + # because a row carries three things - the town, the street and the departure time - and two + # towns can repeat within one travel, where only the street tells them apart. App\Form\AssignmentType + # puts the latter two on the choice as data attributes. + #} +{%- block pickup_choice_widget -%} + {%- if choices is empty -%} + {# no destination chosen yet, or one without boarding points - the caller says which #} +

{{ attr['data-empty-text']|default('Keine Zustiege verfügbar.') }}

+ {%- elseif expanded -%} + {# 82% of destinations have seven boarding points or fewer and fit without scrolling; + the longest hold fifteen, which would otherwise stretch the form past the fields + beside it #} +
+ {%- for child in form %} + {%- set departure = child.vars.attr['data-departure']|default('') -%} + {%- set street = child.vars.attr['data-street']|default('') -%} +
+
+ + {%- if street is not empty %} +

{{ street }}

+ {%- endif %} +
+
+ {{- form_widget(child) -}} +
+
+ {% endfor -%} +
+ {%- else -%} + {{- block('choice_widget_collapsed') -}} + {%- endif -%} +{%- endblock pickup_choice_widget -%} + {%- block birthday_widget -%}
{{ form_widget(form.children['day']) }} diff --git a/tests/Form/PickupChoiceWidgetTest.php b/tests/Form/PickupChoiceWidgetTest.php new file mode 100644 index 0000000..ec16230 --- /dev/null +++ b/tests/Form/PickupChoiceWidgetTest.php @@ -0,0 +1,126 @@ +render([ + ['busProId' => 74, 'city' => 'Stuttgart', 'street' => 'Flughafen (Busterminal)', 'time' => '03:15'], + ['busProId' => 673, 'city' => 'Stuttgart', 'street' => 'Schelmenwasenstr. 15', 'time' => '03:15'], + ]); + + $this->assertSame(3, substr_count($html, 'type="radio"'), 'two boarding points plus the placeholder'); + $this->assertSame(2, substr_count($html, '>Stuttgart<')); + $this->assertStringContainsString('Flughafen (Busterminal)', $html); + $this->assertStringContainsString('Schelmenwasenstr. 15', $html); + $this->assertSame(2, substr_count($html, '03:15 Uhr')); + } + + public function testThePlaceholderRendersWithoutADepartureOrStreet(): void + { + $html = $this->render([ + ['busProId' => 4, 'city' => 'Karlsruhe', 'street' => 'A5 - Autohof', 'time' => '23:59'], + ]); + + $this->assertStringContainsString('Keine Busbegleitung', $html); + // one time only: the placeholder must not pick up the boarding point's + $this->assertSame(1, substr_count($html, 'Uhr')); + } + + public function testAFullDateInTheSourceIsRenderedAsAClock(): void + { + $html = $this->render([ + ['busProId' => 1, 'city' => 'Hannover', 'street' => 'ZOB', 'time' => '08.03.2024 20:45:00'], + ]); + + $this->assertStringContainsString('20:45 Uhr', $html); + $this->assertStringNotContainsString('08.03.2024', $html); + } + + public function testABoardingPointWithoutATimeOrStreetStillRenders(): void + { + $html = $this->render([ + ['busProId' => 4711, 'city' => null, 'street' => null, 'time' => null], + ]); + + $this->assertStringContainsString('Zustieg 4711', $html); + $this->assertStringNotContainsString('Uhr', $html); + } + + public function testWithoutADestinationTheFieldExplainsItselfInsteadOfShowingAnEmptySelect(): void + { + $html = $this->render([], 'Bitte erst eine Destination wählen …'); + + $this->assertStringContainsString('Bitte erst eine Destination wählen', $html); + $this->assertStringNotContainsString('type="radio"', $html); + } + + public function testADestinationWithoutBoardingPointsSaysSoInstead(): void + { + // 1271 of 2267 destinations carry no boarding points at all + $html = $this->render([], 'Für diese Destination sind keine Zustiege hinterlegt.'); + + $this->assertStringContainsString('keine Zustiege hinterlegt', $html); + $this->assertStringNotContainsString('type="radio"', $html); + } + + /** + * @param list> $pickups + */ + private function render(array $pickups, string $emptyText = 'Keine Zustiege verfügbar.'): string + { + self::bootKernel(); + + $options = PickupOptions::inDepartureOrder((new Destination())->setPickups($pickups)); + $labels = array_column($options, 'label', 'busProId'); + $attributes = []; + + foreach ($options as $option) { + $attributes[$option['busProId']] = [ + 'data-departure' => $option['departure'] ?? '', + 'data-street' => $option['street'] ?? '', + ]; + } + + /** @var FormFactoryInterface $formFactory */ + $formFactory = self::getContainer()->get('form.factory'); + $form = $formFactory->createNamed('pickup', ChoiceType::class, null, [ + 'label' => 'Busbegleitung ab', + 'required' => false, + 'placeholder' => 'Keine Busbegleitung', + 'choices' => array_column($options, 'busProId'), + 'choice_label' => static fn (int $busProId): string => $labels[$busProId], + 'choice_attr' => static fn (int $busProId): array => $attributes[$busProId], + 'expanded' => true, + 'block_prefix' => 'pickup_choice', + 'attr' => ['data-empty-text' => $emptyText], + // a bare field rendered outside a request, so there is no session to hold a token + 'csrf_protection' => false, + ]); + + /** @var Environment $twig */ + $twig = self::getContainer()->get('twig'); + + return $twig + ->createTemplate('{{ form_widget(form) }}') + ->render(['form' => $form->createView()]) + ; + } +} diff --git a/tests/Service/Assignment/PickupDepartureOrderTest.php b/tests/Service/Assignment/PickupDepartureOrderTest.php new file mode 100644 index 0000000..8733c59 --- /dev/null +++ b/tests/Service/Assignment/PickupDepartureOrderTest.php @@ -0,0 +1,137 @@ +assertSame( + ['Hannover', 'Göttingen', 'Mannheim', 'Kassel', 'Frankfurt', 'Karlsruhe', 'Basel'], + array_column($sorted, 'city'), + ); + } + + public function testADatedDepartureAfterMidnightKeepsItsOwnDay(): void + { + // destination DKIWE/110425: 03:15 carries the next day's date itself, so it must not + // be pulled back in front of the evening departures + $sorted = PickupDepartureOrder::sort([ + self::pickup(1, 'Kiel', '02:15'), + self::pickup(2, 'Hamburg', '10.04.2025 23:59:00'), + self::pickup(3, 'Flensburg', '10.04.2025 21:30:00'), + self::pickup(4, 'Lübeck', '10.04.2025 23:00:00'), + self::pickup(5, 'Bremen', '11.04.2025 03:15:00'), + self::pickup(6, 'Rostock', '01:00'), + self::pickup(7, 'Schwerin', '04:15'), + ]); + + $this->assertSame( + ['Flensburg', 'Lübeck', 'Hamburg', 'Rostock', 'Kiel', 'Bremen', 'Schwerin'], + array_column($sorted, 'city'), + ); + } + + public function testWithoutAnyDatedEntryTheClockAloneOrders(): void + { + $sorted = PickupDepartureOrder::sort([ + self::pickup(1, 'Ulm', '07:30'), + self::pickup(2, 'Augsburg', '05:15'), + self::pickup(3, 'München', '09:00'), + ]); + + $this->assertSame(['Augsburg', 'Ulm', 'München'], array_column($sorted, 'city')); + } + + public function testAResolvedDatetimeWinsOverTheStoredTime(): void + { + // what MyE&P's travel export delivers: time for display, datetime for ordering + $sorted = PickupDepartureOrder::sort([ + [...self::pickup(1, 'Kassel', '00:30'), 'datetime' => '2024-03-09T00:30:00+01:00'], + [...self::pickup(2, 'Hannover', '20:45'), 'datetime' => '2024-03-08T20:45:00+01:00'], + ]); + + $this->assertSame(['Hannover', 'Kassel'], array_column($sorted, 'city')); + } + + public function testEntriesWithoutAReadableTimeGoLastInTheirStoredOrder(): void + { + $sorted = PickupDepartureOrder::sort([ + self::pickup(1, 'Ohne Zeit', ''), + self::pickup(2, 'Nur Datum', '16.02.2024'), + self::pickup(3, 'Bremen', '22:00'), + self::pickup(4, 'Gar nichts', null), + ]); + + $this->assertSame( + ['Bremen', 'Ohne Zeit', 'Nur Datum', 'Gar nichts'], + array_column($sorted, 'city'), + ); + } + + public function testTheClockReadsBothStoredTimeShapes(): void + { + $this->assertSame('20:45', PickupDepartureOrder::clock(self::pickup(1, 'Hannover', '08.03.2024 20:45:00'))); + $this->assertSame('02:15', PickupDepartureOrder::clock(self::pickup(2, 'Kassel', '02:15'))); + } + + public function testTheClockIsNullWhenNoTimeCanBeRead(): void + { + $this->assertNull(PickupDepartureOrder::clock(self::pickup(1, 'Nur Datum', '16.02.2024'))); + $this->assertNull(PickupDepartureOrder::clock(self::pickup(2, 'Ohne Zeit', ''))); + $this->assertNull(PickupDepartureOrder::clock(self::pickup(3, 'Gar nichts', null))); + } + + public function testTheClockPrefersAResolvedDatetime(): void + { + $pickup = [...self::pickup(1, 'Hannover', '08.03.2024 20:45:00'), 'datetime' => '2024-03-08T20:45:00+01:00']; + + $this->assertSame('20:45', PickupDepartureOrder::clock($pickup)); + } + + public function testAnEmptyListStaysEmpty(): void + { + $this->assertSame([], PickupDepartureOrder::sort([])); + } + + public function testTheStoredEntriesArePassedThroughUntouched(): void + { + $pickups = [self::pickup(7, 'Bremen', '22:00')]; + + $this->assertSame($pickups, PickupDepartureOrder::sort($pickups)); + } + + /** + * @return array + */ + private static function pickup(int $busProId, string $city, ?string $time): array + { + return [ + 'busProId' => $busProId, + 'city' => $city, + 'street' => 'Irgendwo 1', + 'time' => $time, + ]; + } +} diff --git a/tests/Service/Common/PickupOptionsTest.php b/tests/Service/Common/PickupOptionsTest.php new file mode 100644 index 0000000..5599c07 --- /dev/null +++ b/tests/Service/Common/PickupOptionsTest.php @@ -0,0 +1,136 @@ +destination([ + ['busProId' => 74, 'city' => 'Stuttgart', 'street' => 'Flughafen (Busterminal)', 'time' => '03:15'], + ['busProId' => 673, 'city' => 'Stuttgart', 'street' => 'Schelmenwasenstr. 15', 'time' => '03:15'], + ])); + + $this->assertSame([74, 673], array_column($options, 'busProId')); + $this->assertSame(['Stuttgart', 'Stuttgart'], array_column($options, 'label')); + $this->assertSame( + ['Flughafen (Busterminal)', 'Schelmenwasenstr. 15'], + array_column($options, 'street'), + ); + } + + public function testTwoBoardingPointsInOneTownBothSurviveByTown(): void + { + $options = PickupOptions::byTown($this->destination([ + ['busProId' => 74, 'city' => 'Stuttgart', 'street' => 'Flughafen (Busterminal)', 'time' => '03:15'], + ['busProId' => 673, 'city' => 'Stuttgart', 'street' => 'Schelmenwasenstr. 15', 'time' => '03:15'], + ])); + + $this->assertSame([74, 673], array_column($options, 'busProId')); + // the town alone would offer the teamer the same word twice + $this->assertSame( + ['Stuttgart, Flughafen (Busterminal)', 'Stuttgart, Schelmenwasenstr. 15'], + array_column($options, 'label'), + ); + } + + public function testARepeatedTownWithoutAStreetFallsBackToTheDepartureTime(): void + { + $options = PickupOptions::byTown($this->destination([ + ['busProId' => 1, 'city' => 'München', 'street' => null, 'time' => '03:00'], + ['busProId' => 2, 'city' => 'München', 'street' => null, 'time' => '03:30'], + ])); + + $this->assertSame(['München, 03:00 Uhr', 'München, 03:30 Uhr'], array_column($options, 'label')); + } + + public function testARepeatedTownWithNeitherFallsBackToTheId(): void + { + $options = PickupOptions::byTown($this->destination([ + ['busProId' => 1, 'city' => 'München', 'street' => null, 'time' => null], + ['busProId' => 2, 'city' => 'München', 'street' => null, 'time' => null], + ])); + + $this->assertSame(['München (Zustieg 1)', 'München (Zustieg 2)'], array_column($options, 'label')); + } + + public function testAUniqueTownIsNotDisambiguated(): void + { + $options = PickupOptions::byTown($this->destination([ + ['busProId' => 4, 'city' => 'Karlsruhe', 'street' => 'A5 - Autohof Bruchsal', 'time' => '23:59'], + ])); + + $this->assertSame('Karlsruhe', $options[0]['label']); + } + + public function testByTownIsAlphabeticalWhileDepartureOrderIsChronological(): void + { + $pickups = [ + ['busProId' => 1, 'city' => 'Hannover', 'street' => 'ZOB', 'time' => '08.03.2024 20:45:00'], + ['busProId' => 2, 'city' => 'Aachen', 'street' => 'Bushof', 'time' => '02:30'], + ]; + + $this->assertSame(['Aachen', 'Hannover'], array_column(PickupOptions::byTown($this->destination($pickups)), 'label')); + $this->assertSame(['Hannover', 'Aachen'], array_column(PickupOptions::inDepartureOrder($this->destination($pickups)), 'label')); + } + + public function testTheDepartureIsANormalisedClockWhateverTheSourceHeld(): void + { + $options = PickupOptions::inDepartureOrder($this->destination([ + ['busProId' => 1, 'city' => 'Hannover', 'street' => 'ZOB', 'time' => '08.03.2024 20:45:00'], + ['busProId' => 2, 'city' => 'Kassel', 'street' => 'Auestadion', 'time' => '00:30'], + ])); + + $this->assertSame(['20:45', '00:30'], array_column($options, 'departure')); + } + + public function testABoardingPointWithoutATownFallsBackToItsId(): void + { + // none exist today; they arrive with MyE&P's export, which reports the boarding + // points whose master data it could not resolve rather than dropping them + $options = PickupOptions::inDepartureOrder($this->destination([ + ['busProId' => 4711, 'city' => null, 'street' => null, 'time' => '05:00'], + ])); + + $this->assertSame('Zustieg 4711', $options[0]['label']); + $this->assertNull($options[0]['street']); + } + + public function testABoardingPointWithoutAnIdIsDropped(): void + { + $options = PickupOptions::inDepartureOrder($this->destination([ + ['busProId' => 0, 'city' => 'Nirgendwo', 'street' => null, 'time' => '05:00'], + ['busProId' => 27, 'city' => 'Stuttgart', 'street' => null, 'time' => '06:00'], + ])); + + $this->assertSame([27], array_column($options, 'busProId')); + } + + public function testADestinationWithoutBoardingPointsYieldsNothing(): void + { + $this->assertSame([], PickupOptions::inDepartureOrder($this->destination([]))); + $this->assertSame([], PickupOptions::byTown($this->destination([]))); + } + + /** + * @param list> $pickups + */ + private function destination(array $pickups): Destination + { + return (new Destination())->setPickups($pickups); + } +}