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
@@ -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