feat: order and format the pickup fields
This commit is contained in:
1 parent
8da9af46f5
commit
392e67448e
9 files changed
+746
-21
No files matched your search
@@ -7,6 +7,7 @@ use App\Entity\Destination;
|
|||||||
use App\Entity\Fee;
|
use App\Entity\Fee;
|
||||||
use App\Entity\JobProfile;
|
use App\Entity\JobProfile;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Service\Common\PickupOptions;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Doctrine\ORM\EntityRepository;
|
use Doctrine\ORM\EntityRepository;
|
||||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||||
@@ -130,6 +131,11 @@ class AssignmentType extends AbstractType
|
|||||||
'label' => 'Busbegleitung ab',
|
'label' => 'Busbegleitung ab',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'placeholder' => 'Keine Busbegleitung',
|
'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) {
|
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
|
||||||
$form = $event->getForm();
|
$form = $event->getForm();
|
||||||
@@ -234,15 +240,16 @@ class AssignmentType extends AbstractType
|
|||||||
|
|
||||||
private function addPickupField(Destination $destination, FormInterface $form): void
|
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) {
|
foreach ($options as $option) {
|
||||||
if ($pickup['time']) {
|
// read back by the pickup_choice_widget block in templates/forms.html.twig
|
||||||
$label = sprintf('%s, %s Uhr', trim($pickup['city']), trim($pickup['time']));
|
$attributes[$option['busProId']] = [
|
||||||
} else {
|
'data-departure' => $option['departure'] ?? '',
|
||||||
$label = trim($pickup['city']);
|
'data-street' => $option['street'] ?? '',
|
||||||
}
|
];
|
||||||
$choices[$label] = $pickup['busProId'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove placeholder field first
|
// Remove placeholder field first
|
||||||
@@ -252,7 +259,14 @@ class AssignmentType extends AbstractType
|
|||||||
'label' => 'Busbegleitung ab',
|
'label' => 'Busbegleitung ab',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'placeholder' => 'Keine Busbegleitung',
|
'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.'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Form;
|
namespace App\Form;
|
||||||
|
|
||||||
use App\Entity\Application;
|
use App\Entity\Application;
|
||||||
|
use App\Service\Common\PickupOptions;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
@@ -44,14 +45,14 @@ class TeamerApplicationType extends AbstractType
|
|||||||
$teamer = $application->getTeamer();
|
$teamer = $application->getTeamer();
|
||||||
|
|
||||||
if (0 === (int) $assignment->getPickup()) {
|
if (0 === (int) $assignment->getPickup()) {
|
||||||
$pickupChoices = [];
|
// keyed by busProId, not by the town: two boarding points share a town
|
||||||
foreach ($pickups as $pickup) {
|
// on six destinations, and keying on the label dropped the second one
|
||||||
$pickupChoices[$pickup['city']] = $pickup['busProId'];
|
$options = PickupOptions::byTown($assignment->getDestination());
|
||||||
}
|
$labels = array_column($options, 'label', 'busProId');
|
||||||
ksort($pickupChoices);
|
|
||||||
$form->add('pickup', ChoiceType::class, [
|
$form->add('pickup', ChoiceType::class, [
|
||||||
'label' => 'Zustieg in',
|
'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())) {
|
} elseif (false === in_array($assignment->getPickup(), $teamer->getPickups())) {
|
||||||
$form->add('confirmPickup', CheckboxType::class, [
|
$form->add('confirmPickup', CheckboxType::class, [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Form;
|
namespace App\Form;
|
||||||
|
|
||||||
use App\Entity\Disposition;
|
use App\Entity\Disposition;
|
||||||
|
use App\Service\Common\PickupOptions;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
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
|
// Add pickup select field only to assignments that are not including bus chaperon
|
||||||
if (0 === (int) $assignment->getPickup()) {
|
if (0 === (int) $assignment->getPickup()) {
|
||||||
$pickupChoices = [];
|
// keyed by busProId, not by the town: two boarding points share a town
|
||||||
foreach ($assignment->getDestination()->getPickups() as $pickup) {
|
// on six destinations, and keying on the label dropped the second one
|
||||||
$pickupChoices[$pickup['city']] = $pickup['busProId'];
|
$options = PickupOptions::byTown($assignment->getDestination());
|
||||||
}
|
$labels = array_column($options, 'label', 'busProId');
|
||||||
ksort($pickupChoices);
|
|
||||||
$event->getForm()->add('pickup', ChoiceType::class, [
|
$event->getForm()->add('pickup', ChoiceType::class, [
|
||||||
'label' => 'Zustieg in',
|
'label' => 'Zustieg in',
|
||||||
'choices' => $pickupChoices,
|
'choices' => array_column($options, 'busProId'),
|
||||||
|
'choice_label' => static fn (int $busProId): string => $labels[$busProId],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Assignment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts a destination's boarding points into departure order.
|
||||||
|
*
|
||||||
|
* The stored order is the order BusProNet's travel export happens to list them in, which
|
||||||
|
* is not chronological: a real row reads 20:45, 00:30, 04:15, 02:45, 22:00, 01:30, 23:30.
|
||||||
|
*
|
||||||
|
* Ordering them is not a plain sort on the clock, because a coach leaves in the evening and
|
||||||
|
* keeps picking up through the night, so 00:30 comes *after* 23:30. Which of the two a given
|
||||||
|
* entry means is only decidable from the date, and the stored time carries one in some
|
||||||
|
* entries and not others - BusProNet writes both "08.03.2024 20:45:00" and "02:15", and 453
|
||||||
|
* of 996 destinations hold both forms at once.
|
||||||
|
*
|
||||||
|
* So an entry that carries a date is absolute and used as it stands, and a bare clock time
|
||||||
|
* is placed on the day the coach sets off, rolled to the next day when it falls before that
|
||||||
|
* first dated departure. Where nothing carries a date there is no overnight leg to resolve -
|
||||||
|
* none of those 240 destinations spans midnight - and the clock alone is the right order.
|
||||||
|
*
|
||||||
|
* Once MyE&P's travel export replaces the XML import, every entry carries a resolved
|
||||||
|
* `datetime` and that wins outright; the rest of this is then only reading legacy rows.
|
||||||
|
*/
|
||||||
|
final class PickupDepartureOrder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $pickups as stored on Destination::$pickups
|
||||||
|
*
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<array<string, mixed>> $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<string, mixed> $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<string, mixed> $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<string, mixed> $pickup
|
||||||
|
*/
|
||||||
|
private static function time(array $pickup): ?string
|
||||||
|
{
|
||||||
|
$time = trim((string) ($pickup['time'] ?? ''));
|
||||||
|
|
||||||
|
return '' === $time ? null : $time;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Common;
|
||||||
|
|
||||||
|
use App\Entity\Destination;
|
||||||
|
use App\Service\Assignment\PickupDepartureOrder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The boarding points of a destination, as something a form can offer.
|
||||||
|
*
|
||||||
|
* Every caller keys its choices on the busProId rather than on the label, because the
|
||||||
|
* label is not unique: six destinations have two boarding points in the same town, and one
|
||||||
|
* of them has both at 03:15. Keying on the label, as all three pickup fields used to, made
|
||||||
|
* the second one unreachable - it never reached the form and could not be picked.
|
||||||
|
*
|
||||||
|
* Two orders, because the two audiences read the list differently. Disposition planners
|
||||||
|
* read a route and want it chronological; a teamer is looking up their own town in a list
|
||||||
|
* and wants it alphabetical.
|
||||||
|
*/
|
||||||
|
final class PickupOptions
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return list<array{busProId: int, label: string, departure: ?string, street: ?string}>
|
||||||
|
*/
|
||||||
|
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<array{busProId: int, label: string, departure: ?string, street: ?string}>
|
||||||
|
*/
|
||||||
|
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<int, array<string, mixed>> $pickups
|
||||||
|
*
|
||||||
|
* @return list<array{busProId: int, label: string, departure: ?string, street: ?string}>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,6 +126,47 @@
|
|||||||
{% endfor -%}
|
{% endfor -%}
|
||||||
{%- endblock choice_widget_expanded -%}
|
{%- 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 #}
|
||||||
|
<p class="text-sm leading-6 text-gray-500">{{ attr['data-empty-text']|default('Keine Zustiege verfügbar.') }}</p>
|
||||||
|
{%- 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 #}
|
||||||
|
<div class="max-h-80 divide-y divide-gray-100 overflow-y-auto rounded-md ring-1 ring-inset ring-gray-300">
|
||||||
|
{%- for child in form %}
|
||||||
|
{%- set departure = child.vars.attr['data-departure']|default('') -%}
|
||||||
|
{%- set street = child.vars.attr['data-street']|default('') -%}
|
||||||
|
<div class="flex items-start gap-3 px-3 py-2">
|
||||||
|
<div class="min-w-0 flex-1 text-sm leading-5">
|
||||||
|
<label for="{{ child.vars.id }}" class="flex select-none items-baseline gap-2">
|
||||||
|
<span class="font-medium text-gray-900">{{ child.vars.label }}</span>
|
||||||
|
{%- if departure is not empty %}
|
||||||
|
<span class="ml-auto shrink-0 tabular-nums text-gray-500">{{ departure }} Uhr</span>
|
||||||
|
{%- endif %}
|
||||||
|
</label>
|
||||||
|
{%- if street is not empty %}
|
||||||
|
<p class="truncate text-xs text-gray-500" title="{{ street }}">{{ street }}</p>
|
||||||
|
{%- endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex h-5 items-center">
|
||||||
|
{{- form_widget(child) -}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor -%}
|
||||||
|
</div>
|
||||||
|
{%- else -%}
|
||||||
|
{{- block('choice_widget_collapsed') -}}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endblock pickup_choice_widget -%}
|
||||||
|
|
||||||
{%- block birthday_widget -%}
|
{%- block birthday_widget -%}
|
||||||
<div class="grid grid-cols-3 gap-x-1">
|
<div class="grid grid-cols-3 gap-x-1">
|
||||||
{{ form_widget(form.children['day']) }}
|
{{ form_widget(form.children['day']) }}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Form;
|
||||||
|
|
||||||
|
use App\Entity\Destination;
|
||||||
|
use App\Service\Common\PickupOptions;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
|
use Symfony\Component\Form\FormFactoryInterface;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the radio list the way the assignment form does, because the parts that break here
|
||||||
|
* only break at render time: templates/forms.html.twig reads the departure and the street off
|
||||||
|
* each choice, twig runs with strict_variables, and the "Keine Busbegleitung" placeholder is a
|
||||||
|
* choice that carries neither.
|
||||||
|
*/
|
||||||
|
class PickupChoiceWidgetTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
public function testEveryBoardingPointIsRenderedWithItsTimeAndStreet(): void
|
||||||
|
{
|
||||||
|
$html = $this->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<array<string, mixed>> $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()])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Assignment;
|
||||||
|
|
||||||
|
use App\Service\Assignment\PickupDepartureOrder;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fixtures here are real destination rows, because the shapes that make this hard are
|
||||||
|
* not ones anyone would invent: BusProNet mixes dated and bare times in the same row, and
|
||||||
|
* the departures run across midnight.
|
||||||
|
*/
|
||||||
|
class PickupDepartureOrderTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testBareClockTimesFollowTheDatedEveningDepartures(): void
|
||||||
|
{
|
||||||
|
// destination PMVUS/090324, in its stored order
|
||||||
|
$sorted = PickupDepartureOrder::sort([
|
||||||
|
self::pickup(1, 'Hannover', '08.03.2024 20:45:00'),
|
||||||
|
self::pickup(2, 'Kassel', '00:30'),
|
||||||
|
self::pickup(3, 'Basel', '04:15'),
|
||||||
|
self::pickup(4, 'Karlsruhe', '02:45'),
|
||||||
|
self::pickup(5, 'Göttingen', '08.03.2024 22:00:00'),
|
||||||
|
self::pickup(6, 'Frankfurt', '01:30'),
|
||||||
|
self::pickup(7, 'Mannheim', '08.03.2024 23:30:00'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->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<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function pickup(int $busProId, string $city, ?string $time): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'busProId' => $busProId,
|
||||||
|
'city' => $city,
|
||||||
|
'street' => 'Irgendwo 1',
|
||||||
|
'time' => $time,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Common;
|
||||||
|
|
||||||
|
use App\Entity\Destination;
|
||||||
|
use App\Service\Common\PickupOptions;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All three pickup fields used to key their choices by the town, which is not unique: six
|
||||||
|
* destinations have two boarding points in the same town and one of them has both at 03:15.
|
||||||
|
* The second one never reached the form, so it could not be picked.
|
||||||
|
*/
|
||||||
|
class PickupOptionsTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Destination DWWPGLH090126, where only the street tells the two apart.
|
||||||
|
*/
|
||||||
|
public function testTwoBoardingPointsInOneTownBothSurviveInDepartureOrder(): void
|
||||||
|
{
|
||||||
|
$options = PickupOptions::inDepartureOrder($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'));
|
||||||
|
$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<array<string, mixed>> $pickups
|
||||||
|
*/
|
||||||
|
private function destination(array $pickups): Destination
|
||||||
|
{
|
||||||
|
return (new Destination())->setPickups($pickups);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user