fix: avoid selection trap for mandatory services with age constraints
This commit is contained in:
@@ -191,12 +191,19 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
|
|
||||||
$attributes = [];
|
$attributes = [];
|
||||||
|
|
||||||
// Make readonly and checked for mandatory services (pre-selection handled by service layer)
|
// Make mandatory services readonly only when actually selected by participant.
|
||||||
|
// Unselected mandatory services remain interactive so the user can select them
|
||||||
|
// after age changes (e.g. agency skeleton bookings where DOB is corrected).
|
||||||
if (true === $service->mandatory) {
|
if (true === $service->mandatory) {
|
||||||
$attributes['checked'] = true;
|
$participant = $bookingDto->getParticipant($participantIndex);
|
||||||
|
$isSelected = null !== $participant
|
||||||
|
&& $this->hasServiceById($participant->additionalServices, $service->id);
|
||||||
|
|
||||||
|
if (true === $isSelected) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'Diese Leistung ist nicht abwählbar';
|
$attributes['data-tooltip'] = 'Diese Leistung ist nicht abwählbar';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add service description as data attribute for frontend use
|
// Add service description as data attribute for frontend use
|
||||||
if (null !== $service->description && '' !== trim($service->description)) {
|
if (null !== $service->description && '' !== trim($service->description)) {
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Form\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Constants;
|
||||||
|
use App\BusProNet\Model\Service;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Model\ParticipantDto;
|
||||||
|
use App\Form\Service\ParticipantFieldOptionsProvider;
|
||||||
|
use App\Service\BookingPriceCalculatorService;
|
||||||
|
use App\Service\InsuranceService;
|
||||||
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that mandatory services in choice_attr are only locked (readonly)
|
||||||
|
* when actually selected in the participant's DTO, preventing incorrect
|
||||||
|
* force-locking after age-related service changes.
|
||||||
|
*/
|
||||||
|
class ParticipantFieldOptionsProviderMandatoryServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private ParticipantFieldOptionsProvider $provider;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$serviceAvailabilityCalculator = $this->createMock(ServiceAvailabilityCalculator::class);
|
||||||
|
$insuranceService = $this->createMock(InsuranceService::class);
|
||||||
|
$priceCalculatorService = $this->createMock(BookingPriceCalculatorService::class);
|
||||||
|
$translator = $this->createMock(TranslatorInterface::class);
|
||||||
|
|
||||||
|
$this->provider = new ParticipantFieldOptionsProvider(
|
||||||
|
$serviceAvailabilityCalculator,
|
||||||
|
$insuranceService,
|
||||||
|
$priceCalculatorService,
|
||||||
|
$translator
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMandatoryServiceSelectedHasReadonlyAndTooltip(): void
|
||||||
|
{
|
||||||
|
$mandatoryService = $this->createMandatoryService(1, 'Ortstaxe');
|
||||||
|
$travel = $this->createTravelWithAdditionalServices([$mandatoryService]);
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->index = 0;
|
||||||
|
$participant->dateOfBirth = $travel->dateFrom->modify('-25 years');
|
||||||
|
$participant->additionalServices = [$mandatoryService];
|
||||||
|
$bookingDto->participants[0] = $participant;
|
||||||
|
|
||||||
|
$options = $this->provider->getFieldOptions('additionalServices', $bookingDto, 0);
|
||||||
|
$choiceAttr = $options['choice_attr'];
|
||||||
|
$attributes = $choiceAttr($mandatoryService);
|
||||||
|
|
||||||
|
$this->assertTrue($attributes['readonly']);
|
||||||
|
$this->assertSame('Diese Leistung ist nicht abwählbar', $attributes['data-tooltip']);
|
||||||
|
$this->assertArrayNotHasKey('checked', $attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMandatoryServiceNotSelectedHasNoReadonlyOrChecked(): void
|
||||||
|
{
|
||||||
|
$mandatoryService = $this->createMandatoryService(1, 'Ortstaxe');
|
||||||
|
$travel = $this->createTravelWithAdditionalServices([$mandatoryService]);
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->index = 0;
|
||||||
|
$participant->dateOfBirth = $travel->dateFrom->modify('-25 years');
|
||||||
|
$participant->additionalServices = [];
|
||||||
|
$bookingDto->participants[0] = $participant;
|
||||||
|
|
||||||
|
$options = $this->provider->getFieldOptions('additionalServices', $bookingDto, 0);
|
||||||
|
$choiceAttr = $options['choice_attr'];
|
||||||
|
$attributes = $choiceAttr($mandatoryService);
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('readonly', $attributes);
|
||||||
|
$this->assertArrayNotHasKey('checked', $attributes);
|
||||||
|
$this->assertArrayNotHasKey('data-tooltip', $attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonMandatoryServiceUnaffected(): void
|
||||||
|
{
|
||||||
|
$nonMandatoryService = new Service();
|
||||||
|
$nonMandatoryService->id = 2;
|
||||||
|
$nonMandatoryService->label = 'Keycard-Pfand';
|
||||||
|
$nonMandatoryService->subType = Constants::TOKEN_ADDITIONAL;
|
||||||
|
$nonMandatoryService->available = 10;
|
||||||
|
$nonMandatoryService->price = 10.0;
|
||||||
|
$nonMandatoryService->mandatory = false;
|
||||||
|
|
||||||
|
$travel = $this->createTravelWithAdditionalServices([$nonMandatoryService]);
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->index = 0;
|
||||||
|
$participant->dateOfBirth = $travel->dateFrom->modify('-25 years');
|
||||||
|
$participant->additionalServices = [$nonMandatoryService];
|
||||||
|
$bookingDto->participants[0] = $participant;
|
||||||
|
|
||||||
|
$options = $this->provider->getFieldOptions('additionalServices', $bookingDto, 0);
|
||||||
|
$choiceAttr = $options['choice_attr'];
|
||||||
|
$attributes = $choiceAttr($nonMandatoryService);
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('readonly', $attributes);
|
||||||
|
$this->assertArrayNotHasKey('checked', $attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNullParticipantReturnsEmptyOptions(): void
|
||||||
|
{
|
||||||
|
$mandatoryService = $this->createMandatoryService(1, 'Ortstaxe');
|
||||||
|
$travel = $this->createTravelWithAdditionalServices([$mandatoryService]);
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
|
||||||
|
// Do not set participant — age filtering cannot determine choices, returns empty
|
||||||
|
$options = $this->provider->getFieldOptions('additionalServices', $bookingDto, 0);
|
||||||
|
|
||||||
|
$this->assertEmpty($options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createMandatoryService(int $id, string $label): Service
|
||||||
|
{
|
||||||
|
$service = new Service();
|
||||||
|
$service->id = $id;
|
||||||
|
$service->label = $label;
|
||||||
|
$service->subType = Constants::TOKEN_ADDITIONAL;
|
||||||
|
$service->available = 10;
|
||||||
|
$service->price = 5.0;
|
||||||
|
$service->mandatory = true;
|
||||||
|
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTravelWithAdditionalServices(array $services): Travel
|
||||||
|
{
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->dateFrom = new \DateTimeImmutable('+30 days');
|
||||||
|
$travel->dateTo = new \DateTimeImmutable('+37 days');
|
||||||
|
|
||||||
|
$indexed = [];
|
||||||
|
foreach ($services as $service) {
|
||||||
|
$indexed[$service->id] = $service;
|
||||||
|
}
|
||||||
|
$travel->additionalServices = $indexed;
|
||||||
|
|
||||||
|
return $travel;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user