calculator = new ServiceAvailabilityCalculator(); } public function testParkingServiceIsUnavailableWhenSoldOut(): void { $parkingService = $this->createParkingService(id: 1, available: 0); $bookingDto = $this->createBookingDtoWithServices([$parkingService]); $this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); } public function testParkingServiceIsAvailableWhenQuotaRemains(): void { $parkingService = $this->createParkingService(id: 1, available: 5); $bookingDto = $this->createBookingDtoWithServices([$parkingService]); $this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); } public function testParkingSelectedByOtherParticipantCountsTowardUsage(): void { $parkingService = $this->createParkingService(id: 1, available: 1); $bookingDto = $this->createBookingDtoWithServices([$parkingService]); $otherParticipant = new ParticipantDto(); $otherParticipant->parkingService = $parkingService; $bookingDto->participants[1] = $otherParticipant; // Only one parking slot remains, and another participant already claimed it, // so it must show unavailable for the current participant (index 0) $this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); } public function testServiceOnBookingStopIsUnavailableDespiteUnlimitedQuota(): void { $skiPass = $this->createSkiPass(id: 1, available: null, status: Constants::STATUS_BLOCKED); $bookingDto = $this->createBookingDtoWithServices([$skiPass]); $this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); } public function testServiceOnBookingStopIsUnavailableDespiteRemainingQuota(): void { $skiPass = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_BLOCKED); $bookingDto = $this->createBookingDtoWithServices([$skiPass]); $this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); } public function testFilterAvailableServicesRemovesSoldOutAndBlockedServices(): void { $bookable = $this->createSkiPass(id: 1, available: 5); $soldOut = $this->createSkiPass(id: 2, available: 0); $blocked = $this->createSkiPass(id: 3, available: null, status: Constants::STATUS_BLOCKED); $unlimited = $this->createSkiPass(id: 4, available: null); $bookingDto = $this->createBookingDtoWithServices([$bookable, $soldOut, $blocked, $unlimited]); $filtered = $this->calculator->filterAvailableServices( $bookingDto->travel->additionalServices, $bookingDto, 0 ); $this->assertSame([1, 4], array_keys($filtered)); } public function testOnRequestServiceStaysAvailableInCreateMode(): void { $skiPass = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST); $bookingDto = $this->createBookingDtoWithServices([$skiPass]); // Create may still move the whole booking to status 'A', so on request is bookable there $this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); } public function testOnRequestServiceIsUnavailableInEditMode(): void { $skiPass = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST); $bookingDto = $this->createEditBookingDtoWithServices([$skiPass]); $this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); } public function testOnRequestServiceAlreadyHeldStaysAvailableInEditMode(): void { $skiPass = $this->createSkiPass(id: 1, available: 0, status: Constants::STATUS_ON_REQUEST); $bookingDto = $this->createEditBookingDtoWithServices([$skiPass], heldServiceIds: [1]); // Withdrawing a booked service breaks the Leistung/Teilnehmer counts, so it must be keepable $this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); } public function testSeatsHeldByOtherParticipantsDoNotCountTowardUsageInEditMode(): void { // BusPro reports frei="3" after deducting the seats this booking already holds $parkingService = $this->createParkingService(id: 1, available: 3); $bookingDto = $this->createEditBookingDtoWithServices([$parkingService]); $this->holdParkingForOtherParticipants($bookingDto, $parkingService, [1, 2, 3, 4, 5, 6, 7, 8]); $this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); $this->assertSame(3, $this->calculator->calculateRemainingAvailability($bookingDto, 0)[1]); } public function testNewSelectionByOtherParticipantStillCountsTowardUsageInEditMode(): void { $parkingService = $this->createParkingService(id: 1, available: 1); $bookingDto = $this->createEditBookingDtoWithServices([$parkingService]); $otherParticipant = new ParticipantDto(); $otherParticipant->parkingService = $parkingService; $bookingDto->participants[1] = $otherParticipant; // Participant 1 picks the last slot in this edit without holding it in the booking $this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); } public function testAutoBookedOnRequestServiceStaysAvailableInEditMode(): void { $service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST); $service->autoBook = true; $bookingDto = $this->createEditBookingDtoWithServices([$service]); $this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); } public function testMandatoryNonTransportOnRequestServiceStaysAvailableInEditMode(): void { $service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST); $service->mandatory = true; $service->category = Constants::CATEGORY_ADDITIONAL; $bookingDto = $this->createEditBookingDtoWithServices([$service]); // Blocking a service the mandatory-services validator requires makes the form unsatisfiable $this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); } public function testMandatoryTransportOnRequestServiceIsStillUnavailableInEditMode(): void { $service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST); $service->mandatory = true; $service->category = Constants::CATEGORY_TRANSPORTATION; $bookingDto = $this->createEditBookingDtoWithServices([$service]); // pflicht on a transport leg means "pick one of this group", not "compulsory" $this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0)); } public function testFilterAvailableServicesNeverEmptiesAGroupOnTheOnRequestRuleAlone(): void { $first = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST); $second = $this->createSkiPass(id: 2, available: 10, status: Constants::STATUS_ON_REQUEST); $bookingDto = $this->createEditBookingDtoWithServices([$first, $second]); $filtered = $this->calculator->filterAvailableServices( $bookingDto->travel->additionalServices, $bookingDto, 0 ); $this->assertSame([1, 2], array_keys($filtered)); } public function testFilterAvailableServicesStillEmptiesAGroupThatIsSoldOutOrBlocked(): void { $soldOut = $this->createSkiPass(id: 1, available: 0, status: Constants::STATUS_ON_REQUEST); $blocked = $this->createSkiPass(id: 2, available: null, status: Constants::STATUS_BLOCKED); $bookingDto = $this->createEditBookingDtoWithServices([$soldOut, $blocked]); $filtered = $this->calculator->filterAvailableServices( $bookingDto->travel->additionalServices, $bookingDto, 0 ); $this->assertSame([], array_keys($filtered)); } private function createParkingService(int $id, int $available): Service { $service = new Service(); $service->id = $id; $service->label = 'Parkplatz'; $service->subType = Constants::TOKEN_PARKING; $service->available = $available; return $service; } private function createSkiPass(int $id, ?int $available, string $status = Constants::STATUS_AVAILABLE): Service { $service = new Service(); $service->id = $id; $service->label = sprintf('Skipass %d', $id); $service->subType = Constants::TOKEN_SKI_PASS; $service->available = $available; $service->status = $status; return $service; } private function createBookingDtoWithServices(array $services): BookingDto { $travel = new Travel(); $indexed = []; foreach ($services as $service) { $indexed[$service->id] = $service; } $travel->additionalServices = $indexed; $bookingDto = new BookingDto($travel, 1); $bookingDto->participants[0] = new ParticipantDto(); return $bookingDto; } /** * @param array $services * @param list $heldServiceIds IDs participant 0 already holds in the booking */ private function createEditBookingDtoWithServices(array $services, array $heldServiceIds = []): BookingDto { $bookingDto = $this->createBookingDtoWithServices($services); $heldServices = []; foreach ($heldServiceIds as $serviceId) { $held = clone $bookingDto->travel->additionalServices[$serviceId]; $held->mapping = [0]; $heldServices[$serviceId] = $held; } // A non-null booking is what puts the DTO into edit mode $booking = new Booking(); $booking->additionalServices = $heldServices; $bookingDto->booking = $booking; return $bookingDto; } /** * Prefills the given participants with the parking service, as the edit form does for a * service they hold in the live booking. * * @param list $participantIndexes */ private function holdParkingForOtherParticipants(BookingDto $bookingDto, Service $parkingService, array $participantIndexes): void { $held = clone $parkingService; $held->mapping = $participantIndexes; $bookingDto->booking->additionalServices[$parkingService->id] = $held; foreach ($participantIndexes as $index) { $participant = new ParticipantDto(); $participant->parkingService = $parkingService; $bookingDto->participants[$index] = $participant; } } }