diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 125b7e3..9333431 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -982,12 +982,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider /** * Filters ski pass choices for display. * - * Unlike other services, ski passes are shown even when age-restricted - * (marked as readonly instead of hidden) to avoid user confusion about - * included ski passes not being visible. - * - * Baby filtering is preserved: babies only see services explicitly - * including their age range. + * Ski passes follow the same age evaluation rules as the rest of the booking flow. + * Participants only see passes that match their age or birth year. In edit mode, + * the currently booked ski pass is preserved even if it would otherwise be filtered + * out, so existing bookings remain renderable and editable. * * @param Service[] $services Array of ski pass Service objects to filter * @param BookingDto $bookingDto The booking DTO containing participant data @@ -1004,17 +1002,18 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider return []; } - // Check if participant is a baby - $age = $participant->getAge($bookingDto->travel->dateFrom); - $isBaby = null !== $age && $age <= Constants::BABY_MAX_AGE; + $filteredServices = $this->filterServicesByAgeConstraints($services, $bookingDto, $participantIndex); - // For babies: keep existing filtering logic (only show services with explicit baby age ranges) - if ($isBaby) { - return $this->filterServicesByAgeConstraints($services, $bookingDto, $participantIndex); + if (BookingDto::MODE_EDIT !== $bookingDto->getMode() || null === $participant->skiPass?->id) { + return $filteredServices; } - // For non-babies: return all services (age-restricted ones will be marked readonly in choice_attr) - return $services; + $currentSkiPassId = $participant->skiPass->id; + if (false === isset($filteredServices[$currentSkiPassId])) { + $filteredServices[$currentSkiPassId] = $services[$currentSkiPassId] ?? $participant->skiPass; + } + + return $filteredServices; } /** diff --git a/tests/Form/Service/ParticipantFieldOptionsProviderBabyTest.php b/tests/Form/Service/ParticipantFieldOptionsProviderBabyTest.php index a984c17..f5e8194 100644 --- a/tests/Form/Service/ParticipantFieldOptionsProviderBabyTest.php +++ b/tests/Form/Service/ParticipantFieldOptionsProviderBabyTest.php @@ -5,8 +5,10 @@ declare(strict_types=1); namespace App\Tests\Form\Service; use App\BusProNet\Constants; +use App\BusProNet\Model\Booking; use App\BusProNet\Model\Service; use App\BusProNet\Model\Travel; +use App\BusProNet\XmlParser\TravelParser; use App\BusProNet\Utility\DirectionMapper; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; @@ -16,6 +18,7 @@ use App\Service\InsuranceManager; use App\Service\ServiceAvailabilityCalculator; use App\Service\ServiceLabelFormatter; use PHPUnit\Framework\TestCase; +use Symfony\Component\DomCrawler\Crawler; use Symfony\Contracts\Translation\TranslatorInterface; class ParticipantFieldOptionsProviderBabyTest extends TestCase @@ -30,6 +33,45 @@ class ParticipantFieldOptionsProviderBabyTest extends TestCase $priceCalculatorService = $this->createMock(BookingPriceCalculator::class); $serviceLabelFormatter = new ServiceLabelFormatter(); $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback( + function (string $message, array $parameters = []): string { + return match ($message) { + 'service.age_constraint.prefix' => sprintf('Nur %s', $parameters['%constraint%'] ?? ''), + 'service.age_constraint.absolute_age.range' => sprintf( + 'Alter %s-%s', + $parameters['%ageFrom%'] ?? '', + $parameters['%ageTo%'] ?? '' + ), + 'service.age_constraint.absolute_age.min' => sprintf( + 'Alter ab %s', + $parameters['%ageFrom%'] ?? '' + ), + 'service.age_constraint.absolute_age.max' => sprintf( + 'Alter bis %s', + $parameters['%ageTo%'] ?? '' + ), + 'service.age_constraint.birth_year.single' => sprintf( + 'Jahrgang %s', + $parameters['%year%'] ?? '' + ), + 'service.age_constraint.birth_year.range' => sprintf( + 'Jahrgang %s-%s', + $parameters['%yearFrom%'] ?? '', + $parameters['%yearTo%'] ?? '' + ), + 'service.age_constraint.birth_year.min' => sprintf( + 'Jahrgang ab %s', + $parameters['%yearFrom%'] ?? '' + ), + 'service.age_constraint.birth_year.max' => sprintf( + 'Jahrgang bis %s', + $parameters['%yearTo%'] ?? '' + ), + 'service.age_constraint.mixed.separator' => ' und ', + default => $message, + }; + } + ); $this->provider = new ParticipantFieldOptionsProvider( $this->serviceAvailabilityCalculator, @@ -128,6 +170,75 @@ class ParticipantFieldOptionsProviderBabyTest extends TestCase $this->assertContains(3, $choiceIds, 'Service without age restrictions should be shown for adult'); } + public function testSkiPassesAreFilteredByBirthYearForAdultParticipant(): void + { + $travel = $this->createTravelWithExportedSkiPasses([195992, 195984]); + $bookingDto = new BookingDto($travel, 1); + + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = new \DateTimeImmutable('1990-12-26'); + $bookingDto->participants[0] = $participant; + + $options = $this->provider->getFieldOptions('skiPass', $bookingDto, 0); + + $this->assertEmpty($options, 'Adult participant should not see birth-year-mismatched ski passes'); + } + + public function testSkiPassesKeepMatchingBirthYearAndHideMismatchedPasses(): void + { + $travel = $this->createTravelWithExportedSkiPasses([195992, 195984]); + $bookingDto = new BookingDto($travel, 1); + + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = new \DateTimeImmutable('2011-12-26'); + $bookingDto->participants[0] = $participant; + + $options = $this->provider->getFieldOptions('skiPass', $bookingDto, 0); + + $choices = $options['choices']; + $choiceIds = array_map(fn (Service $service) => $service->id, $choices); + + $this->assertContains(195992, $choiceIds, 'Matching 2009-2012 ski pass should remain visible'); + $this->assertNotContains(195984, $choiceIds, '2013-2020 ski pass should be hidden for a 2011-born participant'); + } + + public function testEditModePreservesBookedSkiPassEvenWhenBirthYearMismatched(): void + { + $travel = $this->createTravelWithExportedSkiPasses([195992, 195984]); + $bookingDto = new BookingDto($travel, 1); + + $bookedSkiPass = clone $travel->additionalServices[195992]; + $bookedSkiPass->mapping = [0]; + + $booking = new Booking(); + $booking->additionalServices = [ + 195992 => $bookedSkiPass, + ]; + $bookingDto->booking = $booking; + + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = new \DateTimeImmutable('1990-12-26'); + $participant->skiPass = $bookedSkiPass; + $bookingDto->participants[0] = $participant; + + $options = $this->provider->getFieldOptions('skiPass', $bookingDto, 0); + + $choices = $options['choices']; + $choiceIds = array_map(fn (Service $service) => $service->id, $choices); + + $this->assertContains(195992, $choiceIds, 'Booked ski pass should remain visible in edit mode'); + + $choiceAttr = $options['choice_attr']; + $attributes = $choiceAttr($choices[195992] ?? $choices[array_key_first($choices)]); + + $this->assertTrue($attributes['readonly']); + $this->assertArrayHasKey('data-tooltip', $attributes); + $this->assertStringContainsString('2009', $attributes['data-tooltip']); + } + public function testBabyGetRegularPkwInTransportation(): void { $travel = $this->createTravelWithTransportation(); @@ -364,4 +475,22 @@ class ParticipantFieldOptionsProviderBabyTest extends TestCase return $travel; } + + /** + * @param list $serviceIds + */ + private function createTravelWithExportedSkiPasses(array $serviceIds): Travel + { + $parser = new TravelParser(); + $crawler = new Crawler(file_get_contents('/var/www/html/var/xmlexport/Ziel_2456.xml')); + $travelNode = $crawler->filterXPath('//reise/termin')->first(); + $travel = $parser->parse($travelNode); + + $travel->additionalServices = array_intersect_key( + $travel->additionalServices, + array_flip($serviceIds) + ); + + return $travel; + } }