diff --git a/app/Allocator/Stand/AbstractArrivalStandAllocator.php b/app/Allocator/Stand/AbstractArrivalStandAllocator.php deleted file mode 100644 index 42e5d8733..000000000 --- a/app/Allocator/Stand/AbstractArrivalStandAllocator.php +++ /dev/null @@ -1,88 +0,0 @@ -getPossibleStands($aircraft)->first()?->id; - } - - /* - * Base query for stands at the arrival airfield, which are of a suitable - * size (or max size if no type) for the aircraft and not occupied. - */ - private function getArrivalAirfieldStandQuery(NetworkAircraft $aircraft): Builder - { - return Stand::whereHas('airfield', function (Builder $query) use ($aircraft) { - $query->where('code', $aircraft->planned_destairport); - }) - ->sizeAppropriate(Aircraft::where('code', $aircraft->planned_aircraft_short)->first()) - ->available() - ->select('stands.*'); - } - - /** - * Get all the possible stands that are available for allocation. - * - * @param NetworkAircraft $aircraft - * @return Collection|Stand[] - */ - private function getPossibleStands(NetworkAircraft $aircraft): Collection - { - $orderedQuery = $this->getOrderedStandsQuery($this->getArrivalAirfieldStandQuery($aircraft), $aircraft); - return $orderedQuery === null - ? new Collection() - : $this->applyBaseOrderingToStandsQuery($orderedQuery, $aircraft)->get(); - } - - /** - * Apply the base ordering to the stands query. This orders stands by weight ascending - * so smaller aircraft prefer smaller stands and also applies an element of randomness - * so we don't just put all the aircraft next to each other. - * - * @param Builder $query - * @return Builder - */ - private function applyBaseOrderingToStandsQuery(Builder $query, NetworkAircraft $aircraft): Builder - { - return $query->orderByAerodromeReferenceCode() - ->orderByAssignmentPriority() - ->leftJoin('stand_requests as other_stand_requests', function (JoinClause $join) use ($aircraft) { - // Prefer stands that haven't been requested by someone else - $join->on('stands.id', '=', 'other_stand_requests.stand_id') - ->on('other_stand_requests.user_id', '<>', $join->raw($aircraft->cid)) - ->on( - 'other_stand_requests.requested_time', - '>', - $join->raw( - sprintf( - '\'%s\'', - Carbon::now() - ) - ) - ); - }) - ->orderByRaw('other_stand_requests.id IS NULL') - ->inRandomOrder(); - } - - /** - * If true, will prefer stands that haven't been requsted by the user; - */ - protected function prefersNonRequestedStands(): bool - { - return true; - } - - abstract protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircraft): ?Builder; -} diff --git a/app/Allocator/Stand/AirlineAircraftArrivalStandAllocator.php b/app/Allocator/Stand/AirlineAircraftArrivalStandAllocator.php index 5230864c7..2f80fc6df 100644 --- a/app/Allocator/Stand/AirlineAircraftArrivalStandAllocator.php +++ b/app/Allocator/Stand/AirlineAircraftArrivalStandAllocator.php @@ -2,35 +2,47 @@ namespace App\Allocator\Stand; -use App\Models\Aircraft\Aircraft; use App\Models\Vatsim\NetworkAircraft; -use App\Services\AirlineService; +use Closure; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; -class AirlineAircraftArrivalStandAllocator extends AbstractArrivalStandAllocator +class AirlineAircraftArrivalStandAllocator implements ArrivalStandAllocator, RankableArrivalStandAllocator { - private AirlineService $airlineService; + use SelectsFromAirlineSpecificStands; - public function __construct(AirlineService $airlineService) + /** + * This allocator uses the standard SelectsFromAirlineSpecificStands trait to generate a stand query, + * with additional filters that only stands for a specific aircraft type are selected. + */ + public function allocate(NetworkAircraft $aircraft): ?int { - $this->airlineService = $airlineService; + // We cant allocate a stand if we don't know the airline or aircraft type + if ($aircraft->airline_id === null || $aircraft->aircraft_id === null) { + return null; + } + + return $this->selectAirlineSpecificStands( + $aircraft, + $this->queryFilter($aircraft) + ); } - protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircraft): ?Builder + public function getRankedStandAllocation(NetworkAircraft $aircraft): Collection { - $airline = $this->airlineService->getAirlineForAircraft($aircraft); - if ($airline === null) { - return null; + // We cant allocate a stand if we don't know the airline or aircraft type + if ($aircraft->airline_id === null || $aircraft->aircraft_id === null) { + return collect(); } - $aircraftType = Aircraft::where('code', $aircraft->planned_aircraft)->first(); - if (!$aircraftType) { - return null; - } + return $this->selectRankedAirlineSpecificStands( + $aircraft, + $this->queryFilter($aircraft) + ); + } - return $stands->with('airlines') - ->airline($airline) - ->where('airline_stand.aircraft_id', $aircraftType->id) - ->orderBy('airline_stand.priority'); + private function queryFilter(NetworkAircraft $aircraft): Closure + { + return fn (Builder $query) => $query->where('airline_stand.aircraft_id', $aircraft->aircraft_id); } } diff --git a/app/Allocator/Stand/AirlineAircraftTerminalArrivalStandAllocator.php b/app/Allocator/Stand/AirlineAircraftTerminalArrivalStandAllocator.php index 19e0687f6..bac395cfa 100644 --- a/app/Allocator/Stand/AirlineAircraftTerminalArrivalStandAllocator.php +++ b/app/Allocator/Stand/AirlineAircraftTerminalArrivalStandAllocator.php @@ -2,36 +2,53 @@ namespace App\Allocator\Stand; -use App\Models\Aircraft\Aircraft; use App\Models\Vatsim\NetworkAircraft; -use App\Services\AirlineService; +use Closure; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; -class AirlineAircraftTerminalArrivalStandAllocator extends AbstractArrivalStandAllocator +class AirlineAircraftTerminalArrivalStandAllocator implements ArrivalStandAllocator, RankableArrivalStandAllocator { - private AirlineService $airlineService; + use SelectsStandsFromAirlineSpecificTerminals; - public function __construct(AirlineService $airlineService) + /* + * This allocator: + * + * - Selects stands that are size appropriate and available + * - Filters these to stands at terminals that are specifically selected for the airline AND a given aircraft type + * - Orders these stands by the airline's priority for the stand + * - Orders these stands by the common conditions, minus the general allocation priority + * (see OrdersStandsByCommonConditions) + * - Selects the first stand that pops up + */ + public function allocate(NetworkAircraft $aircraft): ?int { - $this->airlineService = $airlineService; + // We can only allocate a stand if we know the airline and aircraft type + if ($aircraft->airline_id === null || $aircraft->aircraft_id === null) { + return null; + } + + return $this->selectStandsAtAirlineSpecificTerminals( + $aircraft, + $this->queryFilter($aircraft) + ); } - protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircraft): ?Builder + public function getRankedStandAllocation(NetworkAircraft $aircraft): Collection { - $airline = $this->airlineService->getAirlineForAircraft($aircraft); - if ($airline === null) { - return null; + // We cant allocate a stand if we don't know the airline or aircraft type + if ($aircraft->airline_id === null || $aircraft->aircraft_id === null) { + return collect(); } - $aircraftType = Aircraft::where('code', $aircraft->planned_aircraft)->first(); - if (!$aircraftType) { - return null; - } + return $this->selectRankedStandsAtAirlineSpecificTerminals( + $aircraft, + $this->queryFilter($aircraft) + ); + } - return $stands->join('terminals', 'terminals.id', '=', 'stands.terminal_id') - ->join('airline_terminal', 'terminals.id', '=', 'airline_terminal.terminal_id') - ->where('airline_terminal.airline_id', $airline->id) - ->where('airline_terminal.aircraft_id', $aircraftType->id) - ->orderBy('airline_terminal.priority'); + private function queryFilter(NetworkAircraft $aircraft): Closure + { + return fn (Builder $query) => $query->where('airline_terminal.aircraft_id', $aircraft->aircraft_id); } } diff --git a/app/Allocator/Stand/AirlineArrivalStandAllocator.php b/app/Allocator/Stand/AirlineArrivalStandAllocator.php deleted file mode 100644 index 75fa7f8cc..000000000 --- a/app/Allocator/Stand/AirlineArrivalStandAllocator.php +++ /dev/null @@ -1,30 +0,0 @@ -airlineService = $airlineService; - } - - protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircraft): ?Builder - { - $airline = $this->airlineService->getAirlineForAircraft($aircraft); - return $airline === null - ? null - : $stands->airline($airline) - ->whereNull('airline_stand.destination') - ->whereNull('airline_stand.callsign_slug') - ->whereNull('airline_stand.full_callsign') - ->whereNull('airline_stand.aircraft_id') - ->orderBy('airline_stand.priority'); - } -} diff --git a/app/Allocator/Stand/AirlineCallsignArrivalStandAllocator.php b/app/Allocator/Stand/AirlineCallsignArrivalStandAllocator.php index 72df86540..986c8033b 100644 --- a/app/Allocator/Stand/AirlineCallsignArrivalStandAllocator.php +++ b/app/Allocator/Stand/AirlineCallsignArrivalStandAllocator.php @@ -4,29 +4,61 @@ use App\Models\Vatsim\NetworkAircraft; use App\Services\AirlineService; +use Closure; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; -class AirlineCallsignArrivalStandAllocator extends AbstractArrivalStandAllocator +class AirlineCallsignArrivalStandAllocator implements ArrivalStandAllocator, RankableArrivalStandAllocator { + use SelectsFromAirlineSpecificStands; use UsesCallsignSlugs; - private AirlineService $airlineService; + private readonly AirlineService $airlineService; public function __construct(AirlineService $airlineService) { $this->airlineService = $airlineService; } - protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircraft): ?Builder + /** + * This allocator: + * + * - Selects stands that are size appropriate and available + * - Filters these to stands that are specifically selected for the airline and a specific callsign + * - Orders these stands by the airline's priority for the stand + * - Orders these stands by the common conditions, minus the general allocation priority + * (see OrdersStandsByCommonConditions) + * - Selects the first stand that pops up + */ + public function allocate(NetworkAircraft $aircraft): ?int { - $airline = $this->airlineService->getAirlineForAircraft($aircraft); - if ($airline === null) { + // We can only allocate a stand if we know the airline + if ($aircraft->airline_id === null) { return null; } - return $stands->with('airlines') - ->airline($airline) - ->where('airline_stand.full_callsign', $this->getFullCallsignSlug($aircraft)) - ->orderBy('airline_stand.priority'); + return $this->selectAirlineSpecificStands( + $aircraft, + $this->queryFilter($aircraft) + ); + } + + public function getRankedStandAllocation(NetworkAircraft $aircraft): Collection + { + // We can only allocate a stand if we know the airline + if ($aircraft->airline_id === null) { + return collect(); + } + + return $this->selectRankedAirlineSpecificStands( + $aircraft, + $this->queryFilter($aircraft) + ); + } + + private function queryFilter(NetworkAircraft $aircraft): Closure + { + return fn (Builder $query) => + $query->where('airline_stand.full_callsign', $this->getFullCallsignSlug($aircraft)); } } diff --git a/app/Allocator/Stand/AirlineCallsignSlugArrivalStandAllocator.php b/app/Allocator/Stand/AirlineCallsignSlugArrivalStandAllocator.php index fddf60824..82e388617 100644 --- a/app/Allocator/Stand/AirlineCallsignSlugArrivalStandAllocator.php +++ b/app/Allocator/Stand/AirlineCallsignSlugArrivalStandAllocator.php @@ -4,12 +4,20 @@ use App\Models\Vatsim\NetworkAircraft; use App\Services\AirlineService; +use Closure; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; -class AirlineCallsignSlugArrivalStandAllocator extends AbstractArrivalStandAllocator +class AirlineCallsignSlugArrivalStandAllocator implements ArrivalStandAllocator, RankableArrivalStandAllocator { + use SelectsFromAirlineSpecificStands; use UsesCallsignSlugs; + private const ORDER_BYS = [ + 'airline_stand.callsign_slug IS NOT NULL', + 'LENGTH(airline_stand.callsign_slug) DESC', + ]; + private AirlineService $airlineService; public function __construct(AirlineService $airlineService) @@ -17,17 +25,47 @@ public function __construct(AirlineService $airlineService) $this->airlineService = $airlineService; } - protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircraft): ?Builder + /** + * This allocator: + * + * - Selects stands that are size appropriate and available + * - Filters these to stands that are specifically selected for the airline and a specific callsign slug + * - Orders these stands by the airline's priority for the stand + * - Orders these stands by the common conditions, minus the general allocation priority + * (see OrdersStandsByCommonConditions) + * - Selects the first stand that pops up + */ + public function allocate(NetworkAircraft $aircraft): ?int { - $airline = $this->airlineService->getAirlineForAircraft($aircraft); - if ($airline === null) { + // We can't allocate a stand if we don't know the airline + if ($aircraft->airline_id === null || $aircraft->aircraft_id === null) { return null; } - return $stands->with('airlines') - ->airlineCallsign($airline, $this->getCallsignSlugs($aircraft)) - ->orderByRaw('airline_stand.callsign_slug IS NOT NULL') - ->orderByRaw('LENGTH(airline_stand.callsign_slug) DESC') - ->orderBy('airline_stand.priority'); + return $this->selectAirlineSpecificStands( + $aircraft, + $this->queryFilter($aircraft), + self::ORDER_BYS + ); + } + + public function getRankedStandAllocation(NetworkAircraft $aircraft): Collection + { + // We can only allocate a stand if we know the airline + if ($aircraft->airline_id === null || $aircraft->aircraft_id === null) { + return collect(); + } + + return $this->selectRankedAirlineSpecificStands( + $aircraft, + $this->queryFilter($aircraft), + self::ORDER_BYS + ); + } + + private function queryFilter(NetworkAircraft $aircraft): Closure + { + return fn (Builder $query) => + $query->whereIn('airline_stand.callsign_slug', $this->getCallsignSlugs($aircraft)); } } diff --git a/app/Allocator/Stand/AirlineCallsignSlugTerminalArrivalStandAllocator.php b/app/Allocator/Stand/AirlineCallsignSlugTerminalArrivalStandAllocator.php index 43d2cc8aa..d7bc87fe7 100644 --- a/app/Allocator/Stand/AirlineCallsignSlugTerminalArrivalStandAllocator.php +++ b/app/Allocator/Stand/AirlineCallsignSlugTerminalArrivalStandAllocator.php @@ -4,11 +4,19 @@ use App\Models\Vatsim\NetworkAircraft; use App\Services\AirlineService; +use Closure; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; -class AirlineCallsignSlugTerminalArrivalStandAllocator extends AbstractArrivalStandAllocator +class AirlineCallsignSlugTerminalArrivalStandAllocator implements ArrivalStandAllocator, RankableArrivalStandAllocator { use UsesCallsignSlugs; + use SelectsStandsFromAirlineSpecificTerminals; + + private const ORDER_BYS = [ + 'airline_terminal.callsign_slug IS NOT NULL', + 'LENGTH(airline_terminal.callsign_slug) DESC', + ]; private AirlineService $airlineService; @@ -17,19 +25,49 @@ public function __construct(AirlineService $airlineService) $this->airlineService = $airlineService; } - protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircraft): ?Builder + /** + * This allocator: + * + * - Selects stands that are size appropriate and available + * - Filters these to stands at a terminal that is specifically selected for the airline and + * a set of callsign slugs + * - Orders these by the specific callsign slug, descending by length + * - Orders these stands by the airline's priority for the stand + * - Orders these stands by the common conditions, minus the general allocation priority + * (see OrdersStandsByCommonConditions) + * - Selects the first stand that pops up + */ + public function allocate(NetworkAircraft $aircraft): ?int { - $airline = $this->airlineService->getAirlineForAircraft($aircraft); - if ($airline === null) { + // If the aircraft doesnt have an airline, we cant allocate a stand + if ($aircraft->airline_id === null || $aircraft->aircraft_id === null) { return null; } - return $stands->join('terminals', 'terminals.id', '=', 'stands.terminal_id') - ->join('airline_terminal', 'terminals.id', '=', 'airline_terminal.terminal_id') - ->where('airline_terminal.airline_id', $airline->id) - ->whereIn('airline_terminal.callsign_slug', $this->getCallsignSlugs($aircraft)) - ->orderByRaw('airline_terminal.callsign_slug IS NOT NULL') - ->orderByRaw('LENGTH(airline_terminal.callsign_slug) DESC') - ->orderBy('airline_terminal.priority'); + return $this->selectStandsAtAirlineSpecificTerminals( + $aircraft, + $this->queryFilter($aircraft), + self::ORDER_BYS + ); + } + + public function getRankedStandAllocation(NetworkAircraft $aircraft): Collection + { + // We can only allocate a stand if we know the airline + if ($aircraft->airline_id === null || $aircraft->aircraft_id === null) { + return collect(); + } + + return $this->selectRankedStandsAtAirlineSpecificTerminals( + $aircraft, + $this->queryFilter($aircraft), + self::ORDER_BYS + ); + } + + private function queryFilter(NetworkAircraft $aircraft): Closure + { + return fn (Builder $query) + => $query->whereIn('airline_terminal.callsign_slug', $this->getCallsignSlugs($aircraft)); } } diff --git a/app/Allocator/Stand/AirlineCallsignTerminalArrivalStandAllocator.php b/app/Allocator/Stand/AirlineCallsignTerminalArrivalStandAllocator.php index b2e29e476..c4653c311 100644 --- a/app/Allocator/Stand/AirlineCallsignTerminalArrivalStandAllocator.php +++ b/app/Allocator/Stand/AirlineCallsignTerminalArrivalStandAllocator.php @@ -4,11 +4,14 @@ use App\Models\Vatsim\NetworkAircraft; use App\Services\AirlineService; +use Closure; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; -class AirlineCallsignTerminalArrivalStandAllocator extends AbstractArrivalStandAllocator +class AirlineCallsignTerminalArrivalStandAllocator implements ArrivalStandAllocator, RankableArrivalStandAllocator { use UsesCallsignSlugs; + use SelectsStandsFromAirlineSpecificTerminals; private AirlineService $airlineService; @@ -17,17 +20,46 @@ public function __construct(AirlineService $airlineService) $this->airlineService = $airlineService; } - protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircraft): ?Builder + /** + * This allocator: + * + * - Selects stands that are size appropriate and available + * - Filters these to stands at a terminal that is specifically selected for the airline and + * a specific callsign + * - Orders these stands by the airline's priority for the stand + * - Orders these stands by the common conditions, minus the general allocation priority + * (see OrdersStandsByCommonConditions)` + * - Selects the first stand that pops up + */ + public function allocate(NetworkAircraft $aircraft): ?int { - $airline = $this->airlineService->getAirlineForAircraft($aircraft); - if ($airline === null) { + // If the aircraft doesnt have an airline, we cant allocate a stand + if ($aircraft->airline_id === null || $aircraft->aircraft_id === null) { return null; } - return $stands->join('terminals', 'terminals.id', '=', 'stands.terminal_id') - ->join('airline_terminal', 'terminals.id', '=', 'airline_terminal.terminal_id') - ->where('airline_terminal.airline_id', $airline->id) - ->where('airline_terminal.full_callsign', $this->getFullCallsignSlug($aircraft)) - ->orderBy('airline_terminal.priority'); + return $this->selectStandsAtAirlineSpecificTerminals( + $aircraft, + $this->queryFilter($aircraft) + ); + } + + public function getRankedStandAllocation(NetworkAircraft $aircraft): Collection + { + // If the aircraft doesnt have an airline, we cant allocate a stand + if ($aircraft->airline_id === null || $aircraft->aircraft_id === null) { + return collect(); + } + + return $this->selectRankedStandsAtAirlineSpecificTerminals( + $aircraft, + $this->queryFilter($aircraft) + ); + } + + private function queryFilter(NetworkAircraft $aircraft): Closure + { + return fn (Builder $query) + => $query->where('airline_terminal.full_callsign', $this->getFullCallsignSlug($aircraft)); } } diff --git a/app/Allocator/Stand/AirlineDestinationArrivalStandAllocator.php b/app/Allocator/Stand/AirlineDestinationArrivalStandAllocator.php index f10882874..097c4575b 100644 --- a/app/Allocator/Stand/AirlineDestinationArrivalStandAllocator.php +++ b/app/Allocator/Stand/AirlineDestinationArrivalStandAllocator.php @@ -4,31 +4,64 @@ use App\Allocator\UsesDestinationStrings; use App\Models\Vatsim\NetworkAircraft; -use App\Services\AirlineService; +use Closure; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; -class AirlineDestinationArrivalStandAllocator extends AbstractArrivalStandAllocator +class AirlineDestinationArrivalStandAllocator implements ArrivalStandAllocator, RankableArrivalStandAllocator { use UsesDestinationStrings; + use SelectsFromAirlineSpecificStands; - private AirlineService $airlineService; + private const ORDER_BYS = [ + 'airline_stand.destination IS NOT NULL', + 'LENGTH(airline_stand.destination) DESC', + ]; - public function __construct(AirlineService $airlineService) + /** + * This allocator: + * + * - Selects stands that are size appropriate and available + * - Filters these to stands that are specifically selected for the airline and a specific set of destinations + * - Orders these by the most specific destination first + * - Orders these stands by the airline's priority for the stand + * - Orders these stands by the common conditions, minus the general allocation priority + * (see OrdersStandsByCommonConditions) + * - Selects the first stand that pops up + */ + public function allocate(NetworkAircraft $aircraft): ?int { - $this->airlineService = $airlineService; + // We cant allocate a stand if we don't know the airline + if ($aircraft->airline_id === null || $aircraft->aircraft_id === null) { + return null; + } + + return $this->selectAirlineSpecificStands( + $aircraft, + $this->queryFilter($aircraft), + self::ORDER_BYS + ); } - protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircraft): ?Builder + public function getRankedStandAllocation(NetworkAircraft $aircraft): Collection { - $airline = $this->airlineService->getAirlineForAircraft($aircraft); - if ($airline === null) { - return null; + // We can only allocate a stand if we know the airline + if ($aircraft->airline_id === null || $aircraft->aircraft_id === null) { + return collect(); } - return $stands->with('airlines') - ->airlineDestination($airline, $this->getDestinationStrings($aircraft)) - ->orderByRaw('airline_stand.destination IS NOT NULL') - ->orderByRaw('LENGTH(airline_stand.destination) DESC') - ->orderBy('airline_stand.priority'); + return $this->selectRankedAirlineSpecificStands( + $aircraft, + $this->queryFilter($aircraft), + self::ORDER_BYS + ); + } + + private function queryFilter(NetworkAircraft $aircraft): Closure + { + return fn (Builder $query) => $query->whereIn( + 'airline_stand.destination', + $this->getDestinationStrings($aircraft) + ); } } diff --git a/app/Allocator/Stand/AirlineDestinationTerminalArrivalStandAllocator.php b/app/Allocator/Stand/AirlineDestinationTerminalArrivalStandAllocator.php index 26a31b144..47c0f7e12 100644 --- a/app/Allocator/Stand/AirlineDestinationTerminalArrivalStandAllocator.php +++ b/app/Allocator/Stand/AirlineDestinationTerminalArrivalStandAllocator.php @@ -4,33 +4,65 @@ use App\Allocator\UsesDestinationStrings; use App\Models\Vatsim\NetworkAircraft; -use App\Services\AirlineService; +use Closure; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; -class AirlineDestinationTerminalArrivalStandAllocator extends AbstractArrivalStandAllocator +class AirlineDestinationTerminalArrivalStandAllocator implements ArrivalStandAllocator, RankableArrivalStandAllocator { use UsesDestinationStrings; + use SelectsStandsFromAirlineSpecificTerminals; - private AirlineService $airlineService; + private const ORDER_BYS = [ + 'airline_terminal.destination IS NOT NULL', + 'LENGTH(airline_terminal.destination) DESC', + ]; - public function __construct(AirlineService $airlineService) + /** + * This allocator: + * + * - Selects stands that are size appropriate and available + * - Filters these to stands that are at terminals specifically selected for the airline and a + * specific set of destinations + * - Orders these by the most specific destination first + * - Orders these stands by the airline's priority for the stand + * - Orders these stands by the common conditions, minus the general allocation priority + * (see OrdersStandsByCommonConditions) + * - Selects the first stand that pops up + */ + public function allocate(NetworkAircraft $aircraft): ?int { - $this->airlineService = $airlineService; + // If the aircraft doesnt have an airline, we cant allocate a stand + if ($aircraft->airline_id === null || $aircraft->aircraft_id === null) { + return null; + } + + return $this->selectStandsAtAirlineSpecificTerminals( + $aircraft, + $this->queryFilter($aircraft), + self::ORDER_BYS + ); } - protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircraft): ?Builder + public function getRankedStandAllocation(NetworkAircraft $aircraft): Collection { - $airline = $this->airlineService->getAirlineForAircraft($aircraft); - if ($airline === null) { - return null; + // If the aircraft doesnt have an airline, we cant allocate a stand + if ($aircraft->airline_id === null || $aircraft->aircraft_id === null) { + return collect(); } - return $stands->join('terminals', 'terminals.id', '=', 'stands.terminal_id') - ->join('airline_terminal', 'terminals.id', '=', 'airline_terminal.terminal_id') - ->where('airline_terminal.airline_id', $airline->id) - ->whereIn('airline_terminal.destination', $this->getDestinationStrings($aircraft)) - ->orderByRaw('airline_terminal.destination IS NOT NULL') - ->orderByRaw('LENGTH(airline_terminal.destination) DESC') - ->orderBy('airline_terminal.priority'); + return $this->selectRankedStandsAtAirlineSpecificTerminals( + $aircraft, + $this->queryFilter($aircraft), + self::ORDER_BYS + ); + } + + public function queryFilter(NetworkAircraft $aircraft): Closure + { + return fn (Builder $query) => $query->whereIn( + 'airline_terminal.destination', + $this->getDestinationStrings($aircraft) + ); } } diff --git a/app/Allocator/Stand/AirlineGeneralArrivalStandAllocator.php b/app/Allocator/Stand/AirlineGeneralArrivalStandAllocator.php new file mode 100644 index 000000000..9f578b919 --- /dev/null +++ b/app/Allocator/Stand/AirlineGeneralArrivalStandAllocator.php @@ -0,0 +1,57 @@ +airline_id === null || $aircraft->aircraft_id === null) { + return null; + } + + return $this->selectAirlineSpecificStands( + $aircraft, + $this->queryFilter() + ); + } + + public function getRankedStandAllocation(NetworkAircraft $aircraft): Collection + { + // We cant allocate a stand if we don't know the airline or aircraft type + if ($aircraft->airline_id === null || $aircraft->aircraft_id === null) { + return collect(); + } + + return $this->selectRankedAirlineSpecificStands( + $aircraft, + $this->queryFilter() + ); + } + + private function queryFilter(): Closure + { + return fn (Builder $query) => $query->whereNull('airline_stand.destination') + ->whereNull('airline_stand.callsign_slug') + ->whereNull('airline_stand.full_callsign') + ->whereNull('airline_stand.aircraft_id'); + } +} diff --git a/app/Allocator/Stand/AirlineGeneralTerminalArrivalStandAllocator.php b/app/Allocator/Stand/AirlineGeneralTerminalArrivalStandAllocator.php new file mode 100644 index 000000000..d8f53356e --- /dev/null +++ b/app/Allocator/Stand/AirlineGeneralTerminalArrivalStandAllocator.php @@ -0,0 +1,58 @@ +airline_id === null || $aircraft->aircraft_id === null) { + return null; + } + + return $this->selectStandsAtAirlineSpecificTerminals( + $aircraft, + $this->queryFilter() + ); + } + + public function getRankedStandAllocation(NetworkAircraft $aircraft): Collection + { + // If the aircraft doesnt have an airline, we cant allocate a stand + if ($aircraft->airline_id === null || $aircraft->aircraft_id === null) { + return collect(); + } + + return $this->selectRankedStandsAtAirlineSpecificTerminals( + $aircraft, + $this->queryFilter() + ); + } + + private function queryFilter(): Closure + { + return fn (Builder $query) => $query->whereNull('airline_terminal.destination') + ->whereNull('airline_terminal.callsign_slug') + ->whereNull('airline_terminal.full_callsign') + ->whereNull('airline_terminal.aircraft_id'); + } +} diff --git a/app/Allocator/Stand/AirlineTerminalArrivalStandAllocator.php b/app/Allocator/Stand/AirlineTerminalArrivalStandAllocator.php deleted file mode 100644 index 0ac5d3e85..000000000 --- a/app/Allocator/Stand/AirlineTerminalArrivalStandAllocator.php +++ /dev/null @@ -1,33 +0,0 @@ -airlineService = $airlineService; - } - - protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircraft): ?Builder - { - if (($airline = $this->airlineService->getAirlineForAircraft($aircraft)) === null) { - return null; - } - - return $stands->join('terminals', 'terminals.id', '=', 'stands.terminal_id') - ->join('airline_terminal', 'terminals.id', '=', 'airline_terminal.terminal_id') - ->where('airline_terminal.airline_id', $airline->id) - ->whereNull('airline_terminal.destination') - ->whereNull('airline_terminal.callsign_slug') - ->whereNull('airline_terminal.full_callsign') - ->whereNull('airline_terminal.aircraft_id') - ->orderBy('airline_terminal.priority'); - } -} diff --git a/app/Allocator/Stand/AppliesOrdering.php b/app/Allocator/Stand/AppliesOrdering.php new file mode 100644 index 000000000..deb830251 --- /dev/null +++ b/app/Allocator/Stand/AppliesOrdering.php @@ -0,0 +1,17 @@ + $query->orderByRaw($orderBy), + $stands + ); + } +} diff --git a/app/Allocator/Stand/ArrivalStandAllocator.php b/app/Allocator/Stand/ArrivalStandAllocator.php new file mode 100644 index 000000000..afd30a19d --- /dev/null +++ b/app/Allocator/Stand/ArrivalStandAllocator.php @@ -0,0 +1,15 @@ +whereHas('stand', function (Builder $standQuery) { @@ -26,7 +26,7 @@ protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircr ->first(); return $reservation - ? Stand::where('stands.id', $reservation->stand_id)->select('stands.*') + ? Stand::where('stands.id', $reservation->stand_id)->first()->id : null; } } diff --git a/app/Allocator/Stand/CargoAirlineFallbackStandAllocator.php b/app/Allocator/Stand/CargoAirlineFallbackStandAllocator.php index a1af7ce3a..4bdc158b3 100644 --- a/app/Allocator/Stand/CargoAirlineFallbackStandAllocator.php +++ b/app/Allocator/Stand/CargoAirlineFallbackStandAllocator.php @@ -4,15 +4,18 @@ use App\Models\Vatsim\NetworkAircraft; use App\Services\AirlineService; +use Closure; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; /** * A fallback allocator for cargo airlines. Will allocate any * cargo stand to any airline that is type cargo. */ -class CargoAirlineFallbackStandAllocator extends AbstractArrivalStandAllocator +class CargoAirlineFallbackStandAllocator implements ArrivalStandAllocator, RankableArrivalStandAllocator { use ChecksForCargoAirlines; + use SelectsStandsUsingStandardConditions; private AirlineService $airlineService; @@ -21,12 +24,39 @@ public function __construct(AirlineService $airlineService) $this->airlineService = $airlineService; } - protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircraft): ?Builder + /** + * This allocator: + * + * - Only allocates cargo stands to cargo airlines + * - Orders by common conditions (see OrdersStandsByCommonConditions) + * - Selects the first available stand (see SelectsFirstApplicableStand) + */ + public function allocate(NetworkAircraft $aircraft): ?int { - if (!$this->isCargoAirline($aircraft)) { + if ($aircraft->aircraft_id === null || !$this->isCargoAirline($aircraft)) { return null; } - return $stands->cargo(); + return $this->selectStandsUsingStandardConditions( + $aircraft, + $this->queryFilter() + ); + } + + public function getRankedStandAllocation(NetworkAircraft $aircraft): Collection + { + if ($aircraft->aircraft_id === null || !$this->isCargoAirline($aircraft)) { + return collect(); + } + + return $this->selectRankedStandsUsingStandardConditions( + $aircraft, + $this->queryFilter() + ); + } + + private function queryFilter(): Closure + { + return fn (Builder $query) => $query->cargo(); } } diff --git a/app/Allocator/Stand/CargoFlightArrivalStandAllocator.php b/app/Allocator/Stand/CargoFlightArrivalStandAllocator.php index 0449ea232..bb28da324 100644 --- a/app/Allocator/Stand/CargoFlightArrivalStandAllocator.php +++ b/app/Allocator/Stand/CargoFlightArrivalStandAllocator.php @@ -4,14 +4,17 @@ use App\Models\Vatsim\NetworkAircraft; use App\Services\AirlineService; +use Closure; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; /** * Secondary cargo stand allocator, with no airline preferences. Only concerned with FP remarks explicitly * stating that the flight is cargo - which means a cargo stand should be given. */ -class CargoFlightArrivalStandAllocator extends AbstractArrivalStandAllocator +class CargoFlightArrivalStandAllocator implements ArrivalStandAllocator { + use SelectsStandsUsingStandardConditions; use ChecksForCargoAirlines; private AirlineService $airlineService; @@ -21,12 +24,39 @@ public function __construct(AirlineService $airlineService) $this->airlineService = $airlineService; } - protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircraft): ?Builder + /** + * This allocator: + * + * - Only allocates cargo stands to cargo airlines + * - Orders by common conditions (see OrdersStandsByCommonConditions) + * - Selects the first available stand (see SelectsFirstApplicableStand) + */ + public function allocate(NetworkAircraft $aircraft): ?int { if (!$this->isCargoFlight($aircraft)) { return null; } - return $stands->cargo(); + return $this->selectStandsUsingStandardConditions( + $aircraft, + $this->queryFilter() + ); + } + + public function getRankedStandAllocation(NetworkAircraft $aircraft): Collection + { + if (!$this->isCargoFlight($aircraft)) { + return collect(); + } + + return $this->selectRankedStandsUsingStandardConditions( + $aircraft, + $this->queryFilter() + ); + } + + private function queryFilter(): Closure + { + return fn (Builder $query) => $query->cargo(); } } diff --git a/app/Allocator/Stand/CargoFlightPreferredArrivalStandAllocator.php b/app/Allocator/Stand/CargoFlightPreferredArrivalStandAllocator.php index c3fc51f2a..8b7272bf9 100644 --- a/app/Allocator/Stand/CargoFlightPreferredArrivalStandAllocator.php +++ b/app/Allocator/Stand/CargoFlightPreferredArrivalStandAllocator.php @@ -4,7 +4,9 @@ use App\Models\Vatsim\NetworkAircraft; use App\Services\AirlineService; +use Closure; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; /** * The primary arrival stand allocator for cargo. Looks for either a cargo airline @@ -13,8 +15,9 @@ * * This allows airlines that also handle passengers to have stands for their cargo operation. */ -class CargoFlightPreferredArrivalStandAllocator extends AbstractArrivalStandAllocator +class CargoFlightPreferredArrivalStandAllocator implements ArrivalStandAllocator { + use SelectsFromAirlineSpecificStands; use ChecksForCargoAirlines; private AirlineService $airlineService; @@ -24,18 +27,34 @@ public function __construct(AirlineService $airlineService) $this->airlineService = $airlineService; } - protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircraft): ?Builder + public function allocate(NetworkAircraft $aircraft): ?int { + // If the aircraft doesnt have an airline, we cant allocate a stand if (!$this->isCargoAirline($aircraft) && !$this->isCargoFlight($aircraft)) { return null; } - if (!($airline = $this->airlineService->getAirlineForAircraft($aircraft))) { - return null; + return $this->selectAirlineSpecificStands( + $aircraft, + $this->queryFilter() + ); + } + + public function getRankedStandAllocation(NetworkAircraft $aircraft): Collection + { + // If the aircraft doesnt have an airline, we cant allocate a stand + if (!$this->isCargoAirline($aircraft) && !$this->isCargoFlight($aircraft)) { + return collect(); } + + return $this->selectRankedAirlineSpecificStands( + $aircraft, + $this->queryFilter() + ); + } - return $stands->cargo() - ->airline($airline) - ->orderBy('airline_stand.priority'); + private function queryFilter(): Closure + { + return fn (Builder $query) => $query->cargo(); } } diff --git a/app/Allocator/Stand/CidReservedArrivalStandAllocator.php b/app/Allocator/Stand/CidReservedArrivalStandAllocator.php index 54b120385..f9d2162a4 100644 --- a/app/Allocator/Stand/CidReservedArrivalStandAllocator.php +++ b/app/Allocator/Stand/CidReservedArrivalStandAllocator.php @@ -10,9 +10,9 @@ /** * Matches the network aircraft with a stand reservation based on the pilots CID. */ -class CidReservedArrivalStandAllocator extends AbstractArrivalStandAllocator +class CidReservedArrivalStandAllocator implements ArrivalStandAllocator { - protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircraft): ?Builder + public function allocate(NetworkAircraft $aircraft): ?int { $reservation = StandReservation::with('stand') ->whereHas('stand', function (Builder $standQuery) { @@ -24,7 +24,7 @@ protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircr ->first(); return $reservation - ? Stand::where('stands.id', $reservation->stand_id)->select('stands.*') + ? Stand::where('stands.id', $reservation->stand_id)->first()->id : null; } } diff --git a/app/Allocator/Stand/ConsidersStandRequests.php b/app/Allocator/Stand/ConsidersStandRequests.php new file mode 100644 index 000000000..32be3cb62 --- /dev/null +++ b/app/Allocator/Stand/ConsidersStandRequests.php @@ -0,0 +1,32 @@ +leftJoin('stand_requests as other_stand_requests', function (JoinClause $join) use ($aircraft) { + // Prefer stands that haven't been requested by someone else + $join->on('stands.id', '=', 'other_stand_requests.stand_id') + ->on('other_stand_requests.user_id', '<>', $join->raw($aircraft->cid)) + ->on( + 'other_stand_requests.requested_time', + '>', + $join->raw( + sprintf( + '\'%s\'', + Carbon::now()->subMinutes( + config('vatsim-connect.stand_request_expiry_minutes') + )->toDateTimeString() + ) + ) + ); + }); + } +} diff --git a/app/Allocator/Stand/DomesticInternationalStandAllocator.php b/app/Allocator/Stand/DomesticInternationalStandAllocator.php index ca31ce8a1..6f4812b4d 100644 --- a/app/Allocator/Stand/DomesticInternationalStandAllocator.php +++ b/app/Allocator/Stand/DomesticInternationalStandAllocator.php @@ -3,18 +3,42 @@ namespace App\Allocator\Stand; use App\Models\Vatsim\NetworkAircraft; +use Closure; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; use Illuminate\Support\Str; -class DomesticInternationalStandAllocator extends AbstractArrivalStandAllocator +class DomesticInternationalStandAllocator implements ArrivalStandAllocator, RankableArrivalStandAllocator { - protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircraft): ?Builder + use SelectsStandsUsingStandardConditions; + + public function allocate(NetworkAircraft $aircraft): ?int { - if (!$aircraft->planned_depairport) { + if (!$aircraft->planned_depairport || !$aircraft->aircraft_id) { return null; } - return $this->getDomesticInternationalScope($aircraft, $stands); + return $this->selectStandsUsingStandardConditions( + $aircraft, + $this->queryFilter($aircraft) + ); + } + + public function getRankedStandAllocation(NetworkAircraft $aircraft): Collection + { + if (!$aircraft->planned_depairport || !$aircraft->aircraft_id) { + return collect(); + } + + return $this->selectRankedStandsUsingStandardConditions( + $aircraft, + $this->queryFilter($aircraft) + ); + } + + private function queryFilter(NetworkAircraft $aircraft): Closure + { + return fn (Builder $query) => $this->getDomesticInternationalScope($aircraft, $query); } protected function getDomesticInternationalScope(NetworkAircraft $aircraft, Builder $builder): Builder diff --git a/app/Allocator/Stand/FallbackArrivalStandAllocator.php b/app/Allocator/Stand/FallbackArrivalStandAllocator.php index fa4614d67..ade10daa4 100644 --- a/app/Allocator/Stand/FallbackArrivalStandAllocator.php +++ b/app/Allocator/Stand/FallbackArrivalStandAllocator.php @@ -3,12 +3,50 @@ namespace App\Allocator\Stand; use App\Models\Vatsim\NetworkAircraft; +use Closure; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; -class FallbackArrivalStandAllocator extends AbstractArrivalStandAllocator +class FallbackArrivalStandAllocator implements ArrivalStandAllocator, RankableArrivalStandAllocator { - protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircraft): ?Builder + use SelectsStandsUsingStandardConditions; + + /** + * This allocator: + * + * - Only allocates stands that are not cargo + * - Orders by common conditions (see OrdersStandsByCommonConditions) + * - Selects the first available stand (see SelectsFirstApplicableStand) + * + * @param NetworkAircraft $aircraft + * @return integer|null + */ + public function allocate(NetworkAircraft $aircraft): ?int + { + if ($aircraft->aircraft_id === null) { + return null; + } + + return $this->selectStandsUsingStandardConditions( + $aircraft, + $this->filterQuery(), + ); + } + + public function getRankedStandAllocation(NetworkAircraft $aircraft): Collection + { + if ($aircraft->aircraft_id === null) { + return collect(); + } + + return $this->selectRankedStandsUsingStandardConditions( + $aircraft, + $this->filterQuery(), + ); + } + + private function filterQuery(): Closure { - return $stands->notCargo(); + return fn (Builder $query) => $query->notCargo(); } } diff --git a/app/Allocator/Stand/OrdersStandsByCommonConditions.php b/app/Allocator/Stand/OrdersStandsByCommonConditions.php new file mode 100644 index 000000000..5c00bf7da --- /dev/null +++ b/app/Allocator/Stand/OrdersStandsByCommonConditions.php @@ -0,0 +1,42 @@ +planned_depairport) { + return null; + } + + return $this->selectStandsUsingStandardConditions( + $aircraft, + $this->filterQuery($aircraft), + self::ORDER_BYS, + ); + } + + public function getRankedStandAllocation(NetworkAircraft $aircraft): Collection + { + if (!$aircraft->planned_depairport) { + return collect(); + } + + return $this->selectRankedStandsUsingStandardConditions( + $aircraft, + $this->filterQuery($aircraft), + self::ORDER_BYS, + ); + } + + private function filterQuery(NetworkAircraft $aircraft): Closure { - return $stands - ->whereIn('origin_slug', $this->getDestinationStrings($aircraft)) - ->orderByRaw('origin_slug IS NOT NULL') - ->orderByRaw('LENGTH(origin_slug) DESC'); + return fn (Builder $query) + => $query->notCargo()->whereIn('origin_slug', $this->getDestinationStrings($aircraft)); } } diff --git a/app/Allocator/Stand/RankableArrivalStandAllocator.php b/app/Allocator/Stand/RankableArrivalStandAllocator.php new file mode 100644 index 000000000..86104c68a --- /dev/null +++ b/app/Allocator/Stand/RankableArrivalStandAllocator.php @@ -0,0 +1,15 @@ +first()?->id; + } +} diff --git a/app/Allocator/Stand/SelectsFromAirlineSpecificStands.php b/app/Allocator/Stand/SelectsFromAirlineSpecificStands.php new file mode 100644 index 000000000..a8308fc17 --- /dev/null +++ b/app/Allocator/Stand/SelectsFromAirlineSpecificStands.php @@ -0,0 +1,57 @@ +selectStandsUsingStandardConditions( + $aircraft, + fn (Builder $query) => $specificFilters($query->airline($aircraft->airline_id)), + array_merge( + $specificOrders, + ['airline_stand.priority ASC'], + ), + false + ); + } + + private function selectRankedAirlineSpecificStands( + NetworkAircraft $aircraft, + Closure $specificFilters, + array $specificOrders = [] + ): Collection { + return $this->selectRankedStandsUsingStandardConditions( + $aircraft, + fn (Builder $query) => $specificFilters($query->airline($aircraft->airline_id)), + array_merge( + $specificOrders, + ['airline_stand.priority ASC'], + ), + false + ); + } +} diff --git a/app/Allocator/Stand/SelectsFromSizeAppropriateAvailableStands.php b/app/Allocator/Stand/SelectsFromSizeAppropriateAvailableStands.php new file mode 100644 index 000000000..ce1372f49 --- /dev/null +++ b/app/Allocator/Stand/SelectsFromSizeAppropriateAvailableStands.php @@ -0,0 +1,34 @@ +where('code', $aircraft->planned_destairport); + }) + ->sizeAppropriate($aircraft->aircraft) + ->available() + ->select('stands.*'); + } + + private function sizeAppropriateAvailableStandsAtAirfieldForRanking(NetworkAircraft $aircraft): Builder + { + return Stand::whereHas('airfield', function (Builder $query) use ($aircraft) { + $query->where('code', $aircraft->planned_destairport); + }) + ->sizeAppropriate($aircraft->aircraft) + ->notClosed() + ->select('stands.*'); + } +} diff --git a/app/Allocator/Stand/SelectsStandsFromAirlineSpecificTerminals.php b/app/Allocator/Stand/SelectsStandsFromAirlineSpecificTerminals.php new file mode 100644 index 000000000..e2f86da9f --- /dev/null +++ b/app/Allocator/Stand/SelectsStandsFromAirlineSpecificTerminals.php @@ -0,0 +1,65 @@ +selectStandsUsingStandardConditions( + $aircraft, + fn (Builder $query) => $specificFilters($query->join('terminals', 'terminals.id', '=', 'stands.terminal_id') + ->join('airline_terminal', 'terminals.id', '=', 'airline_terminal.terminal_id') + ->where('airline_terminal.airline_id', $aircraft->airline_id)), + array_merge( + $specificOrders, + ['airline_terminal.priority ASC'], + ), + false + ); + } + + private function selectRankedStandsAtAirlineSpecificTerminals( + NetworkAircraft $aircraft, + Closure $specificFilters, + array $specificOrders = [] + ): Collection { + return $this->selectRankedStandsUsingStandardConditions( + $aircraft, + fn (Builder $query) => $specificFilters($query->join('terminals', 'terminals.id', '=', 'stands.terminal_id') + ->join('airline_terminal', 'terminals.id', '=', 'airline_terminal.terminal_id') + ->where('airline_terminal.airline_id', $aircraft->airline_id)), + array_merge( + $specificOrders, + ['airline_terminal.priority ASC'], + ), + false + ); + } +} diff --git a/app/Allocator/Stand/SelectsStandsUsingStandardConditions.php b/app/Allocator/Stand/SelectsStandsUsingStandardConditions.php new file mode 100644 index 000000000..17de9e173 --- /dev/null +++ b/app/Allocator/Stand/SelectsStandsUsingStandardConditions.php @@ -0,0 +1,97 @@ +selectFirstStand( + $this->standardConditionsStandQuery( + $aircraft, + $specificFilters, + $specificOrders, + $includeAssignmentPriority, + false + ) + ); + } + + private function selectRankedStandsUsingStandardConditions( + NetworkAircraft $aircraft, + Closure $specificFilters, + array $specificOrders = [], + bool $includeAssignmentPriority = true + ): Collection { + $orderByForRankQuery = implode( + ',', + $this->orderByForStandsQuery($specificOrders, $includeAssignmentPriority, true) + ); + + return $this->standardConditionsStandQuery( + $aircraft, + $specificFilters, + $specificOrders, + $includeAssignmentPriority, + true + ) + ->selectRaw(sprintf('DENSE_RANK() OVER (ORDER BY %s) AS `rank`', $orderByForRankQuery)) + ->get(); + } + + private function standardConditionsStandQuery( + NetworkAircraft $aircraft, + Closure $specificFilters, + array $specificOrders = [], + bool $includeAssignmentPriority = true, + bool $isRanking = false + ): Builder { + return $this->applyOrderingToStandsQuery( + $this->joinOtherStandRequests( + $specificFilters( + $isRanking + ? $this->sizeAppropriateAvailableStandsAtAirfieldForRanking($aircraft) + : $this->sizeAppropriateAvailableStandsAtAirfield($aircraft) + ), + $aircraft + ), + $this->orderByForStandsQuery($specificOrders, $includeAssignmentPriority, $isRanking) + ); + } + + private function orderByForStandsQuery(array $customOrders, bool $includeAssignmentPriority, bool $isRanking): array + { + /** + * If we are doing ranking, we don't need to consider stand requests in the priority, nor do we need + * a random order. + */ + if ($includeAssignmentPriority) { + $commonConditions = $isRanking + ? $this->commonOrderByConditionsForRanking + : $this->commonOrderByConditions; + } else { + $commonConditions = $isRanking + ? $this->commonOrderByConditionsWithoutAssignmentPriorityForRanking + : $this->commonOrderByConditionsWithoutAssignmentPriority; + } + + return array_merge( + $customOrders, + $commonConditions + ); + } +} diff --git a/app/Allocator/Stand/UserRequestedArrivalStandAllocator.php b/app/Allocator/Stand/UserRequestedArrivalStandAllocator.php index 42d04061b..62f023b29 100644 --- a/app/Allocator/Stand/UserRequestedArrivalStandAllocator.php +++ b/app/Allocator/Stand/UserRequestedArrivalStandAllocator.php @@ -2,19 +2,24 @@ namespace App\Allocator\Stand; +use App\Models\Stand\Stand; use App\Models\Stand\StandRequest; use App\Models\Vatsim\NetworkAircraft; -use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; -class UserRequestedArrivalStandAllocator extends AbstractArrivalStandAllocator +class UserRequestedArrivalStandAllocator implements ArrivalStandAllocator { - protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircraft): ?Builder + use SelectsFirstApplicableStand; + + public function allocate(NetworkAircraft $aircraft): ?int { $requestedStands = StandRequest::where('user_id', $aircraft->cid) ->whereHas('stand.airfield', function (Builder $airfield) use ($aircraft) { $airfield->where('code', $aircraft->planned_destairport); }) + ->whereHas('stand', function (Builder $standQuery) { + $standQuery->unoccupied()->unassigned(); + }) ->current() ->get(); @@ -22,11 +27,8 @@ protected function getOrderedStandsQuery(Builder $stands, NetworkAircraft $aircr return null; } - return $stands->whereIn('stands.id', $requestedStands->pluck('stand_id')); - } - - protected function prefersNonRequestedStands(): bool - { - return false; + return $this->selectFirstStand( + Stand::whereIn('id', $requestedStands->pluck('stand_id')) + ); } } diff --git a/app/Events/Aircraft/AircraftDataUpdatedEvent.php b/app/Events/Aircraft/AircraftDataUpdatedEvent.php new file mode 100644 index 000000000..ebf536dcb --- /dev/null +++ b/app/Events/Aircraft/AircraftDataUpdatedEvent.php @@ -0,0 +1,7 @@ +roles() + ->whereIn('key', [ + RoleKeys::OPERATIONS_TEAM, + RoleKeys::WEB_TEAM, + RoleKeys::DIVISION_STAFF_GROUP, + RoleKeys::OPERATIONS_CONTRIBUTOR, + ])->exists(); + } + + public function standPredictorFormSubmitted(array $data) + { + $this->currentPrediction = app()->make(ArrivalAllocationService::class) + ->getAllocationRankingForAircraft(new NetworkAircraft($data)) + ->map( + fn (Collection $stands) => + $stands->map( + fn (Collection $standsForRank) => + $standsForRank->sortBy('identifier', SORT_NATURAL) + ->map(fn (Stand $stand) => $stand->identifier)->values() + ) + )->toArray(); + } +} diff --git a/app/Filament/Resources/AircraftResource.php b/app/Filament/Resources/AircraftResource.php index 1a8f401f4..e2afada44 100644 --- a/app/Filament/Resources/AircraftResource.php +++ b/app/Filament/Resources/AircraftResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources; +use App\Events\Aircraft\AircraftDataUpdatedEvent; use App\Filament\Resources\AircraftResource\Pages; use App\Filament\Resources\AircraftResource\RelationManagers\WakeCategoriesRelationManager; use App\Models\Aircraft\Aircraft; @@ -104,7 +105,10 @@ public static function table(Table $table): Table ->actions([ Tables\Actions\ViewAction::make(), Tables\Actions\EditAction::make(), - Tables\Actions\DeleteAction::make(), + Tables\Actions\DeleteAction::make() + ->after(function () { + event(new AircraftDataUpdatedEvent); + }), ]) ->defaultSort('code'); } diff --git a/app/Filament/Resources/AircraftResource/Pages/CreateAircraft.php b/app/Filament/Resources/AircraftResource/Pages/CreateAircraft.php index 3734465d7..7a31748ea 100644 --- a/app/Filament/Resources/AircraftResource/Pages/CreateAircraft.php +++ b/app/Filament/Resources/AircraftResource/Pages/CreateAircraft.php @@ -2,10 +2,16 @@ namespace App\Filament\Resources\AircraftResource\Pages; +use App\Events\Aircraft\AircraftDataUpdatedEvent; use App\Filament\Resources\AircraftResource; use Filament\Resources\Pages\CreateRecord; class CreateAircraft extends CreateRecord { protected static string $resource = AircraftResource::class; + + protected function afterCreate(): void + { + event(new AircraftDataUpdatedEvent); + } } diff --git a/app/Filament/Resources/AircraftResource/Pages/EditAircraft.php b/app/Filament/Resources/AircraftResource/Pages/EditAircraft.php index 7091a87ba..623cbf307 100644 --- a/app/Filament/Resources/AircraftResource/Pages/EditAircraft.php +++ b/app/Filament/Resources/AircraftResource/Pages/EditAircraft.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\AircraftResource\Pages; +use App\Events\Aircraft\AircraftDataUpdatedEvent; use App\Filament\Resources\AircraftResource; use Filament\Pages\Actions; use Filament\Resources\Pages\EditRecord; @@ -10,10 +11,13 @@ class EditAircraft extends EditRecord { protected static string $resource = AircraftResource::class; - protected function getActions(): array + protected function afterSave(): void { - return [ - Actions\DeleteAction::make(), - ]; + event(new AircraftDataUpdatedEvent); + } + + protected function afterDelete(): void + { + event(new AircraftDataUpdatedEvent); } } diff --git a/app/Filament/Resources/AirlineResource.php b/app/Filament/Resources/AirlineResource.php index 03cbe4fac..fa977972c 100644 --- a/app/Filament/Resources/AirlineResource.php +++ b/app/Filament/Resources/AirlineResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources; +use App\Events\Airline\AirlinesUpdatedEvent; use App\Filament\Helpers\SelectOptions; use App\Filament\Resources\AirlineResource\Pages; use App\Filament\Resources\AirlineResource\RelationManagers\StandsRelationManager; @@ -88,7 +89,10 @@ public static function table(Table $table): Table ->actions([ ViewAction::make(), EditAction::make(), - DeleteAction::make(), + DeleteAction::make() + ->after(function () { + event(new AirlinesUpdatedEvent); + }), ]); } diff --git a/app/Filament/Resources/AirlineResource/Pages/CreateAirline.php b/app/Filament/Resources/AirlineResource/Pages/CreateAirline.php index a55fa22a1..f0db6535e 100644 --- a/app/Filament/Resources/AirlineResource/Pages/CreateAirline.php +++ b/app/Filament/Resources/AirlineResource/Pages/CreateAirline.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\AirlineResource\Pages; +use App\Events\Airline\AirlinesUpdatedEvent; use App\Filament\Resources\AirlineResource; use App\Models\Airfield\Terminal; use App\Models\Airline\Airline; @@ -75,4 +76,9 @@ private function getCopyablePivotAttributes(string $localModelColumn, Pivot $piv ARRAY_FILTER_USE_BOTH ); } + + protected function afterCreate(): void + { + event(new AirlinesUpdatedEvent); + } } diff --git a/app/Filament/Resources/AirlineResource/Pages/EditAirline.php b/app/Filament/Resources/AirlineResource/Pages/EditAirline.php index 8fe69c546..576acb3c5 100644 --- a/app/Filament/Resources/AirlineResource/Pages/EditAirline.php +++ b/app/Filament/Resources/AirlineResource/Pages/EditAirline.php @@ -2,10 +2,21 @@ namespace App\Filament\Resources\AirlineResource\Pages; +use App\Events\Airline\AirlinesUpdatedEvent; use App\Filament\Resources\AirlineResource; use Filament\Resources\Pages\EditRecord; class EditAirline extends EditRecord { public static string $resource = AirlineResource::class; + + protected function afterSave(): void + { + event(new AirlinesUpdatedEvent); + } + + protected function afterDelete(): void + { + event(new AirlinesUpdatedEvent); + } } diff --git a/app/Filament/Resources/StandAssignmentsHistoryResource.php b/app/Filament/Resources/StandAssignmentsHistoryResource.php index 7f2be50fd..0cf12ea5f 100644 --- a/app/Filament/Resources/StandAssignmentsHistoryResource.php +++ b/app/Filament/Resources/StandAssignmentsHistoryResource.php @@ -63,12 +63,6 @@ protected static function shouldRegisterNavigation(): bool return self::userCanAccess(); } - public function mount(): void - { - dd(self::userCanAccess()); - abort_unless(self::userCanAccess(), 403); - } - public static function form(Form $form): Form { return $form diff --git a/app/Http/Livewire/StandPredictorForm.php b/app/Http/Livewire/StandPredictorForm.php new file mode 100644 index 000000000..ebc1f47fa --- /dev/null +++ b/app/Http/Livewire/StandPredictorForm.php @@ -0,0 +1,71 @@ + 'You must select a valid stand.', + 'requestedTime' => 'Please enter a valid time.', + ]; + + public function getFormSchema(): array + { + return [ + Grid::make() + ->schema([ + TextInput::make('callsign') + ->placeholder('BAW123') + ->required() + ->label('Callsign'), + Select::make('aircraftType') + ->label('Aircraft Type') + ->options(SelectOptions::aircraftTypes()) + ->required() + ->searchable(), + Select::make('departureAirfield') + ->label('Departure Airfield') + ->options(Airfield::all()->mapWithKeys(fn ($airfield) => [$airfield->code => $airfield->code])) + ->required() + ->searchable(), + Select::make('arrivalAirfield') + ->label('Arrival Airfield') + ->options(Airfield::all()->mapWithKeys(fn ($airfield) => [$airfield->code => $airfield->code])) + ->required() + ->searchable(), + ]) + ]; + } + + public function submit(): void + { + $this->form->validate(); + $this->emit('standPredictorFormSubmitted', [ + 'callsign' => $this->callsign, + 'cid' => Auth::id(), + 'aircraft_id' => $this->aircraftType, + 'airline_id' => app()->make(AirlineService::class)->airlineIdForCallsign($this->callsign), + 'planned_depairport' => $this->departureAirfield, + 'planned_destairport' => $this->arrivalAirfield, + ]); + } +} diff --git a/app/Listeners/Aircraft/NotifyAircraftServiceOfDataUpdate.php b/app/Listeners/Aircraft/NotifyAircraftServiceOfDataUpdate.php new file mode 100644 index 000000000..2ad843273 --- /dev/null +++ b/app/Listeners/Aircraft/NotifyAircraftServiceOfDataUpdate.php @@ -0,0 +1,19 @@ +aircraftService->aircraftDataUpdated(); + } +} diff --git a/app/Listeners/Airline/NotifyAirlineServiceOfDataUpdate.php b/app/Listeners/Airline/NotifyAirlineServiceOfDataUpdate.php new file mode 100644 index 000000000..3e9670235 --- /dev/null +++ b/app/Listeners/Airline/NotifyAirlineServiceOfDataUpdate.php @@ -0,0 +1,19 @@ +airlineService->airlinesUpdated(); + } +} diff --git a/app/Models/Airline/Airline.php b/app/Models/Airline/Airline.php index c9d411409..a04cd078f 100644 --- a/app/Models/Airline/Airline.php +++ b/app/Models/Airline/Airline.php @@ -4,11 +4,14 @@ use App\Models\Airfield\Terminal; use App\Models\Stand\Stand; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Airline extends Model { + use HasFactory; + protected $fillable = [ 'icao_code', 'name', diff --git a/app/Models/Stand/Stand.php b/app/Models/Stand/Stand.php index 18b27dcc7..5c8032b26 100644 --- a/app/Models/Stand/Stand.php +++ b/app/Models/Stand/Stand.php @@ -126,10 +126,10 @@ public function scopeAvailable(Builder $builder): Builder return $this->scopeNotClosed($this->scopeNotReserved($this->scopeUnassigned($this->scopeUnoccupied($builder)))); } - public function scopeAirline(Builder $builder, Airline $airline): Builder + public function scopeAirline(Builder $builder, Airline|int $airline): Builder { return $builder->join('airline_stand', 'stands.id', '=', 'airline_stand.stand_id') - ->where('airline_stand.airline_id', $airline->id) + ->where('airline_stand.airline_id', is_int($airline) ? $airline : $airline->id) ->where( function (Builder $query) { // Timezones here should be local because Heathrow. @@ -140,11 +140,6 @@ function (Builder $query) { ); } - public function scopeAirlineDestination(Builder $builder, Airline $airline, array $destinationStrings): Builder - { - return $this->scopeAirline($builder, $airline)->whereIn('destination', $destinationStrings); - } - public function scopeAirlineCallsign(Builder $builder, Airline $airline, array $slugs): Builder { return $this->scopeAirline($builder, $airline)->whereIn('callsign_slug', $slugs); diff --git a/app/Models/Vatsim/NetworkAircraft.php b/app/Models/Vatsim/NetworkAircraft.php index a16bb0ecd..373cf1ff4 100644 --- a/app/Models/Vatsim/NetworkAircraft.php +++ b/app/Models/Vatsim/NetworkAircraft.php @@ -2,6 +2,7 @@ namespace App\Models\Vatsim; +use App\Models\Aircraft\Aircraft; use App\Models\Airfield\Airfield; use App\Models\Hold\NavaidNetworkAircraft; use App\Models\Navigation\Navaid; @@ -48,6 +49,8 @@ class NetworkAircraft extends Model 'transponder', 'planned_flighttype', 'planned_route', + 'aircraft_id', + 'airline_id', 'remarks', ]; @@ -155,4 +158,9 @@ public function user(): HasOne { return $this->hasOne(User::class, 'id', 'cid'); } + + public function aircraft(): BelongsTo + { + return $this->belongsTo(Aircraft::class); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b0d2c45c5..21f7517d3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,7 @@ use App\Http\Livewire\CurrentStandRequest; use App\Http\Livewire\RequestAStandForm; +use App\Http\Livewire\StandPredictorForm; use App\SocialiteProviders\CoreProvider; use Bugsnag\BugsnagLaravel\Facades\Bugsnag; use Filament\Facades\Filament; @@ -45,9 +46,9 @@ public function boot() $user = Auth::user(); $report->setUser([ - 'id' => $user->id, - 'name' => $user->name - ]); + 'id' => $user->id, + 'name' => $user->name + ]); } }); @@ -93,5 +94,6 @@ function ($app) use ($socialite) { // Livewire Livewire::component('request-a-stand-form', RequestAStandForm::class); Livewire::component('current-stand-request', CurrentStandRequest::class); + Livewire::component('stand-predictor-form', StandPredictorForm::class); } } diff --git a/app/Providers/NetworkServiceProvider.php b/app/Providers/NetworkServiceProvider.php index 956d30492..57c111b6c 100644 --- a/app/Providers/NetworkServiceProvider.php +++ b/app/Providers/NetworkServiceProvider.php @@ -10,6 +10,8 @@ use App\Jobs\Squawk\MarkAssignmentDeletedOnDisconnect; use App\Jobs\Stand\TriggerUnassignmentOnDisconnect; use App\Models\FlightInformationRegion\FlightInformationRegion; +use App\Services\AircraftService; +use App\Services\AirlineService; use App\Services\NetworkAircraftService; use App\Services\NetworkDataDownloadService; use App\Services\NetworkDataService; @@ -30,6 +32,8 @@ public function register() $this->app->singleton(NetworkAircraftService::class, function (Application $application) { return new NetworkAircraftService( $application->make(NetworkDataService::class), + $application->make(AircraftService::class), + $application->make(AirlineService::class), FlightInformationRegion::with('proximityMeasuringPoints') ->get() ->pluck('proximityMeasuringPoints') diff --git a/app/Providers/StandServiceProvider.php b/app/Providers/StandServiceProvider.php index 68c38056e..8dd87c985 100644 --- a/app/Providers/StandServiceProvider.php +++ b/app/Providers/StandServiceProvider.php @@ -22,11 +22,11 @@ use Illuminate\Support\ServiceProvider; use App\Imports\Stand\StandReservationsImport; use App\Allocator\Stand\CargoAirlineFallbackStandAllocator; -use App\Allocator\Stand\AirlineArrivalStandAllocator; +use App\Allocator\Stand\AirlineGeneralArrivalStandAllocator; use App\Allocator\Stand\FallbackArrivalStandAllocator; use App\Allocator\Stand\CallsignFlightplanReservedArrivalStandAllocator; use App\Allocator\Stand\DomesticInternationalStandAllocator; -use App\Allocator\Stand\AirlineTerminalArrivalStandAllocator; +use App\Allocator\Stand\AirlineGeneralTerminalArrivalStandAllocator; use App\Allocator\Stand\AirlineDestinationArrivalStandAllocator; use App\Allocator\Stand\OriginAirfieldStandAllocator; @@ -50,12 +50,12 @@ public function register() $application->make(AirlineCallsignSlugArrivalStandAllocator::class), $application->make(AirlineAircraftArrivalStandAllocator::class), $application->make(AirlineDestinationArrivalStandAllocator::class), - $application->make(AirlineArrivalStandAllocator::class), + $application->make(AirlineGeneralArrivalStandAllocator::class), $application->make(AirlineCallsignTerminalArrivalStandAllocator::class), $application->make(AirlineCallsignSlugTerminalArrivalStandAllocator::class), $application->make(AirlineAircraftTerminalArrivalStandAllocator::class), $application->make(AirlineDestinationTerminalArrivalStandAllocator::class), - $application->make(AirlineTerminalArrivalStandAllocator::class), + $application->make(AirlineGeneralTerminalArrivalStandAllocator::class), $application->make(CargoAirlineFallbackStandAllocator::class), $application->make(OriginAirfieldStandAllocator::class), $application->make(DomesticInternationalStandAllocator::class), diff --git a/app/Services/AircraftService.php b/app/Services/AircraftService.php index abb0f388f..536f7c4c7 100644 --- a/app/Services/AircraftService.php +++ b/app/Services/AircraftService.php @@ -4,9 +4,12 @@ use App\Models\Aircraft\Aircraft; use App\Models\Aircraft\WakeCategory; +use Illuminate\Support\Facades\Cache; class AircraftService { + private const AIRCRAFT_CODE_ID_MAP_CACHE_KEY = 'AIRCRAFT_CODE_ID_MAP'; + public function getAircraftDependency(): array { return Aircraft::with('wakeCategories')->get()->map(fn (Aircraft $aircraft) => [ @@ -17,4 +20,24 @@ public function getAircraftDependency(): array )->toArray(), ])->toArray(); } + + public function getAircraftIdFromCode(string $code): ?int + { + return $this->aircraftCodeIdMap()[$code] ?? null; + } + + public function aircraftDataUpdated(): void + { + Cache::forget(self::AIRCRAFT_CODE_ID_MAP_CACHE_KEY); + } + + private function aircraftCodeIdMap(): array + { + return Cache::rememberForever( + self::AIRCRAFT_CODE_ID_MAP_CACHE_KEY, + fn () => Aircraft::all()->mapWithKeys(fn (Aircraft $aircraft) => [ + $aircraft->code => $aircraft->id, + ])->toArray() + ); + } } diff --git a/app/Services/AirlineService.php b/app/Services/AirlineService.php index e56fd72cc..5db04276e 100644 --- a/app/Services/AirlineService.php +++ b/app/Services/AirlineService.php @@ -4,20 +4,54 @@ use App\Models\Airline\Airline; use App\Models\Vatsim\NetworkAircraft; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; class AirlineService { + private const AIRLINE_CODE_ID_CACHE_MAP = 'AIRLINE_CODE_ID_MAP'; + public function getAirlineForAircraft(NetworkAircraft $aircraft): ?Airline { - return Airline::where('icao_code', Str::substr($aircraft->callsign, 0, 3))->first(); + $airlineId = $this->airlineIdForCallsign($aircraft->callsign); + return $airlineId + ? Airline::find($airlineId) + : null; + } + + public function airlineIdForCallsign(string $callsign): ?int + { + return $this->airlineCodeIdMap()[$this->airlineCodeForCallsign($callsign)] ?? null; + } + + public function getCallsignSlugForAircraft(NetworkAircraft|string $aircraft): string + { + $callsign = $aircraft instanceof NetworkAircraft + ? $aircraft->callsign + : $aircraft; + + return $this->airlineIdForCallsign($callsign) === null + ? $callsign + : Str::substr($callsign, 3); + } + + private function airlineCodeForCallsign(string $callsign): string + { + return Str::substr($callsign, 0, 3); + } + + public function airlinesUpdated() + { + Cache::forget(self::AIRLINE_CODE_ID_CACHE_MAP); } - public function getCallsignSlugForAircraft(NetworkAircraft $aircraft): string + private function airlineCodeIdMap(): array { - $airline = $this->getAirlineForAircraft($aircraft); - return $airline - ? Str::substr($aircraft->callsign, 3) - : $aircraft->callsign; + return Cache::rememberForever( + self::AIRLINE_CODE_ID_CACHE_MAP, + fn () => Airline::all(['id', 'icao_code'])->mapWithKeys(function (Airline $airline) { + return [$airline->icao_code => $airline->id]; + })->toArray() + ); } } diff --git a/app/Services/NetworkAircraftService.php b/app/Services/NetworkAircraftService.php index 2e5d8a944..7a8f74795 100644 --- a/app/Services/NetworkAircraftService.php +++ b/app/Services/NetworkAircraftService.php @@ -21,9 +21,19 @@ class NetworkAircraftService private NetworkDataService $dataService; private Collection $allAircraftBeforeUpdate; - public function __construct(NetworkDataService $dataService, Collection $measuringPoints) - { + private readonly AircraftService $aircraftService; + + private readonly AirlineService $airlineService; + + public function __construct( + NetworkDataService $dataService, + AircraftService $aircraftService, + AirlineService $airlineService, + Collection $measuringPoints + ) { $this->measuringPoints = $measuringPoints; + $this->aircraftService = $aircraftService; + $this->airlineService = $airlineService; $this->dataService = $dataService; } @@ -84,6 +94,8 @@ private function shouldProcessPilot(array $pilot): bool */ private function formatPilot(array $pilot): array { + $shortAircraftCode = $this->getFlightplanDataElement($pilot, 'aircraft_short'); + return [ 'callsign' => $pilot['callsign'], 'cid' => $pilot['cid'], @@ -93,7 +105,7 @@ private function formatPilot(array $pilot): array 'groundspeed' => $pilot['groundspeed'], 'transponder' => $pilot['transponder'], 'planned_aircraft' => $this->getFlightplanDataElement($pilot, 'aircraft'), - 'planned_aircraft_short' => $this->getFlightplanDataElement($pilot, 'aircraft_short'), + 'planned_aircraft_short' => $shortAircraftCode, 'planned_depairport' => $this->getFlightplanDataElement($pilot, 'departure'), 'planned_destairport' => $this->getFlightplanDataElement($pilot, 'arrival'), 'planned_altitude' => $this->getFlightplanDataElement($pilot, 'altitude'), @@ -101,6 +113,10 @@ private function formatPilot(array $pilot): array 'planned_route' => $this->getFlightplanDataElement($pilot, 'route'), 'remarks' => $this->getFlightplanDataElement($pilot, 'remarks'), 'transponder_last_updated_at' => $this->getTransponderUpdatedAtTime($pilot), + 'aircraft_id' => $shortAircraftCode + ? $this->aircraftService->getAircraftIdFromCode($shortAircraftCode) + : null, + 'airline_id' => $this->airlineService->airlineIdForCallsign($pilot['callsign']), ]; } @@ -111,7 +127,7 @@ private function formatPilot(array $pilot): array private function getTransponderUpdatedAtTime(array $pilot): Carbon { return $this->allAircraftBeforeUpdate->has($pilot['callsign']) && - $this->allAircraftBeforeUpdate->get($pilot['callsign'])->transponder === $pilot['transponder'] + $this->allAircraftBeforeUpdate->get($pilot['callsign'])->transponder === $pilot['transponder'] ? $this->allAircraftBeforeUpdate->get($pilot['callsign'])->transponder_last_updated_at : Carbon::now(); } diff --git a/app/Services/Stand/ArrivalAllocationService.php b/app/Services/Stand/ArrivalAllocationService.php index 16ddef0c2..3b3f44de8 100644 --- a/app/Services/Stand/ArrivalAllocationService.php +++ b/app/Services/Stand/ArrivalAllocationService.php @@ -2,7 +2,10 @@ namespace App\Services\Stand; +use App\Allocator\Stand\ArrivalStandAllocator; +use App\Allocator\Stand\RankableArrivalStandAllocator; use App\Models\Airfield\Airfield; +use App\Models\Stand\Stand; use App\Models\Stand\StandAssignment; use App\Models\Vatsim\NetworkAircraft; use App\Services\LocationService; @@ -19,7 +22,7 @@ class ArrivalAllocationService private readonly StandAssignmentsService $assignmentsService; /** - * @var ArrivalStandAllocatorInterface[] + * @var ArrivalStandAllocator[] */ private readonly array $allocators; @@ -83,7 +86,7 @@ private function allocateStandsForArrivingAircraft(): void private function getAircraftThatCanHaveArrivalStandsAllocated(): Collection { return NetworkAircraft::join('airfield', 'airfield.code', '=', 'network_aircraft.planned_destairport') - ->join('aircraft', 'aircraft.code', '=', 'network_aircraft.planned_aircraft_short') + ->join('aircraft', 'network_aircraft.aircraft_id', '=', 'aircraft.id') ->leftJoin('stand_assignments', 'stand_assignments.callsign', '=', 'network_aircraft.callsign') ->whereRaw('network_aircraft.planned_destairport <> network_aircraft.planned_depairport') ->where('aircraft.allocate_stands', '<>', 0) @@ -118,11 +121,28 @@ private function getTimeFromAirfieldInMinutes(NetworkAircraft $aircraft, Airfiel ); $groundspeed = $aircraft->groundspeed === 0 ? 1 : $aircraft->groundspeed; - return (float)($distanceToAirfieldInNm / $groundspeed) * 60.0; + return (float) ($distanceToAirfieldInNm / $groundspeed) * 60.0; } public function getAllocators(): array { return $this->allocators; } + + public function getAllocationRankingForAircraft(NetworkAircraft $aircraft): Collection + { + $ranking = collect(); + + foreach ($this->allocators as $allocator) { + if (!$allocator instanceof RankableArrivalStandAllocator) { + continue; + } + + $ranking[get_class($allocator)] = $allocator->getRankedStandAllocation($aircraft) + ->groupBy('rank') + ->values(); + } + + return $ranking; + } } diff --git a/app/Services/Stand/StandStatusService.php b/app/Services/Stand/StandStatusService.php index 9807bd5c5..aac3c15d2 100644 --- a/app/Services/Stand/StandStatusService.php +++ b/app/Services/Stand/StandStatusService.php @@ -15,7 +15,7 @@ class StandStatusService */ public static function getAirfieldStandStatus(string $airfield): array { - $stands = Stand::with( + return Stand::with( 'airlines', 'type', 'maxAircraftWingspan', @@ -34,13 +34,10 @@ public static function getAirfieldStandStatus(string $airfield): array ) ->withCasts(['latitude' => 'decimal:8', 'longitude' => 'decimal:8']) ->airfield($airfield) - ->get(); - - return $stands->sortBy('identifier', SORT_NATURAL) + ->get() + ->sortBy('identifier', SORT_NATURAL) ->values() - ->map(function (Stand $stand) { - return self::getStandStatus($stand); - }) + ->map(fn (Stand $stand) => self::getStandStatus($stand)) ->toArray(); } diff --git a/database/factories/Airline/AirlineFactory.php b/database/factories/Airline/AirlineFactory.php new file mode 100644 index 000000000..e20290bae --- /dev/null +++ b/database/factories/Airline/AirlineFactory.php @@ -0,0 +1,32 @@ + Str::upper($this->faker->unique()->lexify('???')), + 'name' => $this->faker->unique()->company(), + 'callsign' => $this->faker->unique()->word(), + 'is_cargo' => false, + ]; + } +} diff --git a/database/factories/Stand/StandFactory.php b/database/factories/Stand/StandFactory.php index a4090b768..da67d0eff 100644 --- a/database/factories/Stand/StandFactory.php +++ b/database/factories/Stand/StandFactory.php @@ -45,7 +45,7 @@ private function standIdentifier(): string { return sprintf( '%d%s', - $this->faker->numberBetween(0, 500), + $this->faker->unique()->numberBetween(0, 500), $this->faker->randomElement(['L', 'R', '', 'A']), ); } diff --git a/database/migrations/2023_08_30_170815_add_aircraft_type_column_to_network_aircraft_table.php b/database/migrations/2023_08_30_170815_add_aircraft_type_column_to_network_aircraft_table.php new file mode 100644 index 000000000..88502c2b8 --- /dev/null +++ b/database/migrations/2023_08_30_170815_add_aircraft_type_column_to_network_aircraft_table.php @@ -0,0 +1,34 @@ +foreignIdFor(Aircraft::class) + ->after('remarks') + ->comment('The matched aircraft type for this flight') + ->nullable() + ->constrained() + ->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('network_aircraft', function (Blueprint $table) { + $table->dropForeign(['aircraft_id']); + $table->dropColumn('aircraft_id'); + }); + } +}; diff --git a/database/migrations/2023_08_31_171451_add_airline_id_to_network_aircraft_table.php b/database/migrations/2023_08_31_171451_add_airline_id_to_network_aircraft_table.php new file mode 100644 index 000000000..4fe298cdb --- /dev/null +++ b/database/migrations/2023_08_31_171451_add_airline_id_to_network_aircraft_table.php @@ -0,0 +1,33 @@ +foreignIdFor(Airline::class) + ->nullable() + ->constrained() + ->after('aircraft_id') + ->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('network_aircraft', function (Blueprint $table) { + $table->dropForeign(['airline_id']); + $table->dropColumn('airline_id'); + }); + } +}; diff --git a/phpunit.xml b/phpunit.xml index e7b9a0d68..474fc6f98 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,6 +8,9 @@ + + + diff --git a/resources/views/filament/pages/stand-predictor.blade.php b/resources/views/filament/pages/stand-predictor.blade.php new file mode 100644 index 000000000..b60130f59 --- /dev/null +++ b/resources/views/filament/pages/stand-predictor.blade.php @@ -0,0 +1,24 @@ + + @livewire('stand-predictor-form') + + @if ($this->currentPrediction) + @foreach ($this->currentPrediction as $allocator => $groups) +

Allocator: {{ $allocator }}

+ @if (empty($groups)) + No stands for this allocator. + @continue + @endif + + @php + $rank = 0; + @endphp + @while ($rank < count($groups)) +

Rank {{ $rank + 1 }}

+
{{ implode(',', $groups[$rank]) }}
+ @php + $rank++; + @endphp + @endwhile + @endforeach + @endif +
diff --git a/resources/views/livewire/stand-predictor-form.blade.php b/resources/views/livewire/stand-predictor-form.blade.php new file mode 100644 index 000000000..152bf0913 --- /dev/null +++ b/resources/views/livewire/stand-predictor-form.blade.php @@ -0,0 +1,8 @@ +
+
+ {{ $this->form }} + + Predict + +
+
diff --git a/tests/BaseFilamentTestCase.php b/tests/BaseFilamentTestCase.php index d0030ea3b..c3685c87a 100644 --- a/tests/BaseFilamentTestCase.php +++ b/tests/BaseFilamentTestCase.php @@ -20,4 +20,14 @@ protected function filamentUser(): User { return User::findOrFail(self::ACTIVE_USER_CID); } + + protected function assumeRole(RoleKeys $role): void + { + $this->filamentUser()->roles()->sync([Role::idFromKey($role)]); + } + + protected function noRole(): void + { + $this->filamentUser()->roles()->sync([]); + } } diff --git a/tests/app/Allocator/Stand/AirlineAircraftArrivalStandAllocatorTest.php b/tests/app/Allocator/Stand/AirlineAircraftArrivalStandAllocatorTest.php index 9e5fa4603..093b35dcc 100644 --- a/tests/app/Allocator/Stand/AirlineAircraftArrivalStandAllocatorTest.php +++ b/tests/app/Allocator/Stand/AirlineAircraftArrivalStandAllocatorTest.php @@ -4,8 +4,13 @@ use App\BaseFunctionalTestCase; use App\Models\Aircraft\Aircraft; +use App\Models\Airfield\Airfield; +use App\Models\Airline\Airline; use App\Models\Stand\Stand; +use App\Models\Stand\StandRequest; +use App\Models\Stand\StandReservation; use App\Models\Vatsim\NetworkAircraft; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; class AirlineAircraftArrivalStandAllocatorTest extends BaseFunctionalTestCase @@ -16,6 +21,7 @@ public function setUp(): void { parent::setUp(); $this->allocator = $this->app->make(AirlineAircraftArrivalStandAllocator::class); + Airline::factory()->create(['icao_code' => 'EZY']); } public function testItAllocatesAStandWithAnAircraftType() @@ -307,19 +313,188 @@ public function testItDoesntAllocateNonExistentAirlines() $this->assertNull($this->allocator->allocate($aircraft)); } + public function testItDoesntAllocateUnknownAircraftTypes() + { + DB::table('airline_stand')->insert( + [ + [ + 'airline_id' => 1, + 'stand_id' => 3, + 'aircraft_id' => 1 + ], + [ + 'airline_id' => 2, + 'stand_id' => 1, + 'aircraft_id' => 1 + ], + ] + ); + $aircraft = $this->createAircraft('***1234', 'EGLL', 'EGGD', aircraftType: 'XXX'); + $this->assertNull($this->allocator->allocate($aircraft)); + } + + public function testItDoesntRankStandsIfUnknownAircraftType() + { + $aircraft = $this->newAircraft('BAW23451', 'EGLL', 'EGGD', aircraftType: 'XXX'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItDoesntRankStandsIfUnknownAirline() + { + $aircraft = $this->newAircraft('***1234', 'EGLL', 'EGGD'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItGetsRankedStandAllocation() + { + // Create an airfield that we dont have so we know its a clean test + $airfield = Airfield::factory()->create(['code' => 'EXXX']); + $airfieldId = $airfield->id; + + // Create a small aircraft type to test stand size ranking + $cessna = Aircraft::create( + [ + 'code' => 'C172', + 'allocate_stands' => true, + 'aerodrome_reference_code' => 'A', + 'wingspan' => 1, + 'length' => 12, + ] + ); + + // Should be ranked first - it has the highest priority. It gets a stand reservation to make + // sure it is ranked first even if it is occupied. + $standA1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'A1', + ] + ); + $standA1->airlines()->sync([1 => ['aircraft_id' => 1, 'priority' => 100]]); + StandReservation::create( + [ + 'stand_id' => $standA1->id, + 'start' => Carbon::now()->subMinutes(1), + 'end' => Carbon::now()->addMinutes(1), + ] + ); + + // Should be ranked joint second, lower priority than A1. B1 has a request, to show that requests arent considered + // when ranking. + $standB1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B1', + 'aerodrome_reference_code' => 'C' + ] + ); + StandRequest::factory()->create(['requested_time' => Carbon::now(), 'stand_id' => $standB1->id]); + $standB2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B2', + 'aerodrome_reference_code' => 'C' + ] + ); + $standB1->airlines()->sync([1 => ['aircraft_id' => 1, 'priority' => 101]]); + $standB2->airlines()->sync([1 => ['aircraft_id' => 1, 'priority' => 101]]); + + // Should be ranked joint third, same priority as B1 and B2 but smaller stands + $standC1 = Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'C1']); + $standC1->airlines()->sync([1 => ['aircraft_id' => 1, 'priority' => 101]]); + $standC2 = Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'C2']); + $standC2->airlines()->sync([1 => ['aircraft_id' => 1, 'priority' => 101]]); + + // Should not appear in rankings - wrong airfield + $standD1 = Stand::factory()->create(['airfield_id' => 2, 'identifier' => 'D1']); + $standD1->airlines()->sync([1 => ['aircraft_id' => 1]]); + + // Should not appear in rankings - wrong aircraft type + $standE1 = Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'E1']); + $standE1->airlines()->sync([1 => ['aircraft_id' => $cessna->id]]); + + // Should not appear in rankings - too small ARC + $standF1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'F1', + 'aerodrome_reference_code' => 'A' + ] + ); + $standF1->airlines()->sync([1 => ['aircraft_id' => 1]]); + + // Should not appear in rankings - too small max aircraft size + $standG1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'G1', + 'max_aircraft_id_length' => $cessna->id, + 'max_aircraft_id_wingspan' => $cessna->id + ] + ); + $standG1->airlines()->sync([1 => ['aircraft_id' => 1]]); + + // Should not appear in rankings - closed + $standH1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'H1', + 'aerodrome_reference_code' => 'C', + 'closed_at' => Carbon::now(), + ] + ); + $standH1->airlines()->sync([1 => ['aircraft_id' => 1]]); + + $expectedRanks = [ + $standA1->id => 1, + $standB1->id => 2, + $standB2->id => 2, + $standC1->id => 3, + $standC2->id => 3, + ]; + + $actualRanks = $this->allocator->getRankedStandAllocation( + $this->newAircraft('BAW23451', $airfield->code, 'EGGD') + )->mapWithKeys( + fn($stand) => [$stand->id => $stand->rank] + ) + ->toArray(); + + $this->assertEquals($expectedRanks, $actualRanks); + } + private function createAircraft( string $callsign, string $arrivalAirport, - string $departureAirport + string $departureAirport, + string $aircraftType = 'B738' + ): NetworkAircraft { + return tap( + $this->newAircraft($callsign, $arrivalAirport, $departureAirport, $aircraftType), + fn(NetworkAircraft $aircraft) => $aircraft->save() + ); + } + + private function newAircraft( + string $callsign, + string $arrivalAirport, + string $departureAirport, + string $aircraftType = 'B738' ): NetworkAircraft { - return NetworkAircraft::create( + return new NetworkAircraft( [ 'callsign' => $callsign, 'cid' => 1234, - 'planned_aircraft' => 'B738', - 'planned_aircraft_short' => 'B738', + 'planned_aircraft' => $aircraftType, + 'planned_aircraft_short' => $aircraftType, 'planned_destairport' => $arrivalAirport, 'planned_depairport' => $departureAirport, + 'aircraft_id' => $aircraftType === 'B738' ? 1 : null, + 'airline_id' => match ($callsign) { + 'BAW23451' => 1, + 'EZY7823' => Airline::where('icao_code', 'EZY')->first()->id, + default => null, + }, ] ); } diff --git a/tests/app/Allocator/Stand/AirlineAircraftTerminalArrivalStandAllocatorTest.php b/tests/app/Allocator/Stand/AirlineAircraftTerminalArrivalStandAllocatorTest.php index af606a5d4..24e2a4464 100644 --- a/tests/app/Allocator/Stand/AirlineAircraftTerminalArrivalStandAllocatorTest.php +++ b/tests/app/Allocator/Stand/AirlineAircraftTerminalArrivalStandAllocatorTest.php @@ -4,9 +4,14 @@ use App\BaseFunctionalTestCase; use App\Models\Aircraft\Aircraft; +use App\Models\Airfield\Airfield; use App\Models\Airfield\Terminal; +use App\Models\Airline\Airline; use App\Models\Stand\Stand; +use App\Models\Stand\StandRequest; +use App\Models\Stand\StandReservation; use App\Models\Vatsim\NetworkAircraft; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; class AirlineAircraftTerminalArrivalStandAllocatorTest extends BaseFunctionalTestCase @@ -17,6 +22,7 @@ public function setUp(): void { parent::setUp(); $this->allocator = $this->app->make(AirlineAircraftTerminalArrivalStandAllocator::class); + Airline::factory()->create(['icao_code' => 'EZY']); } public function testItAllocatesAStandWithAFixedCallsign() @@ -249,7 +255,7 @@ public function testItDoesntAllocateAStandWithNoAircraftType() ] ); - $aircraft = $this->createAircraft('BAW23451', 'EGLL', 'EGGD'); + $aircraft = $this->createAircraft('BAW23451', 'EGLL', 'EGGD', aircraftType: 'XXX'); $this->assertNull($this->allocator->allocate($aircraft)); } @@ -335,19 +341,218 @@ public function testItDoesntAllocateNonExistentAirlines() $this->assertNull($this->allocator->allocate($aircraft)); } + public function testItDoesntRankStandsIfUnknownAircraftType() + { + $aircraft = $this->newAircraft('BAW23451', 'EGLL', 'EGGD', aircraftType: 'XXX'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItDoesntRankStandsIfUnknownAirline() + { + $aircraft = $this->newAircraft('***1234', 'EGLL', 'EGGD'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItGetsRankedStandAllocation() + { + // Create an airfield that we dont have so we know its a clean test + $airfield = Airfield::factory()->create(['code' => 'EXXX']); + $airfieldId = $airfield->id; + + // Create a small aircraft type to test stand size ranking + $cessna = Aircraft::create( + [ + 'code' => 'C172', + 'allocate_stands' => true, + 'aerodrome_reference_code' => 'A', + 'wingspan' => 1, + 'length' => 12, + ] + ); + + // Should be ranked first - it has the highest priority. Both stands on the terminal should be + // included. Stand A1 gets a reservation and a request so that we show its not considered. + $terminalA1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalA1->airlines()->sync([1 => ['aircraft_id' => 1, 'priority' => 100]]); + $standA1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalA1->id, + 'identifier' => 'A1', + ] + ); + $standA2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalA1->id, + 'identifier' => 'A2', + ] + ); + StandReservation::create( + [ + 'stand_id' => $standA1->id, + 'start' => Carbon::now()->subMinutes(1), + 'end' => Carbon::now()->addMinutes(1), + ] + ); + StandRequest::factory()->create(['requested_time' => Carbon::now(), 'stand_id' => $standA1->id]); + + // Should be ranked joint second, lower priority than A1. + $terminalB1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalB1->airlines()->sync([1 => ['aircraft_id' => 1, 'priority' => 101]]); + $standB1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalB1->id, + 'identifier' => 'B1', + 'aerodrome_reference_code' => 'C' + ] + ); + + $terminalB2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalB2->airlines()->sync([1 => ['aircraft_id' => 1, 'priority' => 101]]); + $standB2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalB2->id, + 'identifier' => 'B2', + 'aerodrome_reference_code' => 'C' + ] + ); + + // Should be ranked joint third, same priority as B1 and B2 but smaller stands + $terminalC1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalC1->airlines()->sync([1 => ['aircraft_id' => 1, 'priority' => 101]]); + $standC1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C1', + 'terminal_id' => $terminalC1->id, + ] + ); + + $terminalC2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalC2->airlines()->sync([1 => ['aircraft_id' => 1, 'priority' => 101]]); + $standC2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C2', + 'terminal_id' => $terminalC2->id, + ] + ); + + // Should not appear in rankings - wrong airfield + Terminal::find(1)->airlines()->sync([1 => ['aircraft_id' => 1, 'priority' => 101]]); + Stand::factory()->create( + [ + 'airfield_id' => 1, + 'identifier' => 'D1', + 'terminal_id' => 1 + ] + ); + + // Should not appear in rankings - wrong terminal + $terminalD2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'D1', + 'terminal_id' => $terminalD2->id + ] + ); + + // Should not appear in rankings - wrong aircraft type + $terminalE1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalE1->airlines()->sync([1 => ['aircraft_id' => 1]]); + Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'E1']); + + // Should not appear in rankings - too small ARC + $terminalF1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalF1->airlines()->sync([1 => ['aircraft_id' => 1]]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'F1', + 'aerodrome_reference_code' => 'A' + ] + ); + + // Should not appear in rankings - too small max aircraft size + $terminalG1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalG1->airlines()->sync([1 => ['aircraft_id' => 1]]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'G1', + 'max_aircraft_id_length' => $cessna->id, + 'max_aircraft_id_wingspan' => $cessna->id + ] + ); + + + // Should not appear in rankings - closed + $terminalH1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalH1->airlines()->sync([1 => ['aircraft_id' => 1]]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'H1', + 'closed_at' => Carbon::now() + ] + ); + + + $expectedRanks = [ + $standA1->id => 1, + $standA2->id => 1, + $standB1->id => 2, + $standB2->id => 2, + $standC1->id => 3, + $standC2->id => 3, + ]; + + $actualRanks = $this->allocator->getRankedStandAllocation( + $this->newAircraft('BAW23451', $airfield->code, 'EGGD') + )->mapWithKeys( + fn($stand) => [$stand->id => $stand->rank] + ) + ->toArray(); + + $this->assertEquals($expectedRanks, $actualRanks); + } + private function createAircraft( string $callsign, string $arrivalAirport, - string $departureAirport + string $departureAirport, + string $aircraftType = 'B738' + ): NetworkAircraft { + return tap( + $this->newAircraft($callsign, $arrivalAirport, $departureAirport, $aircraftType), + fn(NetworkAircraft $aircraft) => $aircraft->save() + ); + } + + private function newAircraft( + string $callsign, + string $arrivalAirport, + string $departureAirport, + string $aircraftType = 'B738' ): NetworkAircraft { - return NetworkAircraft::create( + return new NetworkAircraft( [ 'callsign' => $callsign, 'cid' => 1234, - 'planned_aircraft' => 'B738', - 'planned_aircraft_short' => 'B738', + 'planned_aircraft' => $aircraftType, + 'planned_aircraft_short' => $aircraftType, 'planned_destairport' => $arrivalAirport, 'planned_depairport' => $departureAirport, + 'aircraft_id' => $aircraftType === 'B738' ? 1 : null, + 'airline_id' => match ($callsign) { + 'BAW23451' => 1, + 'EZY7823' => Airline::where('icao_code', 'EZY')->first()->id, + default => null, + }, ] ); } diff --git a/tests/app/Allocator/Stand/AirlineCallsignArrivalStandAllocatorTest.php b/tests/app/Allocator/Stand/AirlineCallsignArrivalStandAllocatorTest.php index 071ef1c90..db54d0e45 100644 --- a/tests/app/Allocator/Stand/AirlineCallsignArrivalStandAllocatorTest.php +++ b/tests/app/Allocator/Stand/AirlineCallsignArrivalStandAllocatorTest.php @@ -4,8 +4,13 @@ use App\BaseFunctionalTestCase; use App\Models\Aircraft\Aircraft; +use App\Models\Airfield\Airfield; +use App\Models\Airline\Airline; use App\Models\Stand\Stand; +use App\Models\Stand\StandRequest; +use App\Models\Stand\StandReservation; use App\Models\Vatsim\NetworkAircraft; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; class AirlineCallsignArrivalStandAllocatorTest extends BaseFunctionalTestCase @@ -16,6 +21,7 @@ public function setUp(): void { parent::setUp(); $this->allocator = $this->app->make(AirlineCallsignArrivalStandAllocator::class); + Airline::factory()->create(['icao_code' => 'EZY']); } public function testItAllocatesAStandWithAFixedCallsign() @@ -327,19 +333,167 @@ public function testItDoesntAllocateNonExistentAirlines() $this->assertNull($this->allocator->allocate($aircraft)); } + public function testItDoesntRankStandsIfUnknownAirline() + { + $aircraft = $this->newAircraft('***1234', 'EGLL', 'EGGD'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + + public function testItGetsRankedStandAllocation() + { + // Create an airfield that we dont have so we know its a clean test + $airfield = Airfield::factory()->create(['code' => 'EXXX']); + $airfieldId = $airfield->id; + + // Create a small aircraft type to test stand size ranking + $cessna = Aircraft::create( + [ + 'code' => 'C172', + 'allocate_stands' => true, + 'aerodrome_reference_code' => 'A', + 'wingspan' => 1, + 'length' => 12, + ] + ); + + // Should be ranked first - it has the highest priority. It gets a stand reservation to make + // sure it is ranked first even if it is occupied. + $standA1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'A1', + ] + ); + $standA1->airlines()->sync([1 => ['priority' => 100, 'full_callsign' => '23451']]); + StandReservation::create( + [ + 'stand_id' => $standA1->id, + 'start' => Carbon::now()->subMinutes(1), + 'end' => Carbon::now()->addMinutes(1), + ] + ); + + // Should be ranked joint second, lower priority than A1 + $standB1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B1', + 'aerodrome_reference_code' => 'C' + ] + ); + StandRequest::factory()->create(['requested_time' => Carbon::now(), 'stand_id' => $standB1->id]); + $standB2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B2', + 'aerodrome_reference_code' => 'C' + ] + ); + $standB1->airlines()->sync([1 => ['priority' => 101, 'full_callsign' => '23451']]); + $standB2->airlines()->sync([1 => ['priority' => 101, 'full_callsign' => '23451']]); + + // Should be ranked joint third, same priority as B1 and B2 but smaller stands + $standC1 = Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'C1']); + $standC1->airlines()->sync([1 => ['priority' => 101, 'full_callsign' => '23451']]); + $standC2 = Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'C2']); + $standC2->airlines()->sync([1 => ['priority' => 101, 'full_callsign' => '23451']]); + + // Should not appear in rankings - wrong airfield + $standD1 = Stand::factory()->create(['airfield_id' => 2, 'identifier' => 'D1']); + $standD1->airlines()->sync([1 => ['full_callsign' => '23451']]); + + // Should not appear in rankings - wrong callsign + $standE1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'E1', + ] + ); + $standE1->airlines()->sync([1 => ['full_callsign' => 'XYZ']]); + + // Should not appear in rankings - too small ARC + $standF1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'F1', + 'aerodrome_reference_code' => 'A' + ] + ); + $standF1->airlines()->sync([1 => ['full_callsign' => '23451']]); + + // Should not appear in rankings - too small max aircraft size + $standG1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'G1', + 'max_aircraft_id_length' => $cessna->id, + 'max_aircraft_id_wingspan' => $cessna->id + ] + ); + $standG1->airlines()->sync([1 => ['full_callsign' => '23451']]); + + // Should not appear in rankings - closed + $standH1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'H1', + 'aerodrome_reference_code' => 'C', + 'closed_at' => Carbon::now(), + ] + ); + $standH1->airlines()->sync([1 => ['full_callsign' => '23451']]); + + $expectedRanks = [ + $standA1->id => 1, + $standB1->id => 2, + $standB2->id => 2, + $standC1->id => 3, + $standC2->id => 3, + ]; + + $actualRanks = $this->allocator->getRankedStandAllocation( + $this->newAircraft('BAW23451', $airfield->code, 'EGGD') + )->mapWithKeys( + fn($stand) => [$stand->id => $stand->rank] + ) + ->toArray(); + + $this->assertEquals($expectedRanks, $actualRanks); + } + private function createAircraft( string $callsign, string $arrivalAirport, - string $departureAirport + string $departureAirport, + string $aircraftType = 'B738' + ): NetworkAircraft { + return tap( + $this->newAircraft($callsign, $arrivalAirport, $departureAirport, $aircraftType), + fn(NetworkAircraft $aircraft) => $aircraft->save() + ); + } + + private function newAircraft( + string $callsign, + string $arrivalAirport, + string $departureAirport, + string $aircraftType = 'B738' ): NetworkAircraft { return NetworkAircraft::create( [ 'callsign' => $callsign, 'cid' => 1234, - 'planned_aircraft' => 'B738', - 'planned_aircraft_short' => 'B738', + 'planned_aircraft' => $aircraftType, + 'planned_aircraft_short' => $aircraftType, 'planned_destairport' => $arrivalAirport, 'planned_depairport' => $departureAirport, + 'aircraft_id' => $aircraftType === 'B738' ? 1 : null, + 'airline_id' => match ($callsign) { + 'BAW23451' => 1, + 'EZY7823' => Airline::where('icao_code', 'EZY')->first()->id, + default => null, + }, ] ); } diff --git a/tests/app/Allocator/Stand/AirlineCallsignSlugStandAllocatorTest.php b/tests/app/Allocator/Stand/AirlineCallsignSlugArrivalStandAllocatorTest.php similarity index 66% rename from tests/app/Allocator/Stand/AirlineCallsignSlugStandAllocatorTest.php rename to tests/app/Allocator/Stand/AirlineCallsignSlugArrivalStandAllocatorTest.php index 61cdbdb01..f4ff1afdf 100644 --- a/tests/app/Allocator/Stand/AirlineCallsignSlugStandAllocatorTest.php +++ b/tests/app/Allocator/Stand/AirlineCallsignSlugArrivalStandAllocatorTest.php @@ -4,16 +4,24 @@ use App\BaseFunctionalTestCase; use App\Models\Aircraft\Aircraft; +use App\Models\Airfield\Airfield; +use App\Models\Airline\Airline; use App\Models\Stand\Stand; +use App\Models\Stand\StandRequest; +use App\Models\Stand\StandReservation; use App\Models\Vatsim\NetworkAircraft; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; -class AirlineCallsignSlugStandAllocatorTest extends BaseFunctionalTestCase +class AirlineCallsignSlugArrivalStandAllocatorTest extends BaseFunctionalTestCase { + private readonly AirlineCallsignSlugArrivalStandAllocator $allocator; + public function setUp(): void { parent::setUp(); $this->allocator = $this->app->make(AirlineCallsignSlugArrivalStandAllocator::class); + Airline::factory()->create(['icao_code' => 'EZY']); } public function testItAllocatesAStandWithAFixedCallsignSlug() @@ -475,19 +483,195 @@ public function testItDoesntAllocateNonExistentAirlines() $this->assertNull($this->allocator->allocate($aircraft)); } + public function testItDoesntRankStandsIfUnknownAirline() + { + $aircraft = $this->newAircraft('***1234', 'EGLL', 'EGGD'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItDoesntRankStandsIfUnknownAircraft() + { + $aircraft = $this->newAircraft('BAW1234', 'EGLL', 'EGGD', 'C172'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItGetsRankedStandAllocation() + { + // Create an airfield that we dont have so we know its a clean test + $airfield = Airfield::factory()->create(['code' => 'EXXX']); + $airfieldId = $airfield->id; + + // Create a small aircraft type to test stand size ranking + $cessna = Aircraft::create( + [ + 'code' => 'C172', + 'allocate_stands' => true, + 'aerodrome_reference_code' => 'A', + 'wingspan' => 1, + 'length' => 12, + ] + ); + + // Should be ranked first - it has the highest priority. It gets a stand reservation to make + // sure it is ranked first even if it is occupied. + $standA1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'A1', + ] + ); + $standA1->airlines()->sync([1 => ['priority' => 100, 'callsign_slug' => '23451']]); + StandReservation::create( + [ + 'stand_id' => $standA1->id, + 'start' => Carbon::now()->subMinutes(1), + 'end' => Carbon::now()->addMinutes(1), + ] + ); + + // Should be ranked joint second, lower priority than A1 + $standB1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B1', + 'aerodrome_reference_code' => 'C' + ] + ); + StandRequest::factory()->create(['requested_time' => Carbon::now(), 'stand_id' => $standB1->id]); + $standB2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B2', + 'aerodrome_reference_code' => 'C' + ] + ); + $standB1->airlines()->sync([1 => ['priority' => 101, 'callsign_slug' => '23451']]); + $standB2->airlines()->sync([1 => ['priority' => 101, 'callsign_slug' => '23451']]); + + // Should be ranked joint third, same priority as B1 and B2 but smaller stands + $standC1 = Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'C1']); + $standC1->airlines()->sync([1 => ['priority' => 101, 'callsign_slug' => '23451']]); + $standC2 = Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'C2']); + $standC2->airlines()->sync([1 => ['priority' => 101, 'callsign_slug' => '23451']]); + + // Should be ranked 4th, 5th, 6th, 7th, less specific callsign slugs + $standC3 = Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'C3']); + $standC3->airlines()->sync([1 => ['priority' => 101, 'callsign_slug' => '2345']]); + $standC4 = Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'C4']); + $standC4->airlines()->sync([1 => ['priority' => 101, 'callsign_slug' => '234']]); + $standC5 = Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'C5']); + $standC5->airlines()->sync([1 => ['priority' => 101, 'callsign_slug' => '23']]); + $standC6 = Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'C6']); + $standC6->airlines()->sync([1 => ['priority' => 101, 'callsign_slug' => '2']]); + + // Should not appear in rankings - wrong airfield + $standD1 = Stand::factory()->create(['airfield_id' => 2, 'identifier' => 'D1']); + $standD1->airlines()->sync([1 => ['callsign_slug' => '23451']]); + + // Should not appear in rankings - wrong callsign + $standE1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'E1', + ] + ); + $standE1->airlines()->sync([1 => ['callsign_slug' => 'XYZ']]); + + // Should not appear in rankings - no callsign + $standE2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'E2', + ] + ); + $standE2->airlines()->sync([1]); + + // Should not appear in rankings - too small ARC + $standF1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'F1', + 'aerodrome_reference_code' => 'A' + ] + ); + $standF1->airlines()->sync([1 => ['callsign_slug' => '23451']]); + + // Should not appear in rankings - too small max aircraft size + $standG1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'G1', + 'max_aircraft_id_length' => $cessna->id, + 'max_aircraft_id_wingspan' => $cessna->id + ] + ); + $standG1->airlines()->sync([1 => ['callsign_slug' => '23451']]); + + // Should not appear in rankings - closed + $standH1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'H1', + 'aerodrome_reference_code' => 'C', + 'closed_at' => Carbon::now(), + ] + ); + $standH1->airlines()->sync([1 => ['callsign_slug' => '23451']]); + + $expectedRanks = [ + $standA1->id => 1, + $standB1->id => 2, + $standB2->id => 2, + $standC1->id => 3, + $standC2->id => 3, + $standC3->id => 4, + $standC4->id => 5, + $standC5->id => 6, + $standC6->id => 7, + ]; + + $actualRanks = $this->allocator->getRankedStandAllocation( + $this->newAircraft('BAW23451', $airfield->code, 'EGGD') + )->mapWithKeys( + fn($stand) => [$stand->id => $stand->rank] + ) + ->toArray(); + + $this->assertEquals($expectedRanks, $actualRanks); + } + private function createAircraft( string $callsign, string $arrivalAirport, - string $departureAirport + string $departureAirport, + string $aircraftType = 'B738' + ): NetworkAircraft { + return tap( + $this->newAircraft($callsign, $arrivalAirport, $departureAirport, $aircraftType), + fn(NetworkAircraft $aircraft) => $aircraft->save() + ); + } + + private function newAircraft( + string $callsign, + string $arrivalAirport, + string $departureAirport, + string $aircraftType = 'B738' ): NetworkAircraft { - return NetworkAircraft::create( + return new NetworkAircraft( [ 'callsign' => $callsign, 'cid' => 1234, - 'planned_aircraft' => 'B738', - 'planned_aircraft_short' => 'B738', + 'planned_aircraft' => $aircraftType, + 'planned_aircraft_short' => $aircraftType, 'planned_destairport' => $arrivalAirport, 'planned_depairport' => $departureAirport, + 'aircraft_id' => $aircraftType === 'B738' ? 1 : null, + 'airline_id' => match ($callsign) { + 'BAW23451' => 1, + 'EZY7823' => Airline::where('icao_code', 'EZY')->first()->id, + default => null, + }, ] ); } diff --git a/tests/app/Allocator/Stand/AirlineCallsignSlugTerminalArrivalStandAllocatorTest.php b/tests/app/Allocator/Stand/AirlineCallsignSlugTerminalArrivalStandAllocatorTest.php index 6b0451cf9..7641fe9f2 100644 --- a/tests/app/Allocator/Stand/AirlineCallsignSlugTerminalArrivalStandAllocatorTest.php +++ b/tests/app/Allocator/Stand/AirlineCallsignSlugTerminalArrivalStandAllocatorTest.php @@ -4,9 +4,14 @@ use App\BaseFunctionalTestCase; use App\Models\Aircraft\Aircraft; +use App\Models\Airfield\Airfield; use App\Models\Airfield\Terminal; +use App\Models\Airline\Airline; use App\Models\Stand\Stand; +use App\Models\Stand\StandRequest; +use App\Models\Stand\StandReservation; use App\Models\Vatsim\NetworkAircraft; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; class AirlineCallsignSlugTerminalArrivalStandAllocatorTest extends BaseFunctionalTestCase @@ -17,6 +22,7 @@ public function setUp(): void { parent::setUp(); $this->allocator = $this->app->make(AirlineCallsignSlugTerminalArrivalStandAllocator::class); + Airline::factory()->create(['icao_code' => 'EZY']); } public function testItAllocatesAStandWithAFixedCallsignSlug() @@ -501,19 +507,268 @@ public function testItDoesntAllocateNonExistentAirlines() $this->assertNull($this->allocator->allocate($aircraft)); } + public function testItDoesntRankStandsIfUnknownAircraftType() + { + $aircraft = $this->newAircraft('BAW23451', 'EGLL', 'EGGD', aircraftType: 'XXX'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItDoesntRankStandsIfUnknownAirline() + { + $aircraft = $this->newAircraft('***1234', 'EGLL', 'EGGD'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItGetsRankedStandAllocation() + { + // Create an airfield that we dont have so we know its a clean test + $airfield = Airfield::factory()->create(['code' => 'EXXX']); + $airfieldId = $airfield->id; + + // Create a small aircraft type to test stand size ranking + $cessna = Aircraft::create( + [ + 'code' => 'C172', + 'allocate_stands' => true, + 'aerodrome_reference_code' => 'A', + 'wingspan' => 1, + 'length' => 12, + ] + ); + + // Should be ranked first - it has the highest priority. Both stands on the terminal should be + // included. Stand A1 gets a reservation and a request so that we show its not considered. + $terminalA1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalA1->airlines()->sync([1 => ['callsign_slug' => '23451', 'priority' => 100]]); + $standA1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalA1->id, + 'identifier' => 'A1', + ] + ); + $standA2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalA1->id, + 'identifier' => 'A2', + ] + ); + StandReservation::create( + [ + 'stand_id' => $standA1->id, + 'start' => Carbon::now()->subMinutes(1), + 'end' => Carbon::now()->addMinutes(1), + ] + ); + StandRequest::factory()->create(['requested_time' => Carbon::now(), 'stand_id' => $standA1->id]); + + // Should be ranked joint second, lower priority than A1. + $terminalB1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalB1->airlines()->sync([1 => ['callsign_slug' => '23451', 'priority' => 101]]); + $standB1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalB1->id, + 'identifier' => 'B1', + 'aerodrome_reference_code' => 'C' + ] + ); + + $terminalB2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalB2->airlines()->sync([1 => ['callsign_slug' => '23451', 'priority' => 101]]); + $standB2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalB2->id, + 'identifier' => 'B2', + 'aerodrome_reference_code' => 'C' + ] + ); + + // Should be ranked joint third, same priority as B1 and B2 but smaller stands + $terminalC1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalC1->airlines()->sync([1 => ['callsign_slug' => '23451', 'priority' => 101]]); + $standC1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C1', + 'terminal_id' => $terminalC1->id, + ] + ); + + $terminalC2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalC2->airlines()->sync([1 => ['callsign_slug' => '23451', 'priority' => 101]]); + $standC2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C2', + 'terminal_id' => $terminalC2->id, + ] + ); + + // Should be ranked 4th, 5th, 6th, 7th, less specific callsign slugs + $terminalC3 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalC3->airlines()->sync([1 => ['callsign_slug' => '2345', 'priority' => 101]]); + $standC3 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C3', + 'terminal_id' => $terminalC3->id, + ] + ); + + $terminalC4 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalC4->airlines()->sync([1 => ['callsign_slug' => '234', 'priority' => 101]]); + $standC4 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C4', + 'terminal_id' => $terminalC4->id, + ] + ); + + $terminalC5 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalC5->airlines()->sync([1 => ['callsign_slug' => '23', 'priority' => 101]]); + $standC5 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C5', + 'terminal_id' => $terminalC5->id, + ] + ); + + $terminalC6 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalC6->airlines()->sync([1 => ['callsign_slug' => '2', 'priority' => 101]]); + $standC6 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C6', + 'terminal_id' => $terminalC6->id, + ] + ); + + // Should not appear in rankings - wrong airfield + Terminal::find(1)->airlines()->sync([1 => ['callsign_slug' => '23451', 'priority' => 101]]); + Stand::factory()->create( + [ + 'airfield_id' => 1, + 'identifier' => 'D1', + 'terminal_id' => 1 + ] + ); + + // Should not appear in rankings - wrong terminal + $terminalD2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'D1', + 'terminal_id' => $terminalD2->id + ] + ); + + // Should not appear in rankings - wrong callsign_slug + $terminalE1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalE1->airlines()->sync([1 => ['callsign_slug' => 'xxxx']]); + Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'E1']); + + // Should not appear in rankings - no wrong callsign_slug + $terminalE2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalE2->airlines()->sync([1]); + Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'E2']); + + // Should not appear in rankings - too small ARC + $terminalF1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalF1->airlines()->sync([1 => ['callsign_slug' => '23451']]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'F1', + 'aerodrome_reference_code' => 'A' + ] + ); + + // Should not appear in rankings - too small max aircraft size + $terminalG1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalG1->airlines()->sync([1 => ['callsign_slug' => '23451']]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'G1', + 'max_aircraft_id_length' => $cessna->id, + 'max_aircraft_id_wingspan' => $cessna->id + ] + ); + + + // Should not appear in rankings - closed + $terminalH1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalH1->airlines()->sync([1 => ['callsign_slug' => '23451']]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'H1', + 'closed_at' => Carbon::now() + ] + ); + + + $expectedRanks = [ + $standA1->id => 1, + $standA2->id => 1, + $standB1->id => 2, + $standB2->id => 2, + $standC1->id => 3, + $standC2->id => 3, + $standC3->id => 4, + $standC4->id => 5, + $standC5->id => 6, + $standC6->id => 7, + ]; + + $actualRanks = $this->allocator->getRankedStandAllocation( + $this->newAircraft('BAW23451', $airfield->code, 'EGGD') + )->mapWithKeys( + fn($stand) => [$stand->id => $stand->rank] + ) + ->toArray(); + + $this->assertEquals($expectedRanks, $actualRanks); + } + private function createAircraft( string $callsign, string $arrivalAirport, - string $departureAirport + string $departureAirport, + string $aircraftType = 'B738' + ): NetworkAircraft { + return tap( + $this->newAircraft($callsign, $arrivalAirport, $departureAirport, $aircraftType), + fn(NetworkAircraft $aircraft) => $aircraft->save() + ); + } + + private function newAircraft( + string $callsign, + string $arrivalAirport, + string $departureAirport, + string $aircraftType = 'B738' ): NetworkAircraft { - return NetworkAircraft::create( + return new NetworkAircraft( [ 'callsign' => $callsign, 'cid' => 1234, - 'planned_aircraft' => 'B738', - 'planned_aircraft_short' => 'B738', + 'planned_aircraft' => $aircraftType, + 'planned_aircraft_short' => $aircraftType, 'planned_destairport' => $arrivalAirport, 'planned_depairport' => $departureAirport, + 'aircraft_id' => $aircraftType === 'B738' ? 1 : null, + 'airline_id' => match ($callsign) { + 'BAW23451' => 1, + 'EZY7823' => Airline::where('icao_code', 'EZY')->first()->id, + default => null, + }, ] ); } diff --git a/tests/app/Allocator/Stand/AirlineCallsignTerminalArrivalStandAllocatorTest.php b/tests/app/Allocator/Stand/AirlineCallsignTerminalArrivalStandAllocatorTest.php index 4bb3748e5..711c14efa 100644 --- a/tests/app/Allocator/Stand/AirlineCallsignTerminalArrivalStandAllocatorTest.php +++ b/tests/app/Allocator/Stand/AirlineCallsignTerminalArrivalStandAllocatorTest.php @@ -4,9 +4,14 @@ use App\BaseFunctionalTestCase; use App\Models\Aircraft\Aircraft; +use App\Models\Airfield\Airfield; use App\Models\Airfield\Terminal; +use App\Models\Airline\Airline; use App\Models\Stand\Stand; +use App\Models\Stand\StandRequest; +use App\Models\Stand\StandReservation; use App\Models\Vatsim\NetworkAircraft; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; class AirlineCallsignTerminalArrivalStandAllocatorTest extends BaseFunctionalTestCase @@ -17,6 +22,7 @@ public function setUp(): void { parent::setUp(); $this->allocator = $this->app->make(AirlineCallsignTerminalArrivalStandAllocator::class); + Airline::factory()->create(['icao_code' => 'EZY']); } public function testItAllocatesAStandWithAFixedCallsign() @@ -354,19 +360,223 @@ public function testItDoesntAllocateNonExistentAirlines() $this->assertNull($this->allocator->allocate($aircraft)); } + public function testItDoesntRankStandsIfUnknownAircraftType() + { + $aircraft = $this->newAircraft('BAW23451', 'EGLL', 'EGGD', aircraftType: 'XXX'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItDoesntRankStandsIfUnknownAirline() + { + $aircraft = $this->newAircraft('***1234', 'EGLL', 'EGGD'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItGetsRankedStandAllocation() + { + // Create an airfield that we dont have so we know its a clean test + $airfield = Airfield::factory()->create(['code' => 'EXXX']); + $airfieldId = $airfield->id; + + // Create a small aircraft type to test stand size ranking + $cessna = Aircraft::create( + [ + 'code' => 'C172', + 'allocate_stands' => true, + 'aerodrome_reference_code' => 'A', + 'wingspan' => 1, + 'length' => 12, + ] + ); + + // Should be ranked first - it has the highest priority. Both stands on the terminal should be + // included. Stand A1 gets a reservation and a request so that we show its not considered. + $terminalA1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalA1->airlines()->sync([1 => ['full_callsign' => '23451', 'priority' => 100]]); + $standA1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalA1->id, + 'identifier' => 'A1', + ] + ); + $standA2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalA1->id, + 'identifier' => 'A2', + ] + ); + StandReservation::create( + [ + 'stand_id' => $standA1->id, + 'start' => Carbon::now()->subMinutes(1), + 'end' => Carbon::now()->addMinutes(1), + ] + ); + StandRequest::factory()->create(['requested_time' => Carbon::now(), 'stand_id' => $standA1->id]); + + // Should be ranked joint second, lower priority than A1. + $terminalB1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalB1->airlines()->sync([1 => ['full_callsign' => '23451', 'priority' => 101]]); + $standB1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalB1->id, + 'identifier' => 'B1', + 'aerodrome_reference_code' => 'C' + ] + ); + + $terminalB2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalB2->airlines()->sync([1 => ['full_callsign' => '23451', 'priority' => 101]]); + $standB2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalB2->id, + 'identifier' => 'B2', + 'aerodrome_reference_code' => 'C' + ] + ); + + // Should be ranked joint third, same priority as B1 and B2 but smaller stands + $terminalC1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalC1->airlines()->sync([1 => ['full_callsign' => '23451', 'priority' => 101]]); + $standC1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C1', + 'terminal_id' => $terminalC1->id, + ] + ); + + $terminalC2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalC2->airlines()->sync([1 => ['full_callsign' => '23451', 'priority' => 101]]); + $standC2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C2', + 'terminal_id' => $terminalC2->id, + ] + ); + + // Should not appear in rankings - wrong airfield + Terminal::find(1)->airlines()->sync([1 => ['full_callsign' => '23451', 'priority' => 101]]); + Stand::factory()->create( + [ + 'airfield_id' => 1, + 'identifier' => 'D1', + 'terminal_id' => 1 + ] + ); + + // Should not appear in rankings - wrong terminal + $terminalD2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'D1', + 'terminal_id' => $terminalD2->id + ] + ); + + // Should not appear in rankings - wrong full_callsign + $terminalE1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalE1->airlines()->sync([1 => ['full_callsign' => 'xxxx']]); + Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'E1']); + + // Should not appear in rankings - no callsig + $terminalE2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalE2->airlines()->sync([1]); + Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'E2']); + + // Should not appear in rankings - too small ARC + $terminalF1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalF1->airlines()->sync([1 => ['full_callsign' => '23451']]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'F1', + 'aerodrome_reference_code' => 'A' + ] + ); + + // Should not appear in rankings - too small max aircraft size + $terminalG1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalG1->airlines()->sync([1 => ['full_callsign' => '23451']]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'G1', + 'max_aircraft_id_length' => $cessna->id, + 'max_aircraft_id_wingspan' => $cessna->id + ] + ); + + + // Should not appear in rankings - closed + $terminalH1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalH1->airlines()->sync([1 => ['full_callsign' => '23451']]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'H1', + 'closed_at' => Carbon::now() + ] + ); + + + $expectedRanks = [ + $standA1->id => 1, + $standA2->id => 1, + $standB1->id => 2, + $standB2->id => 2, + $standC1->id => 3, + $standC2->id => 3, + ]; + + $actualRanks = $this->allocator->getRankedStandAllocation( + $this->newAircraft('BAW23451', $airfield->code, 'EGGD') + )->mapWithKeys( + fn($stand) => [$stand->id => $stand->rank] + ) + ->toArray(); + + $this->assertEquals($expectedRanks, $actualRanks); + } + private function createAircraft( string $callsign, string $arrivalAirport, - string $departureAirport + string $departureAirport, + string $aircraftType = 'B738' + ): NetworkAircraft { + return tap( + $this->newAircraft($callsign, $arrivalAirport, $departureAirport, $aircraftType), + fn(NetworkAircraft $aircraft) => $aircraft->save() + ); + } + + private function newAircraft( + string $callsign, + string $arrivalAirport, + string $departureAirport, + string $aircraftType = 'B738' ): NetworkAircraft { - return NetworkAircraft::create( + return new NetworkAircraft( [ 'callsign' => $callsign, 'cid' => 1234, - 'planned_aircraft' => 'B738', - 'planned_aircraft_short' => 'B738', + 'planned_aircraft' => $aircraftType, + 'planned_aircraft_short' => $aircraftType, 'planned_destairport' => $arrivalAirport, 'planned_depairport' => $departureAirport, + 'aircraft_id' => $aircraftType === 'B738' ? 1 : null, + 'airline_id' => match ($callsign) { + 'BAW23451' => 1, + 'EZY7823' => Airline::where('icao_code', 'EZY')->first()->id, + default => null, + }, ] ); } diff --git a/tests/app/Allocator/Stand/AirlineDestinationArrivalStandAllocatorTest.php b/tests/app/Allocator/Stand/AirlineDestinationArrivalStandAllocatorTest.php index 161bdc6f4..06ee0255d 100644 --- a/tests/app/Allocator/Stand/AirlineDestinationArrivalStandAllocatorTest.php +++ b/tests/app/Allocator/Stand/AirlineDestinationArrivalStandAllocatorTest.php @@ -4,21 +4,24 @@ use App\BaseFunctionalTestCase; use App\Models\Aircraft\Aircraft; +use App\Models\Airfield\Airfield; +use App\Models\Airline\Airline; use App\Models\Stand\Stand; +use App\Models\Stand\StandRequest; +use App\Models\Stand\StandReservation; use App\Models\Vatsim\NetworkAircraft; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; class AirlineDestinationArrivalStandAllocatorTest extends BaseFunctionalTestCase { - /** - * @var AirlineArrivalStandAllocator - */ - private $allocator; + private readonly AirlineDestinationArrivalStandAllocator $allocator; public function setUp(): void { parent::setUp(); $this->allocator = $this->app->make(AirlineDestinationArrivalStandAllocator::class); + Airline::factory()->create(['icao_code' => 'EZY']); } public function testItAllocatesAStandWithAFixedDestination() @@ -480,20 +483,191 @@ public function testItDoesntAllocateNonExistentAirlines() $this->assertNull($this->allocator->allocate($aircraft)); } + public function testItDoesntRankStandsIfUnknownAirline() + { + $aircraft = $this->newAircraft('***1234', 'EGLL', 'EGGD'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItDoesntRankStandsIfUnknownAircraft() + { + $aircraft = $this->newAircraft('BAW1234', 'EGLL', 'EGGD', 'C172'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItGetsRankedStandAllocation() + { + // Create an airfield that we dont have so we know its a clean test + $airfield = Airfield::factory()->create(['code' => 'EXXX']); + $airfieldId = $airfield->id; + + // Create a small aircraft type to test stand size ranking + $cessna = Aircraft::create( + [ + 'code' => 'C172', + 'allocate_stands' => true, + 'aerodrome_reference_code' => 'A', + 'wingspan' => 1, + 'length' => 12, + ] + ); + + // Should be ranked first - it has the highest priority. It gets a stand reservation to make + // sure it is ranked first even if it is occupied. + $standA1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'A1', + ] + ); + $standA1->airlines()->sync([1 => ['priority' => 100, 'destination' => 'EGGD']]); + StandReservation::create( + [ + 'stand_id' => $standA1->id, + 'start' => Carbon::now()->subMinutes(1), + 'end' => Carbon::now()->addMinutes(1), + ] + ); + + // Should be ranked joint second, lower priority than A1 + $standB1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B1', + 'aerodrome_reference_code' => 'C' + ] + ); + StandRequest::factory()->create(['requested_time' => Carbon::now(), 'stand_id' => $standB1->id]); + $standB2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B2', + 'aerodrome_reference_code' => 'C' + ] + ); + $standB1->airlines()->sync([1 => ['priority' => 101, 'destination' => 'EGGD']]); + $standB2->airlines()->sync([1 => ['priority' => 101, 'destination' => 'EGGD']]); + + // Should be ranked joint third, same priority as lB1 and B2 but smaller stands + $standC1 = Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'C1']); + $standC1->airlines()->sync([1 => ['priority' => 101, 'destination' => 'EGGD']]); + $standC2 = Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'C2']); + $standC2->airlines()->sync([1 => ['priority' => 101, 'destination' => 'EGGD']]); + + // Should be ranked 4th, 5th, 6th less specific destinations + $standC3 = Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'C3']); + $standC3->airlines()->sync([1 => ['priority' => 101, 'destination' => 'EGG']]); + $standC4 = Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'C4']); + $standC4->airlines()->sync([1 => ['priority' => 101, 'destination' => 'EG']]); + $standC5 = Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'C5']); + $standC5->airlines()->sync([1 => ['priority' => 101, 'destination' => 'E']]); + + // Should not appear in rankings - wrong airfield + $standD1 = Stand::factory()->create(['airfield_id' => 2, 'identifier' => 'D1']); + $standD1->airlines()->sync([1 => ['destination' => 'EGGD']]); + + // Should not appear in rankings - wrong destination + $standE1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'E1', + ] + ); + $standE1->airlines()->sync([1 => ['destination' => 'XYZ']]); + + // Should not appear in rankings - no destination + $standE2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'E2', + ] + ); + $standE2->airlines()->sync([1]); + + // Should not appear in rankings - too small ARC + $standF1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'F1', + 'aerodrome_reference_code' => 'A' + ] + ); + $standF1->airlines()->sync([1 => ['destination' => 'EGGD']]); + + // Should not appear in rankings - too small max aircraft size + $standG1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'G1', + 'max_aircraft_id_length' => $cessna->id, + 'max_aircraft_id_wingspan' => $cessna->id + ] + ); + $standG1->airlines()->sync([1 => ['destination' => 'EGGD']]); + + // Should not appear in rankings - closed + $standH1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'H1', + 'aerodrome_reference_code' => 'C', + 'closed_at' => Carbon::now(), + ] + ); + $standH1->airlines()->sync([1 => ['destination' => 'EGGD']]); + + $expectedRanks = [ + $standA1->id => 1, + $standB1->id => 2, + $standB2->id => 2, + $standC1->id => 3, + $standC2->id => 3, + $standC3->id => 4, + $standC4->id => 5, + $standC5->id => 6, + ]; + + $actualRanks = $this->allocator->getRankedStandAllocation( + $this->newAircraft('BAW23451', $airfield->code, 'EGGD') + )->mapWithKeys( + fn($stand) => [$stand->id => $stand->rank] + ) + ->toArray(); + + $this->assertEquals($expectedRanks, $actualRanks); + } private function createAircraft( string $callsign, string $arrivalAirport, - string $departureAirport - ): NetworkAircraft - { - return NetworkAircraft::create( + string $departureAirport, + string $aircraftType = 'B738' + ): NetworkAircraft { + return tap( + $this->newAircraft($callsign, $arrivalAirport, $departureAirport, $aircraftType), + fn(NetworkAircraft $aircraft) => $aircraft->save() + ); + } + + private function newAircraft( + string $callsign, + string $arrivalAirport, + string $departureAirport, + string $aircraftType = 'B738' + ): NetworkAircraft { + return new NetworkAircraft( [ 'callsign' => $callsign, 'cid' => 1234, - 'planned_aircraft' => 'B738', - 'planned_aircraft_short' => 'B738', + 'planned_aircraft' => $aircraftType, + 'planned_aircraft_short' => $aircraftType, 'planned_destairport' => $arrivalAirport, 'planned_depairport' => $departureAirport, + 'aircraft_id' => $aircraftType === 'B738' ? 1 : null, + 'airline_id' => match ($callsign) { + 'BAW23451' => 1, + 'EZY7823' => Airline::where('icao_code', 'EZY')->first()->id, + default => null, + }, ] ); } diff --git a/tests/app/Allocator/Stand/AirlineDestinationTerminalArrivalStandAllocatorTest.php b/tests/app/Allocator/Stand/AirlineDestinationTerminalArrivalStandAllocatorTest.php index 7bd9c45e3..031a42f26 100644 --- a/tests/app/Allocator/Stand/AirlineDestinationTerminalArrivalStandAllocatorTest.php +++ b/tests/app/Allocator/Stand/AirlineDestinationTerminalArrivalStandAllocatorTest.php @@ -4,10 +4,16 @@ use App\BaseFunctionalTestCase; use App\Models\Aircraft\Aircraft; +use App\Models\Airfield\Airfield; use App\Models\Airfield\Terminal; +use App\Models\Airline\Airline; use App\Models\Stand\Stand; +use App\Models\Stand\StandRequest; +use App\Models\Stand\StandReservation; use App\Models\Vatsim\NetworkAircraft; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; class AirlineDestinationTerminalArrivalStandAllocatorTest extends BaseFunctionalTestCase { @@ -17,6 +23,7 @@ public function setUp(): void { parent::setUp(); $this->allocator = $this->app->make(AirlineDestinationTerminalArrivalStandAllocator::class); + Airline::factory()->create(['icao_code' => 'EZY']); } public function testItAllocatesAStandWithAFixedCallsignSlug() @@ -501,19 +508,257 @@ public function testItDoesntAllocateNonExistentAirlines() $this->assertNull($this->allocator->allocate($aircraft)); } + public function testItDoesntRankStandsIfUnknownAircraftType() + { + $aircraft = $this->newAircraft('BAW23451', 'EGLL', 'EGGD', aircraftType: 'XXX'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItDoesntRankStandsIfUnknownAirline() + { + $aircraft = $this->newAircraft('***1234', 'EGLL', 'EGGD'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItGetsRankedStandAllocation() + { + // Create an airfield that we dont have so we know its a clean test + $airfield = Airfield::factory()->create(['code' => 'EXXX']); + $airfieldId = $airfield->id; + + // Create a small aircraft type to test stand size ranking + $cessna = Aircraft::create( + [ + 'code' => 'C172', + 'allocate_stands' => true, + 'aerodrome_reference_code' => 'A', + 'wingspan' => 1, + 'length' => 12, + ] + ); + + // Should be ranked first - it has the highest priority. Both stands on the terminal should be + // included. Stand A1 gets a reservation and a request so that we show its not considered. + $terminalA1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalA1->airlines()->sync([1 => ['destination' => 'EGGD', 'priority' => 100]]); + $standA1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalA1->id, + 'identifier' => 'A1', + ] + ); + $standA2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalA1->id, + 'identifier' => 'A2', + ] + ); + StandReservation::create( + [ + 'stand_id' => $standA1->id, + 'start' => Carbon::now()->subMinutes(1), + 'end' => Carbon::now()->addMinutes(1), + ] + ); + StandRequest::factory()->create(['requested_time' => Carbon::now(), 'stand_id' => $standA1->id]); + + // Should be ranked joint second, lower priority than A1. + $terminalB1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalB1->airlines()->sync([1 => ['destination' => 'EGGD', 'priority' => 101]]); + $standB1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalB1->id, + 'identifier' => 'B1', + 'aerodrome_reference_code' => 'C' + ] + ); + + $terminalB2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalB2->airlines()->sync([1 => ['destination' => 'EGGD', 'priority' => 101]]); + $standB2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalB2->id, + 'identifier' => 'B2', + 'aerodrome_reference_code' => 'C' + ] + ); + + // Should be ranked joint third, same priority as B1 and B2 but smaller stands + $terminalC1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalC1->airlines()->sync([1 => ['destination' => 'EGGD', 'priority' => 101]]); + $standC1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C1', + 'terminal_id' => $terminalC1->id, + ] + ); + + $terminalC2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalC2->airlines()->sync([1 => ['destination' => 'EGGD', 'priority' => 101]]); + $standC2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C2', + 'terminal_id' => $terminalC2->id, + ] + ); + + // Should be ranked 4th, 5th, 6th, 7th, less specific destinations + $terminalC3 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalC3->airlines()->sync([1 => ['destination' => 'EGG', 'priority' => 101]]); + $standC3 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C3', + 'terminal_id' => $terminalC3->id, + ] + ); + + $terminalC4 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalC4->airlines()->sync([1 => ['destination' => 'EG', 'priority' => 101]]); + $standC4 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C4', + 'terminal_id' => $terminalC4->id, + ] + ); + + $terminalC5 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalC5->airlines()->sync([1 => ['destination' => 'E', 'priority' => 101]]); + $standC5 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C5', + 'terminal_id' => $terminalC5->id, + ] + ); + + // Should not appear in rankings - wrong airfield + Terminal::find(1)->airlines()->sync([1 => ['destination' => 'EGGD', 'priority' => 101]]); + Stand::factory()->create( + [ + 'airfield_id' => 1, + 'identifier' => 'D1', + 'terminal_id' => 1 + ] + ); + + // Should not appear in rankings - wrong terminal + $terminalD2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'D1', + 'terminal_id' => $terminalD2->id + ] + ); + + // Should not appear in rankings - wrong destination + $terminalE1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalE1->airlines()->sync([1 => ['destination' => 'xxxx']]); + Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'E1']); + + // Should not appear in rankings - no destination + $terminalE2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalE2->airlines()->sync([1]); + Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'E2']); + + // Should not appear in rankings - too small ARC + $terminalF1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalF1->airlines()->sync([1 => ['destination' => 'EGGD']]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'F1', + 'aerodrome_reference_code' => 'A' + ] + ); + + // Should not appear in rankings - too small max aircraft size + $terminalG1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalG1->airlines()->sync([1 => ['destination' => 'EGGD']]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'G1', + 'max_aircraft_id_length' => $cessna->id, + 'max_aircraft_id_wingspan' => $cessna->id + ] + ); + + + // Should not appear in rankings - closed + $terminalH1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalH1->airlines()->sync([1 => ['destination' => 'EGGD']]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'H1', + 'closed_at' => Carbon::now() + ] + ); + + + $expectedRanks = [ + $standA1->id => 1, + $standA2->id => 1, + $standB1->id => 2, + $standB2->id => 2, + $standC1->id => 3, + $standC2->id => 3, + $standC3->id => 4, + $standC4->id => 5, + $standC5->id => 6, + ]; + + $actualRanks = $this->allocator->getRankedStandAllocation( + $this->newAircraft('BAW23451', $airfield->code, 'EGGD') + )->mapWithKeys( + fn($stand) => [$stand->id => $stand->rank] + ) + ->toArray(); + + $this->assertEquals($expectedRanks, $actualRanks); + } + private function createAircraft( string $callsign, string $arrivalAirport, - string $departureAirport + string $departureAirport, + string $aircraftType = 'B738' + ): NetworkAircraft { + return tap( + $this->newAircraft($callsign, $arrivalAirport, $departureAirport, $aircraftType), + fn(NetworkAircraft $aircraft) => $aircraft->save() + ); + } + + private function newAircraft( + string $callsign, + string $arrivalAirport, + string $departureAirport, + string $aircraftType = 'B738' ): NetworkAircraft { - return NetworkAircraft::create( + return new NetworkAircraft( [ 'callsign' => $callsign, 'cid' => 1234, - 'planned_aircraft' => 'B738', - 'planned_aircraft_short' => 'B738', + 'planned_aircraft' => $aircraftType, + 'planned_aircraft_short' => $aircraftType, 'planned_destairport' => $arrivalAirport, 'planned_depairport' => $departureAirport, + 'aircraft_id' => $aircraftType === 'B738' ? 1 : null, + 'airline_id' => match ($callsign) { + 'BAW23451' => 1, + 'EZY7823' => Airline::where('icao_code', 'EZY')->first()->id, + default => null, + }, ] ); } diff --git a/tests/app/Allocator/Stand/AirlineArrivalStandAllocatorTest.php b/tests/app/Allocator/Stand/AirlineGeneralArrivalStandAllocatorTest.php similarity index 57% rename from tests/app/Allocator/Stand/AirlineArrivalStandAllocatorTest.php rename to tests/app/Allocator/Stand/AirlineGeneralArrivalStandAllocatorTest.php index de7b4ff6d..01eb48ba9 100644 --- a/tests/app/Allocator/Stand/AirlineArrivalStandAllocatorTest.php +++ b/tests/app/Allocator/Stand/AirlineGeneralArrivalStandAllocatorTest.php @@ -4,18 +4,24 @@ use App\BaseFunctionalTestCase; use App\Models\Aircraft\Aircraft; +use App\Models\Airfield\Airfield; +use App\Models\Airline\Airline; use App\Models\Stand\Stand; +use App\Models\Stand\StandRequest; +use App\Models\Stand\StandReservation; use App\Models\Vatsim\NetworkAircraft; +use Carbon\Carbon; use Illuminate\Support\Facades\DB; -class AirlineArrivalStandAllocatorTest extends BaseFunctionalTestCase +class AirlineGeneralArrivalStandAllocatorTest extends BaseFunctionalTestCase { - private AirlineArrivalStandAllocator $allocator; + private AirlineGeneralArrivalStandAllocator $allocator; public function setUp(): void { parent::setUp(); - $this->allocator = $this->app->make(AirlineArrivalStandAllocator::class); + $this->allocator = $this->app->make(AirlineGeneralArrivalStandAllocator::class); + Airline::factory()->create(['icao_code' => 'EZY']); } public function testItAllocatesAStandForTheAirline() @@ -317,15 +323,195 @@ public function testItDoesntAllocateNonExistentAirlines() $this->assertNull($this->allocator->allocate($aircraft)); } - private function createAircraft(string $callsign, string $arrivalAirport): NetworkAircraft + public function testItDoesntRankStandsIfUnknownAirline() { - return NetworkAircraft::create( + $aircraft = $this->newAircraft('***1234', 'EGLL', 'EGGD'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItGetsRankedStandAllocation() + { + // Create an airfield that we dont have so we know its a clean test + $airfield = Airfield::factory()->create(['code' => 'EXXX']); + $airfieldId = $airfield->id; + + // Create a small aircraft type to test stand size ranking + $cessna = Aircraft::create( + [ + 'code' => 'C172', + 'allocate_stands' => true, + 'aerodrome_reference_code' => 'A', + 'wingspan' => 1, + 'length' => 12, + ] + ); + + // Should be ranked first - it has the highest priority. It gets a stand reservation to make + // sure it is ranked first even if it is occupied. + $standA1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'A1', + ] + ); + $standA1->airlines()->sync([1 => ['priority' => 100]]); + StandReservation::create( + [ + 'stand_id' => $standA1->id, + 'start' => Carbon::now()->subMinutes(1), + 'end' => Carbon::now()->addMinutes(1), + ] + ); + + // Should be ranked joint second, lower priority than A1. Stand B1 gets a request. + $standB1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B1', + 'aerodrome_reference_code' => 'C' + ] + ); + StandRequest::factory()->create(['requested_time' => Carbon::now(), 'stand_id' => $standB1->id]); + $standB2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B2', + 'aerodrome_reference_code' => 'C' + ] + ); + $standB1->airlines()->sync([1 => ['priority' => 101]]); + $standB2->airlines()->sync([1 => ['priority' => 101]]); + + // Should be ranked joint third, same priority as B1 and B2 but smaller stands + $standC1 = Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'C1']); + $standC1->airlines()->sync([1 => ['priority' => 101]]); + $standC2 = Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'C2']); + $standC2->airlines()->sync([1 => ['priority' => 101]]); + + // Should not appear in rankings - wrong airfield + $standD1 = Stand::factory()->create(['airfield_id' => 2, 'identifier' => 'D1']); + $standD1->airlines()->sync([1]); + + // Should not appear in rankings - has a specific aircraft type + $standE1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'E1', + ] + ); + $standE1->airlines()->sync([1 => ['aircraft_id' => 1]]); + + // Should not appear in rankings - has a specific destination + $standE2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'E2', + ] + ); + $standE2->airlines()->sync([1 => ['destination' => 'abc']]); + + // Should not appear in rankings - has a specific callsign + $standE3 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'E3', + ] + ); + $standE3->airlines()->sync([1 => ['full_callsign' => 'abc']]); + + // Should not appear in rankings - has a specific callsign slug + $standE4 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'E4', + ] + ); + $standE4->airlines()->sync([1 => ['callsign_slug' => 'abc']]); + + // Should not appear in rankings - too small ARC + $standF1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'F1', + 'aerodrome_reference_code' => 'A' + ] + ); + $standF1->airlines()->sync([1]); + + // Should not appear in rankings - too small max aircraft size + $standG1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'G1', + 'max_aircraft_id_length' => $cessna->id, + 'max_aircraft_id_wingspan' => $cessna->id + ] + ); + $standG1->airlines()->sync([1]); + + // Should not appear in rankings - closed + $standH1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'H1', + 'aerodrome_reference_code' => 'C', + 'closed_at' => Carbon::now(), + ] + ); + $standH1->airlines()->sync([1]); + + $expectedRanks = [ + $standA1->id => 1, + $standB1->id => 2, + $standB2->id => 2, + $standC1->id => 3, + $standC2->id => 3, + ]; + + $actualRanks = $this->allocator->getRankedStandAllocation( + $this->newAircraft('BAW23451', $airfield->code) + )->mapWithKeys( + fn($stand) => [$stand->id => $stand->rank] + ) + ->toArray(); + + $this->assertEquals($expectedRanks, $actualRanks); + } + + private function createAircraft( + string $callsign, + string $arrivalAirport, + string $aircraftType = 'B738' + ): NetworkAircraft { + return tap( + $this->newAircraft($callsign, $arrivalAirport, $aircraftType), + fn(NetworkAircraft $aircraft) => $aircraft->save() + ); + } + + private function newAircraft( + string $callsign, + string $arrivalAirport, + string $aircraftType = 'B738' + ): NetworkAircraft { + return new NetworkAircraft( [ 'callsign' => $callsign, 'cid' => 1234, - 'planned_aircraft' => 'B738', - 'planned_aircraft_short' => 'B738', - 'planned_destairport' => $arrivalAirport] + 'planned_aircraft' => $aircraftType, + 'planned_aircraft_short' => $aircraftType, + 'planned_destairport' => $arrivalAirport, + 'planned_depairport' => 'EGGD', + 'airline_id' => match ($callsign) { + 'BAW23451' => 1, + 'EZY7823' => Airline::where('icao_code', 'EZY')->first()->id, + default => null, + }, + 'aircraft_id' => match ($aircraftType) { + 'B738' => 1, + default => null, + }, + ] ); } } diff --git a/tests/app/Allocator/Stand/AirlineGeneralTerminalArrivalStandAllocatorTest.php b/tests/app/Allocator/Stand/AirlineGeneralTerminalArrivalStandAllocatorTest.php new file mode 100644 index 000000000..f37275dbd --- /dev/null +++ b/tests/app/Allocator/Stand/AirlineGeneralTerminalArrivalStandAllocatorTest.php @@ -0,0 +1,461 @@ +allocator = $this->app->make(AirlineGeneralTerminalArrivalStandAllocator::class); + Airline::where('icao_code', 'BAW')->first()->terminals()->attach(2); + Stand::find(1)->update(['terminal_id' => 1]); + Stand::find(2)->update(['terminal_id' => 2]); + Airline::factory()->create(['icao_code' => 'EZY']); + } + + public function testItAllocatesAStandAtTheRightTerminal() + { + $aircraft = $this->createAircraft('BAW23451', 'EGLL'); + $this->assertEquals(2, $this->allocator->allocate($aircraft)); + } + + public function testItDoesntAssignTerminalsWithSpecificDestinations() + { + Stand::query()->update(['terminal_id' => null]); + $terminal1 = Terminal::factory()->create(['airfield_id' => 1]); + Stand::factory()->withTerminal($terminal1)->create(['airfield_id' => 1, 'identifier' => '1A']); + DB::table('airline_terminal')->insert( + [ + [ + 'airline_id' => 1, + 'terminal_id' => $terminal1->id, + 'destination' => 'EGFF', + ], + ] + ); + + $aircraft = $this->createAircraft('BAW23451', 'EGLL'); + $this->assertNull($this->allocator->allocate($aircraft)); + } + + public function testItDoesntAssignTerminalsWithSpecificCallsignSlugs() + { + Stand::query()->update(['terminal_id' => null]); + $terminal1 = Terminal::factory()->create(['airfield_id' => 1]); + Stand::factory()->withTerminal($terminal1)->create(['airfield_id' => 1, 'identifier' => '1A']); + DB::table('airline_terminal')->insert( + [ + [ + 'airline_id' => 1, + 'terminal_id' => $terminal1->id, + 'callsign_slug' => '333', + ], + ] + ); + + $aircraft = $this->createAircraft('BAW23451', 'EGLL'); + $this->assertNull($this->allocator->allocate($aircraft)); + } + + public function testItAssignsStandsWithSpecificFullCallsigns() + { + Stand::query()->update(['terminal_id' => null]); + $terminal1 = Terminal::factory()->create(['airfield_id' => 1]); + Stand::factory()->withTerminal($terminal1)->create(['airfield_id' => 1, 'identifier' => '1A']); + DB::table('airline_terminal')->insert( + [ + [ + 'airline_id' => 1, + 'terminal_id' => $terminal1->id, + 'full_callsign' => '333', + ], + ] + ); + + $aircraft = $this->createAircraft('BAW23451', 'EGLL'); + $this->assertNull($this->allocator->allocate($aircraft)); + } + + public function testItDoesntAssignTerminalsWithSpecificAircraftTypes() + { + Stand::query()->update(['terminal_id' => null]); + $terminal1 = Terminal::factory()->create(['airfield_id' => 1]); + Stand::factory()->withTerminal($terminal1)->create(['airfield_id' => 1, 'identifier' => '1A']); + DB::table('airline_terminal')->insert( + [ + [ + 'airline_id' => 1, + 'terminal_id' => $terminal1->id, + 'aircraft_id' => 1, + ], + ] + ); + + $aircraft = $this->createAircraft('BAW23451', 'EGLL'); + $this->assertNull($this->allocator->allocate($aircraft)); + } + + + public function testItAPrefersStandsWithNoSpecificCallsignSlugs() + { + Stand::query()->update(['terminal_id' => null]); + $terminal1 = Terminal::factory()->create(['airfield_id' => 1]); + $stand1 = Stand::factory()->withTerminal($terminal1)->create(['airfield_id' => 1, 'identifier' => '1B']); + $terminal2 = Terminal::factory()->create(['airfield_id' => 1]); + Stand::factory()->withTerminal($terminal2)->create(['airfield_id' => 1, 'identifier' => '1A']); + + DB::table('airline_terminal')->insert( + [ + [ + 'airline_id' => 1, + 'terminal_id' => $terminal2->id, + 'callsign_slug' => '333', + ], + [ + 'airline_id' => 1, + 'terminal_id' => $terminal1->id, + 'callsign_slug' => null, + ], + ] + ); + + $aircraft = $this->createAircraft('BAW23451', 'EGLL'); + $this->assertEquals($stand1->id, $this->allocator->allocate($aircraft)); + } + + public function testItAllocatesStandsInAerodromeReferenceAscendingOrder() + { + Aircraft::where('code', 'B738')->update(['aerodrome_reference_code' => 'B']); + $weightAppropriateStand = Stand::create( + [ + 'airfield_id' => 1, + 'identifier' => '502', + 'latitude' => 54.65875500, + 'longitude' => -6.22258694, + 'aerodrome_reference_code' => 'B', + 'terminal_id' => 2, + ] + ); + + $aircraft = $this->createAircraft('BAW23451', 'EGLL'); + $this->assertEquals($weightAppropriateStand->id, $this->allocator->allocate($aircraft)); + } + + public function testItAllocatesStandsAtAppropriateReferenceCode() + { + Aircraft::where('code', 'B738')->update(['aerodrome_reference_code' => 'E']); + $weightAppropriateStand = Stand::create( + [ + 'airfield_id' => 1, + 'terminal_id' => 2, + 'identifier' => '502', + 'latitude' => 54.65875500, + 'longitude' => -6.22258694, + 'aerodrome_reference_code' => 'E', + ] + ); + + $aircraft = $this->createAircraft('BAW23451', 'EGLL'); + $this->assertEquals($weightAppropriateStand->id, $this->allocator->allocate($aircraft)); + } + + public function testItDoesntAllocateOccupiedStands() + { + $extraStand = Stand::create( + [ + 'airfield_id' => 1, + 'identifier' => '502', + 'latitude' => 54.65875500, + 'longitude' => -6.22258694, + 'aerodrome_reference_code' => 'E', + 'terminal_id' => 2, + ] + ); + + $occupier = $this->createAircraft('EZY7823', 'EGLL'); + $occupier->occupiedStand()->sync([2]); + $aircraft = $this->createAircraft('BAW23451', 'EGLL'); + + $this->assertEquals($extraStand->id, $this->allocator->allocate($aircraft)); + } + + public function testItDoesntAllocateUnavailableStands() + { + $extraStand = Stand::create( + [ + 'airfield_id' => 1, + 'identifier' => '502', + 'latitude' => 54.65875500, + 'longitude' => -6.22258694, + 'aerodrome_reference_code' => 'E', + 'terminal_id' => 2, + ] + ); + NetworkAircraft::find('BAW123')->occupiedStand()->sync([2]); + + $aircraft = $this->createAircraft('BAW23451', 'EGLL'); + $this->assertEquals($extraStand->id, $this->allocator->allocate($aircraft)); + } + + public function testItDoesntAllocateNonExistentAirlines() + { + $aircraft = $this->createAircraft('***1234', 'EGLL'); + $this->assertNull($this->allocator->allocate($aircraft)); + } + + public function testItReturnsNullOnNoStandAllocated() + { + Stand::all()->each(function (Stand $stand) + { + $stand->delete(); + }); + $aircraft = $this->createAircraft('BAW999', 'EGLL'); + $this->assertNull($this->allocator->allocate($aircraft)); + } + + public function testItDoesntRankStandsIfUnknownAircraftType() + { + $aircraft = $this->newAircraft('BAW23451', 'EGLL', 'EGGD', aircraftType: 'XXX'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItDoesntRankStandsIfUnknownAirline() + { + $aircraft = $this->newAircraft('***1234', 'EGLL', 'EGGD'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItGetsRankedStandAllocation() + { + // Create an airfield that we dont have so we know its a clean test + $airfield = Airfield::factory()->create(['code' => 'EXXX']); + $airfieldId = $airfield->id; + + // Create a small aircraft type to test stand size ranking + $cessna = Aircraft::create( + [ + 'code' => 'C172', + 'allocate_stands' => true, + 'aerodrome_reference_code' => 'A', + 'wingspan' => 1, + 'length' => 12, + ] + ); + + // Should be ranked first - it has the highest priority. Both stands on the terminal should be + // included. Stand A1 gets a reservation and a request so that we show its not considered. + $terminalA1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalA1->airlines()->sync([1 => ['priority' => 100]]); + $standA1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalA1->id, + 'identifier' => 'A1', + ] + ); + $standA2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalA1->id, + 'identifier' => 'A2', + ] + ); + StandReservation::create( + [ + 'stand_id' => $standA1->id, + 'start' => Carbon::now()->subMinutes(1), + 'end' => Carbon::now()->addMinutes(1), + ] + ); + StandRequest::factory()->create(['requested_time' => Carbon::now(), 'stand_id' => $standA1->id]); + + // Should be ranked joint second, lower priority than A1. + $terminalB1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalB1->airlines()->sync([1 => ['priority' => 101]]); + $standB1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalB1->id, + 'identifier' => 'B1', + 'aerodrome_reference_code' => 'C' + ] + ); + + $terminalB2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalB2->airlines()->sync([1 => ['priority' => 101]]); + $standB2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'terminal_id' => $terminalB2->id, + 'identifier' => 'B2', + 'aerodrome_reference_code' => 'C' + ] + ); + + // Should be ranked joint third, same priority as B1 and B2 but smaller stands + $terminalC1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalC1->airlines()->sync([1 => ['priority' => 101]]); + $standC1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C1', + 'terminal_id' => $terminalC1->id, + ] + ); + + $terminalC2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalC2->airlines()->sync([1 => ['priority' => 101]]); + $standC2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C2', + 'terminal_id' => $terminalC2->id, + ] + ); + + // Should not appear in rankings - wrong airfield + Terminal::find(1)->airlines()->sync([1 => ['priority' => 101]]); + Stand::factory()->create( + [ + 'airfield_id' => 1, + 'identifier' => 'D1', + 'terminal_id' => 1 + ] + ); + + // Should not appear in rankings - wrong terminal + $terminalD2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'D1', + 'terminal_id' => $terminalD2->id + ] + ); + + // Should not appear in rankings - has a full callsign + $terminalE1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalE1->airlines()->sync([1 => ['full_callsign' => 'xxxx']]); + Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'E1']); + + // Should not appear in rankings - has a callsign_slug + $terminalE2 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalE2->airlines()->sync([1 => ['callsign_slug' => 'xxxx']]); + Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'E2']); + + // Should not appear in rankings - has a specific aircraft_type + $terminalE3 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalE3->airlines()->sync([1 => ['aircraft_id' => 1]]); + Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'E3']); + + // Should not appear in rankings - has a specific destination + $terminalE4 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalE4->airlines()->sync([1 => ['destination' => 'ABC']]); + Stand::factory()->create(['airfield_id' => $airfieldId, 'identifier' => 'E4']); + + // Should not appear in rankings - too small ARC + $terminalF1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalF1->airlines()->sync([1 => ['full_callsign' => '23451']]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'F1', + 'aerodrome_reference_code' => 'A' + ] + ); + + // Should not appear in rankings - too small max aircraft size + $terminalG1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalG1->airlines()->sync([1 => ['full_callsign' => '23451']]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'G1', + 'max_aircraft_id_length' => $cessna->id, + 'max_aircraft_id_wingspan' => $cessna->id + ] + ); + + + // Should not appear in rankings - closed + $terminalH1 = Terminal::factory()->create(['airfield_id' => $airfieldId]); + $terminalH1->airlines()->sync([1 => ['full_callsign' => '23451']]); + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'H1', + 'closed_at' => Carbon::now() + ] + ); + + + $expectedRanks = [ + $standA1->id => 1, + $standA2->id => 1, + $standB1->id => 2, + $standB2->id => 2, + $standC1->id => 3, + $standC2->id => 3, + ]; + + $actualRanks = $this->allocator->getRankedStandAllocation( + $this->newAircraft('BAW23451', $airfield->code, 'EGGD') + )->mapWithKeys( + fn($stand) => [$stand->id => $stand->rank] + ) + ->toArray(); + + $this->assertEquals($expectedRanks, $actualRanks); + } + + private function createAircraft( + string $callsign, + string $arrivalAirport, + string $departureAirport = 'EGGD', + string $aircraftType = 'B738' + ): NetworkAircraft { + return tap( + $this->newAircraft($callsign, $arrivalAirport, $departureAirport, $aircraftType), + fn(NetworkAircraft $aircraft) => $aircraft->save() + ); + } + + private function newAircraft( + string $callsign, + string $arrivalAirport, + string $departureAirport, + string $aircraftType = 'B738' + ): NetworkAircraft { + return new NetworkAircraft( + [ + 'callsign' => $callsign, + 'cid' => 1234, + 'planned_aircraft' => $aircraftType, + 'planned_aircraft_short' => $aircraftType, + 'planned_destairport' => $arrivalAirport, + 'planned_depairport' => $departureAirport, + 'aircraft_id' => $aircraftType === 'B738' ? 1 : null, + 'airline_id' => match ($callsign) { + 'BAW23451' => 1, + 'EZY7823' => Airline::where('icao_code', 'EZY')->first()->id, + default => null, + }, + ] + ); + } +} diff --git a/tests/app/Allocator/Stand/AirlineTerminalArrivalStandAllocatorTest.php b/tests/app/Allocator/Stand/AirlineTerminalArrivalStandAllocatorTest.php deleted file mode 100644 index dc0abca00..000000000 --- a/tests/app/Allocator/Stand/AirlineTerminalArrivalStandAllocatorTest.php +++ /dev/null @@ -1,244 +0,0 @@ -allocator = $this->app->make(AirlineTerminalArrivalStandAllocator::class); - Airline::where('icao_code', 'BAW')->first()->terminals()->attach(2); - Stand::find(1)->update(['terminal_id' => 1]); - Stand::find(2)->update(['terminal_id' => 2]); - } - - public function testItAllocatesAStandAtTheRightTerminal() - { - $aircraft = $this->createAircraft('BAW23451', 'EGLL'); - $this->assertEquals(2, $this->allocator->allocate($aircraft)); - } - - public function testItDoesntAssignTerminalsWithSpecificDestinations() - { - Stand::query()->update(['terminal_id' => null]); - $terminal1 = Terminal::factory()->create(['airfield_id' => 1]); - Stand::factory()->withTerminal($terminal1)->create(['airfield_id' => 1, 'identifier' => '1A']); - DB::table('airline_terminal')->insert( - [ - [ - 'airline_id' => 1, - 'terminal_id' => $terminal1->id, - 'destination' => 'EGFF', - ], - ] - ); - - $aircraft = $this->createAircraft('BAW23451', 'EGLL'); - $this->assertNull($this->allocator->allocate($aircraft)); - } - - public function testItDoesntAssignTerminalsWithSpecificCallsignSlugs() - { - Stand::query()->update(['terminal_id' => null]); - $terminal1 = Terminal::factory()->create(['airfield_id' => 1]); - Stand::factory()->withTerminal($terminal1)->create(['airfield_id' => 1, 'identifier' => '1A']); - DB::table('airline_terminal')->insert( - [ - [ - 'airline_id' => 1, - 'terminal_id' => $terminal1->id, - 'callsign_slug' => '333', - ], - ] - ); - - $aircraft = $this->createAircraft('BAW23451', 'EGLL'); - $this->assertNull($this->allocator->allocate($aircraft)); - } - - public function testItAssignsStandsWithSpecificFullCallsigns() - { - Stand::query()->update(['terminal_id' => null]); - $terminal1 = Terminal::factory()->create(['airfield_id' => 1]); - Stand::factory()->withTerminal($terminal1)->create(['airfield_id' => 1, 'identifier' => '1A']); - DB::table('airline_terminal')->insert( - [ - [ - 'airline_id' => 1, - 'terminal_id' => $terminal1->id, - 'full_callsign' => '333', - ], - ] - ); - - $aircraft = $this->createAircraft('BAW23451', 'EGLL'); - $this->assertNull($this->allocator->allocate($aircraft)); - } - - public function testItDoesntAssignTerminalsWithSpecificAircraftTypes() - { - Stand::query()->update(['terminal_id' => null]); - $terminal1 = Terminal::factory()->create(['airfield_id' => 1]); - Stand::factory()->withTerminal($terminal1)->create(['airfield_id' => 1, 'identifier' => '1A']); - DB::table('airline_terminal')->insert( - [ - [ - 'airline_id' => 1, - 'terminal_id' => $terminal1->id, - 'aircraft_id' => 1, - ], - ] - ); - - $aircraft = $this->createAircraft('BAW23451', 'EGLL'); - $this->assertNull($this->allocator->allocate($aircraft)); - } - - - public function testItAPrefersStandsWithNoSpecificCallsignSlugs() - { - Stand::query()->update(['terminal_id' => null]); - $terminal1 = Terminal::factory()->create(['airfield_id' => 1]); - $stand1 = Stand::factory()->withTerminal($terminal1)->create(['airfield_id' => 1, 'identifier' => '1B']); - $terminal2 = Terminal::factory()->create(['airfield_id' => 1]); - Stand::factory()->withTerminal($terminal2)->create(['airfield_id' => 1, 'identifier' => '1A']); - - DB::table('airline_terminal')->insert( - [ - [ - 'airline_id' => 1, - 'terminal_id' => $terminal2->id, - 'callsign_slug' => '333', - ], - [ - 'airline_id' => 1, - 'terminal_id' => $terminal1->id, - 'callsign_slug' => null, - ], - ] - ); - - $aircraft = $this->createAircraft('BAW23451', 'EGLL'); - $this->assertEquals($stand1->id, $this->allocator->allocate($aircraft)); - } - - public function testItAllocatesStandsInAerodromeReferenceAscendingOrder() - { - Aircraft::where('code', 'B738')->update(['aerodrome_reference_code' => 'B']); - $weightAppropriateStand = Stand::create( - [ - 'airfield_id' => 1, - 'identifier' => '502', - 'latitude' => 54.65875500, - 'longitude' => -6.22258694, - 'aerodrome_reference_code' => 'B', - 'terminal_id' => 2, - ] - ); - - $aircraft = $this->createAircraft('BAW23451', 'EGLL'); - $this->assertEquals($weightAppropriateStand->id, $this->allocator->allocate($aircraft)); - } - - public function testItAllocatesStandsAtAppropriateReferenceCode() - { - Aircraft::where('code', 'B738')->update(['aerodrome_reference_code' => 'E']); - $weightAppropriateStand = Stand::create( - [ - 'airfield_id' => 1, - 'terminal_id' => 2, - 'identifier' => '502', - 'latitude' => 54.65875500, - 'longitude' => -6.22258694, - 'aerodrome_reference_code' => 'E', - ] - ); - - $aircraft = $this->createAircraft('BAW23451', 'EGLL'); - $this->assertEquals($weightAppropriateStand->id, $this->allocator->allocate($aircraft)); - } - - public function testItDoesntAllocateOccupiedStands() - { - $extraStand = Stand::create( - [ - 'airfield_id' => 1, - 'identifier' => '502', - 'latitude' => 54.65875500, - 'longitude' => -6.22258694, - 'aerodrome_reference_code' => 'E', - 'terminal_id' => 2, - ] - ); - - $occupier = $this->createAircraft('EZY7823', 'EGLL'); - $occupier->occupiedStand()->sync([2]); - $aircraft = $this->createAircraft('BAW23451', 'EGLL'); - - $this->assertEquals($extraStand->id, $this->allocator->allocate($aircraft)); - } - - public function testItDoesntAllocateUnavailableStands() - { - $extraStand = Stand::create( - [ - 'airfield_id' => 1, - 'identifier' => '502', - 'latitude' => 54.65875500, - 'longitude' => -6.22258694, - 'aerodrome_reference_code' => 'E', - 'terminal_id' => 2, - ] - ); - NetworkAircraft::find('BAW123')->occupiedStand()->sync([2]); - - $aircraft = $this->createAircraft('BAW23451', 'EGLL'); - $this->assertEquals($extraStand->id, $this->allocator->allocate($aircraft)); - } - - public function testItDoesntAllocateNonExistentAirlines() - { - $aircraft = $this->createAircraft('***1234', 'EGLL'); - $this->assertNull($this->allocator->allocate($aircraft)); - } - - public function testItReturnsNullOnNoStandAllocated() - { - Stand::all()->each(function (Stand $stand) { - $stand->delete(); - }); - $aircraft = $this->createAircraft('BAW999', 'EGLL'); - $this->assertNull($this->allocator->allocate($aircraft)); - } - - private function createAircraft( - string $callsign, - string $arrivalAirport, - string $departureAirport = 'EGGD' - ): NetworkAircraft { - return NetworkAircraft::create( - [ - 'callsign' => $callsign, - 'cid' => 1234, - 'planned_aircraft' => 'B738', - 'planned_aircraft_short' => 'B738', - 'planned_destairport' => $arrivalAirport, - 'planned_depairport' => $departureAirport, - ] - ); - } -} diff --git a/tests/app/Allocator/Stand/CargoAirlineFallbackStandAllocatorTest.php b/tests/app/Allocator/Stand/CargoAirlineFallbackStandAllocatorTest.php index 1e93a14e6..233fd81ba 100644 --- a/tests/app/Allocator/Stand/CargoAirlineFallbackStandAllocatorTest.php +++ b/tests/app/Allocator/Stand/CargoAirlineFallbackStandAllocatorTest.php @@ -4,11 +4,15 @@ use App\BaseFunctionalTestCase; use App\Models\Aircraft\Aircraft; +use App\Models\Airfield\Airfield; use App\Models\Airline\Airline; use App\Models\Stand\Stand; use App\Models\Stand\StandAssignment; +use App\Models\Stand\StandRequest; +use App\Models\Stand\StandReservation; use App\Models\Stand\StandType; use App\Models\Vatsim\NetworkAircraft; +use Illuminate\Support\Carbon; class CargoAirlineFallbackStandAllocatorTest extends BaseFunctionalTestCase { @@ -115,17 +119,182 @@ public function testItDoesntAllocateCargoStandsIfNoAirline() $this->assertNull($allocation); } + public function testItDoesntRankStandsIfUnknownAircraft() + { + Airline::where('icao_code', 'VIR')->update(['is_cargo' => true]); + $aircraft = $this->newAircraft('VIR22F', 'EGLL', 'EGGD', 'C172'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItGetsRankedStandAllocation() + { + // Make Virgin a cargo airline + Airline::where('icao_code', 'VIR')->update(['is_cargo' => true]); + + // Create an airfield that we dont have so we know its a clean test + $airfield = Airfield::factory()->create(['code' => 'EXXX']); + $airfieldId = $airfield->id; + + // Create a small aircraft type to test stand size ranking + $cessna = Aircraft::create( + [ + 'code' => 'C172', + 'allocate_stands' => true, + 'aerodrome_reference_code' => 'A', + 'wingspan' => 0.5, + 'length' => 0.6, + ] + ); + + // Should be ranked first - its the smallest stand that's applicable + $standA1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'A1', + 'assignment_priority' => 100, + 'aerodrome_reference_code' => 'E', + 'type_id' => 3, + ] + ); + StandReservation::create( + [ + 'stand_id' => $standA1->id, + 'start' => Carbon::now()->subMinutes(1), + 'end' => Carbon::now()->addMinutes(1), + ] + ); + + // Should be ranked joint second, bigger than A1, but same priority + $standB1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B1', + 'assignment_priority' => 100, + 'type_id' => 3, + ] + ); + StandRequest::factory()->create(['requested_time' => Carbon::now(), 'stand_id' => $standB1->id]); + $standB2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B2', + 'assignment_priority' => 100, + 'type_id' => 3, + ] + ); + + // Should be ranked joint third, same size as B1 and B2, but lower priority + $standC1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C1', + 'assignment_priority' => 101, + 'type_id' => 3, + ] + ); + $standC2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C2', + 'assignment_priority' => 101, + 'type_id' => 3, + ] + ); + + // Should not appear in rankings - wrong airfield + Stand::factory()->create(['airfield_id' => 2, 'identifier' => 'D1', 'type_id' => 3]); + + // Should not appear in rankings - not cargo + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'E1', + 'type_id' => 2, + ] + ); + + // Should not appear in rankings - too small ARC + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'F1', + 'aerodrome_reference_code' => 'A', + 'type_id' => 3, + ] + ); + + // Should not appear in rankings - too small max aircraft size + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'G1', + 'max_aircraft_id_length' => $cessna->id, + 'max_aircraft_id_wingspan' => $cessna->id, + 'type_id' => 3, + ] + ); + + // Should not appear in rankings - closed + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'H1', + 'aerodrome_reference_code' => 'E', + 'closed_at' => Carbon::now(), + 'type_id' => 3, + ] + ); + + $expectedRanks = [ + $standA1->id => 1, + $standB1->id => 2, + $standB2->id => 2, + $standC1->id => 3, + $standC2->id => 3, + ]; + + $actualRanks = $this->allocator->getRankedStandAllocation( + $this->newAircraft('VIR22F', $airfield->code, 'EGGD') + )->mapWithKeys( + fn($stand) => [$stand->id => $stand->rank] + ) + ->toArray(); + + $this->assertEquals($expectedRanks, $actualRanks); + } private function createAircraft( string $callsign, - string $arrivalAirport + string $arrivalAirport, + string $departureAirport = 'EGGD', + string $aircraftType = 'B744' + ): NetworkAircraft { + return tap( + $this->newAircraft($callsign, $arrivalAirport, $departureAirport, $aircraftType), + fn(NetworkAircraft $aircraft) => $aircraft->save() + ); + } + + private function newAircraft( + string $callsign, + string $arrivalAirport, + string $departureAirport = 'EGGD', + string $aircraftType = 'B744' ): NetworkAircraft { - return NetworkAircraft::create( + return new NetworkAircraft( [ 'callsign' => $callsign, 'cid' => 1234, - 'planned_aircraft' => 'B744', - 'planned_aircraft_short' => 'B744', + 'planned_aircraft' => $aircraftType, + 'planned_aircraft_short' => $aircraftType, 'planned_destairport' => $arrivalAirport, + 'planned_depairport' => $departureAirport, + 'aircraft_id' => Aircraft::where('code', $aircraftType)->first()?->id, + 'airline_id' => match ($callsign) { + 'BAW23451' => 1, + 'EZY7823' => Airline::where('icao_code', 'EZY')->first()->id, + 'VIR22F' => Airline::where('icao_code', 'VIR')->first()->id, + default => null, + }, ] ); } diff --git a/tests/app/Allocator/Stand/CargoFlightArrivalStandAllocatorTest.php b/tests/app/Allocator/Stand/CargoFlightArrivalStandAllocatorTest.php index ff2f5859b..57b421f10 100644 --- a/tests/app/Allocator/Stand/CargoFlightArrivalStandAllocatorTest.php +++ b/tests/app/Allocator/Stand/CargoFlightArrivalStandAllocatorTest.php @@ -4,10 +4,15 @@ use App\BaseFunctionalTestCase; use App\Models\Aircraft\Aircraft; +use App\Models\Airfield\Airfield; +use App\Models\Airline\Airline; use App\Models\Stand\Stand; use App\Models\Stand\StandAssignment; +use App\Models\Stand\StandRequest; +use App\Models\Stand\StandReservation; use App\Models\Stand\StandType; use App\Models\Vatsim\NetworkAircraft; +use Illuminate\Support\Carbon; class CargoFlightArrivalStandAllocatorTest extends BaseFunctionalTestCase { @@ -114,17 +119,172 @@ public function testItDoesntAllocateCargoStandsIfFlightplanNotCargo() $this->assertNull($allocation); } + public function testItDoesntRankStandsIfUnknownAircraft() + { + $aircraft = $this->newAircraft('BAW1234', 'EGLL', 'C172'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItGetsRankedStandAllocation() + { + // Create an airfield that we dont have so we know its a clean test + $airfield = Airfield::factory()->create(['code' => 'EXXX']); + $airfieldId = $airfield->id; + + // Create a small aircraft type to test stand size ranking + $cessna = Aircraft::create( + [ + 'code' => 'C172', + 'allocate_stands' => true, + 'aerodrome_reference_code' => 'A', + 'wingspan' => 0.5, + 'length' => 0.6, + ] + ); + + // Should be ranked first - its the smallest stand that's applicable + $standA1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'A1', + 'assignment_priority' => 100, + 'aerodrome_reference_code' => 'E', + 'type_id' => 3, + ] + ); + StandReservation::create( + [ + 'stand_id' => $standA1->id, + 'start' => Carbon::now()->subMinutes(1), + 'end' => Carbon::now()->addMinutes(1), + ] + ); + + // Should be ranked joint second, bigger than A1, but same priority + $standB1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B1', + 'assignment_priority' => 100, + 'type_id' => 3, + ] + ); + StandRequest::factory()->create(['requested_time' => Carbon::now(), 'stand_id' => $standB1->id]); + $standB2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B2', + 'assignment_priority' => 100, + 'type_id' => 3, + ] + ); + + // Should be ranked joint third, same size as B1 and B2, but lower priority + $standC1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C1', + 'assignment_priority' => 101, + 'type_id' => 3, + ] + ); + $standC2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C2', + 'assignment_priority' => 101, + 'type_id' => 3, + ] + ); + + // Should not appear in rankings - wrong airfield + Stand::factory()->create(['airfield_id' => 2, 'identifier' => 'D1', 'type_id' => 3]); + + // Should not appear in rankings - not cargo + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'E1', + 'type_id' => 2, + ] + ); + + // Should not appear in rankings - too small ARC + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'F1', + 'aerodrome_reference_code' => 'A', + 'type_id' => 3, + ] + ); + + // Should not appear in rankings - too small max aircraft size + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'G1', + 'max_aircraft_id_length' => $cessna->id, + 'max_aircraft_id_wingspan' => $cessna->id, + 'type_id' => 3, + ] + ); + + // Should not appear in rankings - closed + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'H1', + 'aerodrome_reference_code' => 'E', + 'closed_at' => Carbon::now(), + 'type_id' => 3, + ] + ); + + $expectedRanks = [ + $standA1->id => 1, + $standB1->id => 2, + $standB2->id => 2, + $standC1->id => 3, + $standC2->id => 3, + ]; + + $aircraft = $this->newAircraft('VIR22F', $airfield->code); + $aircraft->remarks = 'Some stuff RMK/CARGO Some more stuff'; + $actualRanks = $this->allocator->getRankedStandAllocation( + $aircraft + )->mapWithKeys( + fn($stand) => [$stand->id => $stand->rank] + ) + ->toArray(); + + $this->assertEquals($expectedRanks, $actualRanks); + } private function createAircraft( string $callsign, - string $arrivalAirport + string $arrivalAirport, + string $aircraftType = 'B744' + ): NetworkAircraft { + return tap( + $this->newAircraft($callsign, $arrivalAirport, $aircraftType), + fn(NetworkAircraft $aircraft) => $aircraft->save() + ); + } + + private function newAircraft( + string $callsign, + string $arrivalAirport, + string $aircraftType = 'B744' ): NetworkAircraft { - return NetworkAircraft::create( + return new NetworkAircraft( [ 'callsign' => $callsign, 'cid' => 1234, - 'planned_aircraft' => 'B744', - 'planned_aircraft_short' => 'B744', + 'planned_aircraft' => $aircraftType, + 'planned_aircraft_short' => $aircraftType, 'planned_destairport' => $arrivalAirport, + 'airline_id' => Airline::where('icao_code', 'VIR')->first()->id, + 'aircraft_id' => Aircraft::where('code', $aircraftType)->first()?->id, ] ); } diff --git a/tests/app/Allocator/Stand/CargoFlightPreferredArrivalStandAllocatorTest.php b/tests/app/Allocator/Stand/CargoFlightPreferredArrivalStandAllocatorTest.php index a5660f33c..1824b4aa6 100644 --- a/tests/app/Allocator/Stand/CargoFlightPreferredArrivalStandAllocatorTest.php +++ b/tests/app/Allocator/Stand/CargoFlightPreferredArrivalStandAllocatorTest.php @@ -4,12 +4,17 @@ use App\BaseFunctionalTestCase; use App\Models\Aircraft\Aircraft; +use App\Models\Airfield\Airfield; use App\Models\Airline\Airline; use App\Models\Stand\Stand; use App\Models\Stand\StandAssignment; +use App\Models\Stand\StandRequest; +use App\Models\Stand\StandReservation; use App\Models\Stand\StandType; use App\Models\Vatsim\NetworkAircraft; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; class CargoFlightPreferredArrivalStandAllocatorTest extends BaseFunctionalTestCase { @@ -102,7 +107,7 @@ public function testItReturnsNothingIfNoStandsToAllocated() public function testItDoesntAllocateOccupiedStands() { - StandAssignment::create( + StandAssignment::create( [ 'callsign' => 'BAW123', 'stand_id' => $this->cargoStand->id @@ -126,17 +131,187 @@ public function testItDoesntAllocateCargoStandsIfNoAirline() $this->assertNull($allocation); } + public function testItDoesntRankStandsIfUnknownAircraft() + { + $aircraft = $this->newAircraft('BAW1234', 'EGLL', 'C172'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItDoesntRankStandsIfUnknownAirline() + { + $aircraft = $this->newAircraft('XXX123', 'EGLL'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItGetsRankedStandAllocation() + { + // Create an airfield that we dont have so we know its a clean test + $airfield = Airfield::factory()->create(['code' => 'EXXX']); + $airfieldId = $airfield->id; + + // Create a small aircraft type to test stand size ranking + $cessna = Aircraft::create( + [ + 'code' => 'C172', + 'allocate_stands' => true, + 'aerodrome_reference_code' => 'A', + 'wingspan' => 0.5, + 'length' => 0.6, + ] + ); + + // Should be ranked first - its the smallest stand that's applicable + $standA1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'A1', + 'assignment_priority' => 100, + 'aerodrome_reference_code' => 'E', + 'type_id' => 3, + ] + ); + $standA1->airlines()->sync([Airline::where('icao_code', 'VIR')->first()->id => ['priority' => 100]]); + StandReservation::create( + [ + 'stand_id' => $standA1->id, + 'start' => Carbon::now()->subMinutes(1), + 'end' => Carbon::now()->addMinutes(1), + ] + ); + + // Should be ranked joint second, bigger than A1, but same priority + $standB1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B1', + 'assignment_priority' => 100, + 'type_id' => 3, + ] + ); + $standB1->airlines()->sync([Airline::where('icao_code', 'VIR')->first()->id => ['priority' => 100]]); + StandRequest::factory()->create(['requested_time' => Carbon::now(), 'stand_id' => $standB1->id]); + $standB2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B2', + 'assignment_priority' => 100, + 'type_id' => 3, + ] + ); + $standB2->airlines()->sync([Airline::where('icao_code', 'VIR')->first()->id => ['priority' => 100]]); + + // Should be ranked joint third, same size as B1 and B2, but lower priority + $standC1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C1', + 'assignment_priority' => 101, + 'type_id' => 3, + ] + ); + $standC2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C2', + 'assignment_priority' => 101, + 'type_id' => 3, + ] + ); + $standC1->airlines()->sync([Airline::where('icao_code', 'VIR')->first()->id => ['priority' => 101]]); + $standC2->airlines()->sync([Airline::where('icao_code', 'VIR')->first()->id => ['priority' => 101]]); + + // Should not appear in rankings - wrong airfield + $standD1 = Stand::factory()->create(['airfield_id' => 2, 'identifier' => 'D1', 'type_id' => 3]); + $standD1->airlines()->sync([Airline::where('icao_code', 'VIR')->first()->id => ['priority' => 100]]); + + // Should not appear in rankings - not cargo + $standE1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'E1', + 'type_id' => 2, + ] + ); + $standE1->airlines()->sync([Airline::where('icao_code', 'VIR')->first()->id => ['priority' => 100]]); + + // Should not appear in rankings - too small ARC + $standF1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'F1', + 'aerodrome_reference_code' => 'A', + 'type_id' => 3, + ] + ); + $standF1->airlines()->sync([Airline::where('icao_code', 'VIR')->first()->id => ['priority' => 100]]); + + // Should not appear in rankings - too small max aircraft size + $standG1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'G1', + 'max_aircraft_id_length' => $cessna->id, + 'max_aircraft_id_wingspan' => $cessna->id, + 'type_id' => 3, + ] + ); + $standG1->airlines()->sync([Airline::where('icao_code', 'VIR')->first()->id => ['priority' => 100]]); + + // Should not appear in rankings - closed + $standH1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'H1', + 'aerodrome_reference_code' => 'E', + 'closed_at' => Carbon::now(), + 'type_id' => 3, + ] + ); + $standH1->airlines()->sync([Airline::where('icao_code', 'VIR')->first()->id => ['priority' => 100]]); + + $expectedRanks = [ + $standA1->id => 1, + $standB1->id => 2, + $standB2->id => 2, + $standC1->id => 3, + $standC2->id => 3, + ]; + + $actualRanks = $this->allocator->getRankedStandAllocation( + $this->newAircraft('VIR22F', $airfield->code) + )->mapWithKeys( + fn($stand) => [$stand->id => $stand->rank] + ) + ->toArray(); + + $this->assertEquals($expectedRanks, $actualRanks); + } + private function createAircraft( string $callsign, - string $arrivalAirport + string $arrivalAirport, + string $aircraftType = 'B744' + ): NetworkAircraft { + return tap( + $this->newAircraft($callsign, $arrivalAirport, $aircraftType), + fn(NetworkAircraft $aircraft) => $aircraft->save() + ); + } + + private function newAircraft( + string $callsign, + string $arrivalAirport, + string $aircraftType = 'B744' ): NetworkAircraft { - return NetworkAircraft::create( + return new NetworkAircraft( [ 'callsign' => $callsign, 'cid' => 1234, - 'planned_aircraft' => 'B744', - 'planned_aircraft_short' => 'B744', + 'planned_aircraft' => $aircraftType, + 'planned_aircraft_short' => $aircraftType, 'planned_destairport' => $arrivalAirport, + 'airline_id' => Airline::where('icao_code', Str::substr($callsign, 0, 3))->first()?->id, + 'aircraft_id' => Aircraft::where('code', $aircraftType)->first()?->id, ] ); } diff --git a/tests/app/Allocator/Stand/DomesticInternationalStandAllocatorTest.php b/tests/app/Allocator/Stand/DomesticInternationalStandAllocatorTest.php index 6341aeb93..fc1584169 100644 --- a/tests/app/Allocator/Stand/DomesticInternationalStandAllocatorTest.php +++ b/tests/app/Allocator/Stand/DomesticInternationalStandAllocatorTest.php @@ -4,10 +4,14 @@ use App\BaseFunctionalTestCase; use App\Models\Aircraft\Aircraft; +use App\Models\Airfield\Airfield; use App\Models\Stand\Stand; use App\Models\Stand\StandAssignment; +use App\Models\Stand\StandRequest; +use App\Models\Stand\StandReservation; use App\Models\Stand\StandType; use App\Models\Vatsim\NetworkAircraft; +use Illuminate\Support\Carbon; class DomesticInternationalStandAllocatorTest extends BaseFunctionalTestCase { @@ -167,16 +171,7 @@ public function testItDoesntAllocateTakenStands() public function testItReturnsNothingOnNoDestinationAirport() { - $aircraft = NetworkAircraft::create( - [ - 'callsign' => 'BAW898', - 'cid' => 1234, - 'planned_aircraft' => 'B738', - 'planned_aircraft_short' => 'B738', - 'planned_destairport' => '', - 'planned_depairport' => 'EIDW', - ] - ); + $aircraft = $this->createAircraft('BAW898', 'B738', ''); $this->assertNull($this->allocator->allocate($aircraft)); } @@ -185,13 +180,299 @@ public function testItReturnsNothingOnNoStandAllocated() $this->assertNull($this->allocator->allocate($this->createAircraft('BAW898', 'B738', 'XXXX'))); } + public function testItDoesntRankStandsIfUnknownAircraft() + { + $aircraft = $this->newAircraft('BAW123', 'XXX', 'EGLL', 'EIDW'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItGetsRankedStandAllocationForDomestic() + { + // Create an airfield that we dont have so we know its a clean test + $airfield = Airfield::factory()->create(['code' => 'EXXX']); + $airfieldId = $airfield->id; + + // Create a small aircraft type to test stand size ranking + $cessna = Aircraft::create( + [ + 'code' => 'C172', + 'allocate_stands' => true, + 'aerodrome_reference_code' => 'A', + 'wingspan' => 1, + 'length' => 12, + ] + ); + + // Should be ranked first - its the smallest stand that's applicable + $standA1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'A1', + 'assignment_priority' => 100, + 'aerodrome_reference_code' => 'E', + 'type_id' => 1, + ] + ); + StandReservation::create( + [ + 'stand_id' => $standA1->id, + 'start' => Carbon::now()->subMinutes(1), + 'end' => Carbon::now()->addMinutes(1), + ] + ); + + // Should be ranked joint second, bigger than A1, but same priority + $standB1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B1', + 'assignment_priority' => 100, + 'type_id' => 1, + ] + ); + StandRequest::factory()->create(['requested_time' => Carbon::now(), 'stand_id' => $standB1->id]); + $standB2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B2', + 'assignment_priority' => 100, + 'type_id' => 1, + ] + ); + + // Should be ranked joint third, same size as B1 and B2, but lower priority + $standC1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C1', + 'assignment_priority' => 101, + 'type_id' => 1, + ] + ); + $standC2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C2', + 'assignment_priority' => 101, + 'type_id' => 1, + ] + ); + + // Should not appear in rankings - wrong airfield + Stand::factory()->create(['airfield_id' => 2, 'identifier' => 'D1', 'type_id' => 1]); + + // Should not appear in rankings - not domestic + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'E1', + 'type_id' => 2, + ] + ); + + // Should not appear in rankings - too small ARC + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'F1', + 'aerodrome_reference_code' => 'A', + 'type_id' => 1, + ] + ); + + // Should not appear in rankings - too small max aircraft size + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'G1', + 'max_aircraft_id_length' => $cessna->id, + 'max_aircraft_id_wingspan' => $cessna->id, + 'type_id' => 1, + ] + ); + + // Should not appear in rankings - closed + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'H1', + 'aerodrome_reference_code' => 'E', + 'closed_at' => Carbon::now(), + 'type_id' => 1, + ] + ); + + $expectedRanks = [ + $standA1->id => 1, + $standB1->id => 2, + $standB2->id => 2, + $standC1->id => 3, + $standC2->id => 3, + ]; + + $actualRanks = $this->allocator->getRankedStandAllocation( + $this->newAircraft('VIR22F', 'B738', $airfield->code, 'EGLL') + )->mapWithKeys( + fn($stand) => [$stand->id => $stand->rank] + ) + ->toArray(); + + $this->assertEquals($expectedRanks, $actualRanks); + } + + public function testItGetsRankedStandAllocationForInternational() + { + // Create an airfield that we dont have so we know its a clean test + $airfield = Airfield::factory()->create(['code' => 'EXXX']); + $airfieldId = $airfield->id; + + // Create a small aircraft type to test stand size ranking + $cessna = Aircraft::create( + [ + 'code' => 'C172', + 'allocate_stands' => true, + 'aerodrome_reference_code' => 'A', + 'wingspan' => 1, + 'length' => 12, + ] + ); + + // Should be ranked first - its the smallest stand that's applicable + $standA1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'A1', + 'assignment_priority' => 100, + 'aerodrome_reference_code' => 'E', + 'type_id' => 2, + ] + ); + StandReservation::create( + [ + 'stand_id' => $standA1->id, + 'start' => Carbon::now()->subMinutes(1), + 'end' => Carbon::now()->addMinutes(1), + ] + ); + + // Should be ranked joint second, bigger than A1, but same priority + $standB1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B1', + 'assignment_priority' => 100, + 'type_id' => 2, + ] + ); + StandRequest::factory()->create(['requested_time' => Carbon::now(), 'stand_id' => $standB1->id]); + $standB2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B2', + 'assignment_priority' => 100, + 'type_id' => 2, + ] + ); + + // Should be ranked joint third, same size as B1 and B2, but lower priority + $standC1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C1', + 'assignment_priority' => 101, + 'type_id' => 2, + ] + ); + $standC2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C2', + 'assignment_priority' => 101, + 'type_id' => 2, + ] + ); + + // Should not appear in rankings - wrong airfield + Stand::factory()->create(['airfield_id' => 2, 'identifier' => 'D1', 'type_id' => 1]); + + // Should not appear in rankings - is domestic + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'E1', + 'type_id' => 1, + ] + ); + + // Should not appear in rankings - too small ARC + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'F1', + 'aerodrome_reference_code' => 'A', + 'type_id' => 1, + ] + ); + + // Should not appear in rankings - too small max aircraft size + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'G1', + 'max_aircraft_id_length' => $cessna->id, + 'max_aircraft_id_wingspan' => $cessna->id, + 'type_id' => 1, + ] + ); + + // Should not appear in rankings - closed + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'H1', + 'aerodrome_reference_code' => 'E', + 'closed_at' => Carbon::now(), + 'type_id' => 1, + ] + ); + + $expectedRanks = [ + $standA1->id => 1, + $standB1->id => 2, + $standB2->id => 2, + $standC1->id => 3, + $standC2->id => 3, + ]; + + $actualRanks = $this->allocator->getRankedStandAllocation( + $this->newAircraft('VIR22F', 'B738', $airfield->code, 'KJFK') + )->mapWithKeys( + fn($stand) => [$stand->id => $stand->rank] + ) + ->toArray(); + + $this->assertEquals($expectedRanks, $actualRanks); + } + private function createAircraft( string $callsign, string $type, string $arrivalAirport, string $departureAirport = 'EGKK' ): NetworkAircraft { - return NetworkAircraft::create( + return tap( + $this->newAircraft($callsign, $type, $arrivalAirport, $departureAirport), + fn(NetworkAircraft $aircraft) => $aircraft->save() + ); + } + + private function newAircraft( + string $callsign, + string $type, + string $arrivalAirport, + string $departureAirport = 'EGKK' + ): NetworkAircraft { + return new NetworkAircraft( [ 'callsign' => $callsign, 'cid' => 1234, @@ -199,6 +480,7 @@ private function createAircraft( 'planned_aircraft_short' => $type, 'planned_destairport' => $arrivalAirport, 'planned_depairport' => $departureAirport, + 'aircraft_id' => Aircraft::where('code', $type)->first()?->id, ] ); } diff --git a/tests/app/Allocator/Stand/FallbackArrivalStandAllocatorTest.php b/tests/app/Allocator/Stand/FallbackArrivalStandAllocatorTest.php index b89aca3a7..194142298 100644 --- a/tests/app/Allocator/Stand/FallbackArrivalStandAllocatorTest.php +++ b/tests/app/Allocator/Stand/FallbackArrivalStandAllocatorTest.php @@ -4,9 +4,13 @@ use App\BaseFunctionalTestCase; use App\Models\Aircraft\Aircraft; +use App\Models\Airfield\Airfield; use App\Models\Stand\Stand; +use App\Models\Stand\StandRequest; +use App\Models\Stand\StandReservation; use App\Models\Stand\StandType; use App\Models\Vatsim\NetworkAircraft; +use Carbon\Carbon; class FallbackArrivalStandAllocatorTest extends BaseFunctionalTestCase { @@ -223,18 +227,163 @@ public function testItReturnsNothingOnNoStandAllocated() $this->assertNull($this->allocator->allocate($this->createAircraft('BAW898', 'B738', 'XXXX'))); } + public function testItDoesntRankStandsIfUnknownAircraft() + { + $aircraft = $this->newAircraft('BAW123', 'XXX', 'EGLL', 'EIDW'); + $this->assertEquals(collect(), $this->allocator->getRankedStandAllocation($aircraft)); + } + + public function testItGetsRankedStandAllocation() + { + // Create an airfield that we dont have so we know its a clean test + $airfield = Airfield::factory()->create(['code' => 'EXXX']); + $airfieldId = $airfield->id; + + // Create a small aircraft type to test stand size ranking + $cessna = Aircraft::create( + [ + 'code' => 'C172', + 'allocate_stands' => true, + 'aerodrome_reference_code' => 'A', + 'wingspan' => 1, + 'length' => 12, + ] + ); + + // Should be ranked first - its the smallest stand that's applicable + $standA1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'A1', + 'assignment_priority' => 100, + 'aerodrome_reference_code' => 'E', + ] + ); + StandReservation::create( + [ + 'stand_id' => $standA1->id, + 'start' => Carbon::now()->subMinutes(1), + 'end' => Carbon::now()->addMinutes(1), + ] + ); + + // Should be ranked joint second, bigger than A1, but same priority + $standB1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B1', + 'assignment_priority' => 100, + ] + ); + StandRequest::factory()->create(['requested_time' => Carbon::now(), 'stand_id' => $standB1->id]); + $standB2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B2', + 'assignment_priority' => 100, + ] + ); + + // Should be ranked joint third, same size as B1 and B2, but lower priority + $standC1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C1', + 'assignment_priority' => 101, + ] + ); + $standC2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C2', + 'assignment_priority' => 101, + ] + ); + + // Should not appear in rankings - wrong airfield + Stand::factory()->create(['airfield_id' => 2, 'identifier' => 'D1', 'type_id' => 1]); + + // Should not appear in rankings - is cargo + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'E1', + 'type_id' => 3, + ] + ); + + // Should not appear in rankings - too small ARC + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'F1', + 'aerodrome_reference_code' => 'A' + ] + ); + + // Should not appear in rankings - too small max aircraft size + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'G1', + 'max_aircraft_id_length' => $cessna->id, + 'max_aircraft_id_wingspan' => $cessna->id + ] + ); + + // Should not appear in rankings - closed + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'H1', + 'aerodrome_reference_code' => 'E', + 'closed_at' => Carbon::now() + ] + ); + + $expectedRanks = [ + $standA1->id => 1, + $standB1->id => 2, + $standB2->id => 2, + $standC1->id => 3, + $standC2->id => 3, + ]; + + $actualRanks = $this->allocator->getRankedStandAllocation( + $this->newAircraft('VIR22F', 'B738', $airfield->code) + )->mapWithKeys( + fn($stand) => [$stand->id => $stand->rank] + ) + ->toArray(); + + $this->assertEquals($expectedRanks, $actualRanks); + } + private function createAircraft( string $callsign, string $type, string $arrivalAirport ): NetworkAircraft { - return NetworkAircraft::create( + return tap( + $this->newAircraft($callsign, $type, $arrivalAirport), + fn(NetworkAircraft $aircraft) => + $aircraft->save() + ); + } + + private function newAircraft( + string $callsign, + string $type, + string $arrivalAirport + ): NetworkAircraft { + return new NetworkAircraft( [ 'callsign' => $callsign, 'cid' => 1234, 'planned_aircraft' => $type, 'planned_aircraft_short' => $type, 'planned_destairport' => $arrivalAirport, + 'aircraft_id' => Aircraft::where('code', $type)->first()?->id, ] ); } diff --git a/tests/app/Allocator/Stand/OriginAirfieldStandAllocatorTest.php b/tests/app/Allocator/Stand/OriginAirfieldStandAllocatorTest.php index 75e830fbe..b99c0c867 100644 --- a/tests/app/Allocator/Stand/OriginAirfieldStandAllocatorTest.php +++ b/tests/app/Allocator/Stand/OriginAirfieldStandAllocatorTest.php @@ -4,8 +4,12 @@ use App\BaseFunctionalTestCase; use App\Models\Aircraft\Aircraft; +use App\Models\Airfield\Airfield; use App\Models\Stand\Stand; +use App\Models\Stand\StandRequest; +use App\Models\Stand\StandReservation; use App\Models\Vatsim\NetworkAircraft; +use Carbon\Carbon; class OriginAirfieldStandAllocatorTest extends BaseFunctionalTestCase { @@ -252,13 +256,190 @@ public function testItDoesntAllocateAStandWithNoDestination() $this->assertNull($this->allocator->allocate($aircraft)); } + public function testItGetsRankedStandAllocation() + { + // Create an airfield that we dont have so we know its a clean test + $airfield = Airfield::factory()->create(['code' => 'EXXX']); + $airfieldId = $airfield->id; + + // Create a small aircraft type to test stand size ranking + $cessna = Aircraft::create( + [ + 'code' => 'C172', + 'allocate_stands' => true, + 'aerodrome_reference_code' => 'A', + 'wingspan' => 1, + 'length' => 12, + ] + ); + + // Should be ranked first - it has the highest priority. It gets a stand reservation to make + // sure it is ranked first even if it is occupied. + $standA1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'A1', + 'origin_slug' => 'EGGD', + 'assignment_priority' => 100, + 'aerodrome_reference_code' => 'C' + ] + ); + StandReservation::create( + [ + 'stand_id' => $standA1->id, + 'start' => Carbon::now()->subMinutes(1), + 'end' => Carbon::now()->addMinutes(1), + ] + ); + + // Should be ranked joint second, bigger than A1 but same priority + $standB1 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B1', + 'origin_slug' => 'EGGD', + 'assignment_priority' => 100, + ] + ); + StandRequest::factory()->create(['requested_time' => Carbon::now(), 'stand_id' => $standB1->id]); + $standB2 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'B2', + 'origin_slug' => 'EGGD', + 'assignment_priority' => 100, + ] + ); + + // Should be ranked joint third, same size as B1 but lower priority + $standC1 = Stand::factory()->create( + ['airfield_id' => $airfieldId, 'identifier' => 'C1', 'origin_slug' => 'EGGD', 'assignment_priority' => 101] + ); + $standC2 = Stand::factory()->create( + ['airfield_id' => $airfieldId, 'identifier' => 'C2', 'origin_slug' => 'EGGD', 'assignment_priority' => 101] + ); + + // Should be ranked 4th, 5th, 6th, less specific destinations slugs + $standC3 = Stand::factory()->create( + ['airfield_id' => $airfieldId, 'identifier' => 'C3', 'origin_slug' => 'EGG', 'assignment_priority' => 101] + ); + $standC4 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C4', + 'origin_slug' => 'EG', + 'assignment_priority' => 101 + ] + ); + $standC5 = Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'C5', + 'origin_slug' => 'E', + 'assignment_priority' => 101 + ] + ); + + // Should not appear in rankings - wrong airfield + Stand::factory()->create(['airfield_id' => 2, 'identifier' => 'D1']); + + // Should not appear in rankings - wrong origin slug + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'E1', + 'origin_slug' => 'EGKK', + ] + ); + + // Should not appear in rankings - is cargo + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'E2', + 'origin_slug' => 'EGGD', + 'type_id' => 3, + ] + ); + + // Should not appear in rankings - no origin slug + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'E3', + ] + ); + + // Should not appear in rankings - too small ARC + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'F1', + 'aerodrome_reference_code' => 'A', + 'origin_slug' => 'EGGD', + ] + ); + + // Should not appear in rankings - too small max aircraft size + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'G1', + 'max_aircraft_id_length' => $cessna->id, + 'max_aircraft_id_wingspan' => $cessna->id, + 'origin_slug' => 'EGGD', + ] + ); + + // Should not appear in rankings - closed + Stand::factory()->create( + [ + 'airfield_id' => $airfieldId, + 'identifier' => 'H1', + 'aerodrome_reference_code' => 'C', + 'closed_at' => Carbon::now(), + 'origin_slug' => 'EGGD', + ] + ); + + $expectedRanks = [ + $standA1->id => 1, + $standB1->id => 2, + $standB2->id => 2, + $standC1->id => 3, + $standC2->id => 3, + $standC3->id => 4, + $standC4->id => 5, + $standC5->id => 6 + ]; + + $actualRanks = $this->allocator->getRankedStandAllocation( + $this->newAircraft('BAW23451', $airfield->code, 'EGGD') + )->mapWithKeys( + fn($stand) => [$stand->id => $stand->rank] + ) + ->toArray(); + + $this->assertEquals($expectedRanks, $actualRanks); + } + private function createAircraft( string $callsign, string $arrivalAirport, string $departureAirport - ): NetworkAircraft - { - return NetworkAircraft::create( + ): NetworkAircraft { + return tap( + $this->newAircraft($callsign, $arrivalAirport, $departureAirport), + fn(NetworkAircraft $aircraft) => $aircraft->save() + ); + } + + private function newAircraft( + string $callsign, + string $arrivalAirport, + string $departureAirport + ): NetworkAircraft { + return new NetworkAircraft( [ 'callsign' => $callsign, 'cid' => 1234, @@ -266,6 +447,7 @@ private function createAircraft( 'planned_aircraft_short' => 'B738', 'planned_destairport' => $arrivalAirport, 'planned_depairport' => $departureAirport, + 'aircraft_id' => 1, ] ); } diff --git a/tests/app/Filament/AircraftResourceTest.php b/tests/app/Filament/AircraftResourceTest.php index e348239c9..b7404a34f 100644 --- a/tests/app/Filament/AircraftResourceTest.php +++ b/tests/app/Filament/AircraftResourceTest.php @@ -3,6 +3,7 @@ namespace App\Filament; use App\BaseFilamentTestCase; +use App\Events\Aircraft\AircraftDataUpdatedEvent; use App\Filament\Resources\AircraftResource; use App\Filament\Resources\AircraftResource\Pages\CreateAircraft; use App\Filament\Resources\AircraftResource\Pages\EditAircraft; @@ -13,6 +14,7 @@ use App\Models\Aircraft\WakeCategory; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; use Livewire\Livewire; class AircraftResourceTest extends BaseFilamentTestCase @@ -20,6 +22,12 @@ class AircraftResourceTest extends BaseFilamentTestCase use ChecksOperationsContributorActionVisibility; use ChecksOperationsContributorAccess; + public function setUp(): void + { + parent::setUp(); + Event::fake(); + } + public function testItLoadsDataForView() { Livewire::test(ViewAircraft::class, ['record' => 1]) @@ -48,6 +56,9 @@ public function testItCreatesAnAircraft() 'length' => 208.99, 'allocate_stands' => true, ]); + + // Check that the event was dispatched + Event::assertDispatched(AircraftDataUpdatedEvent::class); } public function testItDoesntCreateAnAircraftWithNoCode() @@ -59,6 +70,9 @@ public function testItDoesntCreateAnAircraftWithNoCode() ->set('data.allocate_stands', true) ->call('create') ->assertHasErrors('data.code'); + + // Check that the event was not dispatched + Event::assertNotDispatched(AircraftDataUpdatedEvent::class); } public function testItDoesntCreateAnAircraftWithEmptyCode() @@ -71,6 +85,9 @@ public function testItDoesntCreateAnAircraftWithEmptyCode() ->set('data.allocate_stands', true) ->call('create') ->assertHasErrors('data.code'); + + // Check that the event was not dispatched + Event::assertNotDispatched(AircraftDataUpdatedEvent::class); } public function testItDoesntCreateAnAircraftWithClashingCode() @@ -83,6 +100,9 @@ public function testItDoesntCreateAnAircraftWithClashingCode() ->set('data.allocate_stands', true) ->call('create') ->assertHasErrors('data.code'); + + // Check that the event was not dispatched + Event::assertNotDispatched(AircraftDataUpdatedEvent::class); } public function testItDoesntCreateAnAircraftWithNoAerodromeReferenceCode() @@ -94,6 +114,9 @@ public function testItDoesntCreateAnAircraftWithNoAerodromeReferenceCode() ->set('data.allocate_stands', true) ->call('create') ->assertHasErrors('data.aerodrome_reference_code'); + + // Check that the event was not dispatched + Event::assertNotDispatched(AircraftDataUpdatedEvent::class); } public function testItDoesntCreateAnAircraftWithNoWingspan() @@ -105,6 +128,9 @@ public function testItDoesntCreateAnAircraftWithNoWingspan() ->set('data.allocate_stands', true) ->call('create') ->assertHasErrors('data.wingspan'); + + // Check that the event was not dispatched + Event::assertNotDispatched(AircraftDataUpdatedEvent::class); } public function testItDoesntCreateAnAircraftWithNegativeWingspan() @@ -117,6 +143,9 @@ public function testItDoesntCreateAnAircraftWithNegativeWingspan() ->set('data.allocate_stands', true) ->call('create') ->assertHasErrors('data.wingspan'); + + // Check that the event was not dispatched + Event::assertNotDispatched(AircraftDataUpdatedEvent::class); } public function testItDoesntCreateAnAircraftWithNoLength() @@ -128,6 +157,9 @@ public function testItDoesntCreateAnAircraftWithNoLength() ->set('data.allocate_stands', true) ->call('create') ->assertHasErrors('data.length'); + + // Check that the event was not dispatched + Event::assertNotDispatched(AircraftDataUpdatedEvent::class); } public function testItDoesntCreateAnAircraftWithNegativeLength() @@ -140,6 +172,9 @@ public function testItDoesntCreateAnAircraftWithNegativeLength() ->set('data.allocate_stands', true) ->call('create') ->assertHasErrors('data.length'); + + // Check that the event was not dispatched + Event::assertNotDispatched(AircraftDataUpdatedEvent::class); } public function testItLoadsDataForEdit() @@ -171,6 +206,9 @@ public function testItEditsAnAircraft() 'length' => 129.50, 'allocate_stands' => false, ]); + + // Check that the event was dispatched + Event::assertDispatched(AircraftDataUpdatedEvent::class); } public function testItEditsAnAircraftAndDoesntErrorWithExistingCode() @@ -192,6 +230,9 @@ public function testItEditsAnAircraftAndDoesntErrorWithExistingCode() 'length' => 129.50, 'allocate_stands' => false, ]); + + // Check that the event was dispatched + Event::assertDispatched(AircraftDataUpdatedEvent::class); } public function testItDoesntEditAnAircraftWithNoCode() @@ -204,6 +245,9 @@ public function testItDoesntEditAnAircraftWithNoCode() ->set('data.allocate_stands', false) ->call('save') ->assertHasErrors('data.code'); + + // Check that the event was not dispatched + Event::assertNotDispatched(AircraftDataUpdatedEvent::class); } public function testItDoesntEditAnAircraftWithEmptyCode() @@ -216,6 +260,9 @@ public function testItDoesntEditAnAircraftWithEmptyCode() ->set('data.allocate_stands', false) ->call('save') ->assertHasErrors('data.code'); + + // Check that the event was not dispatched + Event::assertNotDispatched(AircraftDataUpdatedEvent::class); } public function testItDoesntEditAnAircraftWithClashingCode() @@ -228,11 +275,14 @@ public function testItDoesntEditAnAircraftWithClashingCode() ->set('data.allocate_stands', false) ->call('save') ->assertHasErrors('data.code'); + + // Check that the event was not dispatched + Event::assertNotDispatched(AircraftDataUpdatedEvent::class); } public function testItDoesntEditAnAircraftWithNoWingspan - () - { + ( + ) { Livewire::test(EditAircraft::class, ['record' => 1]) ->set('data.code', 'B738') ->set('data.aerodrome_reference_code', 'F') @@ -241,6 +291,9 @@ public function testItDoesntEditAnAircraftWithNoWingspan ->set('data.allocate_stands', false) ->call('save') ->assertHasErrors('data.wingspan'); + + // Check that the event was not dispatched + Event::assertNotDispatched(AircraftDataUpdatedEvent::class); } public function testItDoesntEditAnAircraftWithNegativeWingspan() @@ -253,6 +306,9 @@ public function testItDoesntEditAnAircraftWithNegativeWingspan() ->set('data.allocate_stands', false) ->call('save') ->assertHasErrors('data.wingspan'); + + // Check that the event was not dispatched + Event::assertNotDispatched(AircraftDataUpdatedEvent::class); } public function testItDoesntEditAnAircraftWithNoLength() @@ -265,6 +321,9 @@ public function testItDoesntEditAnAircraftWithNoLength() ->set('data.allocate_stands', false) ->call('save') ->assertHasErrors('data.length'); + + // Check that the event was not dispatched + Event::assertNotDispatched(AircraftDataUpdatedEvent::class); } public function testItDoesntEditAnAircraftWithNegativeLength() @@ -277,6 +336,37 @@ public function testItDoesntEditAnAircraftWithNegativeLength() ->set('data.allocate_stands', false) ->call('save') ->assertHasErrors('data.length'); + + // Check that the event was not dispatched + Event::assertNotDispatched(AircraftDataUpdatedEvent::class); + } + + public function testItDeletesAircraftFromTheListingPage() + { + Livewire::test(ListAircraft::class) + ->callTableAction('delete', 1) + ->assertHasNoErrors(); + + $this->assertDatabaseMissing('aircraft', [ + 'id' => 1, + ]); + + // Check that the event was dispatched + Event::assertDispatched(AircraftDataUpdatedEvent::class); + } + + public function testItDeletesAircraftFromTheEditPage() + { + Livewire::test(EditAircraft::class, ['record' => 1]) + ->callPageAction('delete') + ->assertHasNoPageActionErrors(); + + $this->assertDatabaseMissing('aircraft', [ + 'id' => 1, + ]); + + // Check that the event was dispatched + Event::assertDispatched(AircraftDataUpdatedEvent::class); } public function testItAllowsWakeCategoryAssociation() diff --git a/tests/app/Filament/AirlineResourceTest.php b/tests/app/Filament/AirlineResourceTest.php index 3033ac96a..87106f768 100644 --- a/tests/app/Filament/AirlineResourceTest.php +++ b/tests/app/Filament/AirlineResourceTest.php @@ -3,6 +3,7 @@ namespace App\Filament; use App\BaseFilamentTestCase; +use App\Events\Airline\AirlinesUpdatedEvent; use App\Filament\Resources\AirlineResource; use App\Filament\Resources\AirlineResource\Pages\CreateAirline; use App\Filament\Resources\AirlineResource\Pages\EditAirline; @@ -15,6 +16,7 @@ use App\Models\Stand\Stand; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Str; use Livewire\Livewire; @@ -23,6 +25,12 @@ class AirlineResourceTest extends BaseFilamentTestCase use ChecksOperationsContributorActionVisibility; use ChecksOperationsContributorAccess; + public function setUp(): void + { + parent::setUp(); + Event::fake(); + } + public function testItLoadsDataForView() { Livewire::test(ViewAirline::class, ['record' => 1]) @@ -51,6 +59,9 @@ public function testItCreatesAnAirline() 'is_cargo' => false, ] ); + + // Check that the event was dispatched + Event::assertDispatched(AirlinesUpdatedEvent::class); } public function testItCreatesACargoAirline() @@ -72,6 +83,9 @@ public function testItCreatesACargoAirline() 'is_cargo' => true, ] ); + + // Check that the event was dispatched + Event::assertDispatched(AirlinesUpdatedEvent::class); } public function testItCreatesAnAirlineAndCopiesStandAndTerminalAssignments() @@ -179,6 +193,9 @@ public function testItCreatesAnAirlineAndCopiesStandAndTerminalAssignments() 'full_callsign' => 'def', ] ); + + // Check that the event was dispatched + Event::assertDispatched(AirlinesUpdatedEvent::class); } public function testItCreatesAnAirlineAndDoesntCopyStandAndTerminalAssignments() @@ -234,6 +251,9 @@ public function testItCreatesAnAirlineAndDoesntCopyStandAndTerminalAssignments() 'airline_id' => $airline->id, ] ); + + // Check that the event was dispatched + Event::assertDispatched(AirlinesUpdatedEvent::class); } public function testItDoesntCreateAnAirlineNoIcaoCode() @@ -244,6 +264,9 @@ public function testItDoesntCreateAnAirlineNoIcaoCode() ->set('data.is_cargo', false) ->call('create') ->assertHasErrors(['data.icao_code']); + + // Check that the event was not dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); } public function testItDoesntCreateAnAirlineIcaoCodeEmpty() @@ -255,6 +278,9 @@ public function testItDoesntCreateAnAirlineIcaoCodeEmpty() ->set('data.is_cargo', false) ->call('create') ->assertHasErrors(['data.icao_code']); + + // Check that the event was not dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); } public function testItDoesntCreateAnAirlineIcaoCodeTooLong() @@ -266,6 +292,9 @@ public function testItDoesntCreateAnAirlineIcaoCodeTooLong() ->set('data.is_cargo', false) ->call('create') ->assertHasErrors(['data.icao_code']); + + // Check that the event was not dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); } public function testItDoesntCreateAnAirlineNoName() @@ -276,6 +305,9 @@ public function testItDoesntCreateAnAirlineNoName() ->set('data.is_cargo', false) ->call('create') ->assertHasErrors(['data.name']); + + // Check that the event was not dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); } public function testItDoesntCreateAnAirlineNameEmpty() @@ -287,6 +319,9 @@ public function testItDoesntCreateAnAirlineNameEmpty() ->set('data.is_cargo', false) ->call('create') ->assertHasErrors(['data.name']); + + // Check that the event was not dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); } public function testItDoesntCreateAnAirlineNameTooLong() @@ -298,6 +333,9 @@ public function testItDoesntCreateAnAirlineNameTooLong() ->set('data.is_cargo', false) ->call('create') ->assertHasErrors(['data.name']); + + // Check that the event was not dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); } public function testItDoesntCreateAnAirlineNoCallsign() @@ -308,6 +346,9 @@ public function testItDoesntCreateAnAirlineNoCallsign() ->set('data.is_cargo', false) ->call('create') ->assertHasErrors(['data.callsign']); + + // Check that the event was not dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); } public function testItDoesntCreateAnAirlineCallsignEmpty() @@ -319,6 +360,9 @@ public function testItDoesntCreateAnAirlineCallsignEmpty() ->set('data.is_cargo', false) ->call('create') ->assertHasErrors(['data.callsign']); + + // Check that the event was not dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); } public function testItDoesntCreateAnAirlineCallsignTooLong() @@ -330,6 +374,9 @@ public function testItDoesntCreateAnAirlineCallsignTooLong() ->set('data.is_cargo', false) ->call('create') ->assertHasErrors(['data.callsign']); + + // Check that the event was not dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); } public function testItLoadsDataForEdit() @@ -339,6 +386,9 @@ public function testItLoadsDataForEdit() ->assertSet('data.name', 'British Airways') ->assertSet('data.callsign', 'SPEEDBIRD') ->assertSet('data.is_cargo', false); + + // Check that the event was not dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); } public function testItEditsAnAirline() @@ -361,6 +411,9 @@ public function testItEditsAnAirline() 'is_cargo' => false, ] ); + + // Check that the event was dispatched + Event::assertDispatched(AirlinesUpdatedEvent::class); } public function testItEditsACargoAirline() @@ -383,6 +436,9 @@ public function testItEditsACargoAirline() 'is_cargo' => true, ] ); + + // Check that the event was dispatched + Event::assertDispatched(AirlinesUpdatedEvent::class); } public function testItDoesntEditAnAirlineNoIcaoCode() @@ -394,6 +450,9 @@ public function testItDoesntEditAnAirlineNoIcaoCode() ->set('data.is_cargo', false) ->call('save') ->assertHasErrors(['data.icao_code']); + + // Check that the event was nmot dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); } public function testItDoesntEditAnAirlineIcaoCodeEmpty() @@ -405,6 +464,9 @@ public function testItDoesntEditAnAirlineIcaoCodeEmpty() ->set('data.is_cargo', false) ->call('save') ->assertHasErrors(['data.icao_code']); + + // Check that the event was nmot dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); } public function testItDoesntEditAnAirlineIcaoCodeTooLong() @@ -416,6 +478,9 @@ public function testItDoesntEditAnAirlineIcaoCodeTooLong() ->set('data.is_cargo', false) ->call('save') ->assertHasErrors(['data.icao_code']); + + // Check that the event was nmot dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); } public function testItDoesntEditAnAirlineNoName() @@ -427,6 +492,9 @@ public function testItDoesntEditAnAirlineNoName() ->set('data.is_cargo', false) ->call('save') ->assertHasErrors(['data.name']); + + // Check that the event was nmot dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); } public function testItDoesntEditAnAirlineNameEmpty() @@ -438,6 +506,9 @@ public function testItDoesntEditAnAirlineNameEmpty() ->set('data.is_cargo', false) ->call('save') ->assertHasErrors(['data.name']); + + // Check that the event was nmot dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); } public function testItDoesntEditAnAirlineNameTooLong() @@ -449,6 +520,9 @@ public function testItDoesntEditAnAirlineNameTooLong() ->set('data.is_cargo', false) ->call('save') ->assertHasErrors(['data.name']); + + // Check that the event was nmot dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); } public function testItDoesntEditAnAirlineNoCallsign() @@ -460,6 +534,9 @@ public function testItDoesntEditAnAirlineNoCallsign() ->set('data.is_cargo', false) ->call('save') ->assertHasErrors(['data.callsign']); + + // Check that the event was nmot dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); } public function testItDoesntEditAnAirlineCallsignEmpty() @@ -471,6 +548,9 @@ public function testItDoesntEditAnAirlineCallsignEmpty() ->set('data.is_cargo', false) ->call('save') ->assertHasErrors(['data.callsign']); + + // Check that the event was nmot dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); } public function testItDoesntEditAnAirlineCallsignTooLong() @@ -482,6 +562,43 @@ public function testItDoesntEditAnAirlineCallsignTooLong() ->set('data.is_cargo', false) ->call('save') ->assertHasErrors(['data.callsign']); + + // Check that the event was nmot dispatched + Event::assertNotDispatched(AirlinesUpdatedEvent::class); + } + + public function testAirlinesCanBeDeletedFromTheEditPage() + { + Livewire::test(EditAirline::class, ['record' => 1]) + ->callPageAction('delete') + ->assertHasNoPageActionErrors(); + + $this->assertDatabaseMissing( + 'airlines', + [ + 'id' => 1, + ] + ); + + // Check that the event was dispatched + Event::assertDispatched(AirlinesUpdatedEvent::class); + } + + public function testAirlinesCanBeDeletedFromTheListingPage() + { + Livewire::test(ListAirlines::class) + ->callTableAction('delete', 1) + ->assertHasNoPageActionErrors(); + + $this->assertDatabaseMissing( + 'airlines', + [ + 'id' => 1, + ] + ); + + // Check that the event was dispatched + Event::assertDispatched(AirlinesUpdatedEvent::class); } public function testItAllowsTerminalPairingWithMinimalData() diff --git a/tests/app/Filament/Pages/StandPredictorTest.php b/tests/app/Filament/Pages/StandPredictorTest.php new file mode 100644 index 000000000..bfc6071a5 --- /dev/null +++ b/tests/app/Filament/Pages/StandPredictorTest.php @@ -0,0 +1,111 @@ +assumeRole($role); + } else { + $this->noRole(); + } + + $response = Livewire::test(StandPredictor::class); + if ($shouldRender) { + $response->assertOk(); + } else { + $response->assertForbidden(); + } + } + + public static function renderRoleProvider(): array + { + return [ + 'None' => [null, false], + 'Contributor' => [RoleKeys::OPERATIONS_CONTRIBUTOR, true], + 'DSG' => [RoleKeys::DIVISION_STAFF_GROUP, true], + 'Web' => [RoleKeys::WEB_TEAM, true], + 'Operations' => [RoleKeys::OPERATIONS_TEAM, true], + ]; + } + + public function testItPresentsStandPredictionsOnEvent() + { + // Create models for the test + $arrivalAirfield = Airfield::factory()->create(); + $departureAirfield = Airfield::factory()->create(); + + // Callsign specific + $stand1 = Stand::factory()->create( + ['airfield_id' => $arrivalAirfield->id, 'identifier' => '1A', 'assignment_priority' => 1] + ); + $stand1->airlines()->sync([1 => ['full_callsign' => '221']]); + $stand2 = Stand::factory()->create( + ['airfield_id' => $arrivalAirfield->id, 'identifier' => '2A', 'assignment_priority' => 1] + ); + $stand2->airlines()->sync([1 => ['full_callsign' => '221']]); + + // Callsign specfic, but with a lower priorityy + $stand3 = Stand::factory()->create(['airfield_id' => $arrivalAirfield->id, 'assignment_priority' => 2]); + $stand3->airlines()->sync([1 => ['full_callsign' => '221', 'priority' => 101]]); + + // Generic + $stand4 = Stand::factory()->create(['airfield_id' => $arrivalAirfield->id, 'assignment_priority' => 3]); + $stand4->airlines()->sync([1]); + + Livewire::test(StandPredictor::class) + ->emit( + 'standPredictorFormSubmitted', + [ + 'callsign' => 'BAW999', + 'cid' => 1202533, + 'planned_destairport' => $arrivalAirfield->code, + 'planned_depairport' => $departureAirfield->code, + 'aircraft_id' => 1, + 'airline_id' => 1, + ] + ) + ->assertSeeHtmlInOrder( + [ + sprintf( + 'Allocator: %s', + CargoAirlineFallbackStandAllocator::class + ), + 'No stands for this allocator', + sprintf( + 'Allocator: %s', + OriginAirfieldStandAllocator::class + ) + ] + )->assertSeeHtml( + [ + sprintf( + 'Allocator: %s', + AirlineCallsignArrivalStandAllocator::class + ), + 'Rank 1', + implode(',', [$stand1->identifier, $stand2->identifier]), + 'Rank 2', + $stand3->identifier, + sprintf( + 'Allocator: %s', + AirlineCallsignSlugArrivalStandAllocator::class + ) + ] + ); + } +} diff --git a/tests/app/Http/Livewire/StandPredictorFormTest.php b/tests/app/Http/Livewire/StandPredictorFormTest.php new file mode 100644 index 000000000..1a95b323d --- /dev/null +++ b/tests/app/Http/Livewire/StandPredictorFormTest.php @@ -0,0 +1,76 @@ +assertOk(); + } + + public function testItSubmits() + { + Livewire::test(StandPredictorForm::class) + ->set('callsign', 'BAW999') + ->set('aircraftType', 1) + ->set('departureAirfield', 'EGKK') + ->set('arrivalAirfield', 'EGLL') + ->call('submit') + ->assertHasNoErrors() + ->assertEmitted('standPredictorFormSubmitted', [ + 'callsign' => 'BAW999', + 'cid' => 1203533, + 'aircraft_id' => 1, + 'airline_id' => 1, + 'planned_depairport' => 'EGKK', + 'planned_destairport' => 'EGLL', + ]); + } + + public function testItDoesntSubmitIfNoCallsign() + { + Livewire::test(StandPredictorForm::class) + ->set('aircraftType', 1) + ->set('departureAirfield', 'EGKK') + ->set('arrivalAirfield', 'EGLL') + ->call('submit') + ->assertHasErrors(['callsign']) + ->assertNotEmitted('standPredictorFormSubmitted'); + } + + public function testItDoesntSubmitIfNoAircraftType() + { + Livewire::test(StandPredictorForm::class) + ->set('callsign', 'BAW123') + ->set('departureAirfield', 'EGKK') + ->set('arrivalAirfield', 'EGLL') + ->call('submit') + ->assertHasErrors(['aircraftType']) + ->assertNotEmitted('standPredictorFormSubmitted'); + } + + public function testItDoesntSubmitIfNoDepartureAirfield() + { + Livewire::test(StandPredictorForm::class) + ->set('callsign', 'BAW123') + ->set('arrivalAirfield', 'EGLL') + ->call('submit') + ->assertHasErrors(['departureAirfield']) + ->assertNotEmitted('standPredictorFormSubmitted'); + } + + public function testItDoesntSubmitIfNoArrivalAirfield() + { + Livewire::test(StandPredictorForm::class) + ->set('callsign', 'BAW123') + ->set('departureAirfield', 'EGKK') + ->call('submit') + ->assertHasErrors(['arrivalAirfield']) + ->assertNotEmitted('standPredictorFormSubmitted'); + } +} diff --git a/tests/app/Models/Stand/StandTest.php b/tests/app/Models/Stand/StandTest.php index ef3f8b17b..b83ecb187 100644 --- a/tests/app/Models/Stand/StandTest.php +++ b/tests/app/Models/Stand/StandTest.php @@ -120,68 +120,6 @@ public function testAirlineOnlyReturnsStandsAtTheRightTime() $this->assertEquals([2, 3], $stands); } - public function testAirlineDestinationOnlyReturnsStandsForTheCorrectAirlineAndDestinations() - { - DB::table('airline_stand')->insert( - [ - [ - 'airline_id' => 1, - 'stand_id' => 1, - 'destination' => 'EGGD', - ], - [ - 'airline_id' => 1, - 'stand_id' => 2, - 'destination' => 'EGFF', - ], - [ - 'airline_id' => 2, - 'stand_id' => 1, - 'destination' => 'EGGD', - ], - ] - ); - - $stands = Stand::airlineDestination( - Airline::find(1), - ['EGGD'] - )->pluck('stands.id')->toArray(); - $this->assertEquals([1], $stands); - } - - public function testAirlineDestinationOnlyReturnsStandsWithinTheRightTime() - { - Carbon::setTestNow(Carbon::parse('2020-12-05 16:00:00')); - DB::table('airline_stand')->insert( - [ - [ - 'airline_id' => 1, - 'stand_id' => 1, - 'destination' => 'EGGD', - 'not_before' => '16:00:01', - ], - [ - 'airline_id' => 1, - 'stand_id' => 2, - 'destination' => 'EGGD', - 'not_before' => null, - ], - [ - 'airline_id' => 1, - 'stand_id' => 3, - 'destination' => 'EGGD', - 'not_before' => '16:00:00', - ], - ] - ); - - $stands = Stand::airlineDestination( - Airline::find(1), - ['EGGD'] - )->pluck('stands.id')->toArray(); - $this->assertEquals([2, 3], $stands); - } - public function testAirlineCallsignOnlyReturnsStandsForTheCorrectAirlineAndCallsigns() { DB::table('airline_stand')->insert( diff --git a/tests/app/Services/AircraftServiceTest.php b/tests/app/Services/AircraftServiceTest.php index c862b6c5e..6c2633edf 100644 --- a/tests/app/Services/AircraftServiceTest.php +++ b/tests/app/Services/AircraftServiceTest.php @@ -3,8 +3,11 @@ namespace App\Services; use App\BaseFunctionalTestCase; +use App\Events\Aircraft\AircraftDataUpdatedEvent; +use App\Models\Aircraft\Aircraft; use App\Models\Aircraft\WakeCategory; use App\Models\Aircraft\WakeCategoryScheme; +use Illuminate\Support\Facades\Cache; class AircraftServiceTest extends BaseFunctionalTestCase { @@ -14,6 +17,9 @@ public function setUp(): void { parent::setUp(); $this->service = $this->app->make(AircraftService::class); + + // Call this to ensure the cache is cleared before each test + $this->service->aircraftDataUpdated(); } public function testItGeneratesDependency() @@ -47,4 +53,48 @@ public function testItGeneratesDependency() $this->assertEquals($expected, $this->service->getAircraftDependency()); } + + public function testItGetsAircraftIdFromCode() + { + $this->assertEquals(1, $this->service->getAircraftIdFromCode('B738')); + $this->assertEquals(2, $this->service->getAircraftIdFromCode('A333')); + $this->assertNull($this->service->getAircraftIdFromCode('A332')); + } + + public function testAircraftIfFromCodeIsCached() + { + $this->assertEquals(1, $this->service->getAircraftIdFromCode('B738')); + Aircraft::withoutEvents(function () + { + Aircraft::where('code', 'B738')->update(['code' => 'B799']); + }); + $this->assertEquals(1, $this->service->getAircraftIdFromCode('B738')); + $this->assertNull($this->service->getAircraftIdFromCode('B799')); + } + + public function testItClearsAircraftCodeCacheOnAircraftUpdated() + { + $this->assertEquals(1, $this->service->getAircraftIdFromCode('B738')); + Aircraft::withoutEvents(function () + { + Aircraft::where('code', 'B738')->update(['code' => 'B799']); + }); + $this->service->aircraftDataUpdated(); + + $this->assertNull($this->service->getAircraftIdFromCode('B738')); + $this->assertEquals(1, $this->service->getAircraftIdFromCode('B799')); + } + + public function testItClearsAircraftCodeCacheOnEvent() + { + $this->assertEquals(1, $this->service->getAircraftIdFromCode('B738')); + Aircraft::where('code', 'B738')->update(['code' => 'B799']); + $this->assertEquals(1, $this->service->getAircraftIdFromCode('B738')); + $this->assertNull($this->service->getAircraftIdFromCode('B799')); + + event(new AircraftDataUpdatedEvent); + + $this->assertNull($this->service->getAircraftIdFromCode('B738')); + $this->assertEquals(1, $this->service->getAircraftIdFromCode('B799')); + } } diff --git a/tests/app/Services/AirlineServiceTest.php b/tests/app/Services/AirlineServiceTest.php index 9974edc6c..251b06d51 100644 --- a/tests/app/Services/AirlineServiceTest.php +++ b/tests/app/Services/AirlineServiceTest.php @@ -3,6 +3,7 @@ namespace App\Services; use App\BaseFunctionalTestCase; +use App\Events\Airline\AirlinesUpdatedEvent; use App\Models\Airline\Airline; use App\Models\Vatsim\NetworkAircraft; use PHPUnit\Framework\Attributes\DataProvider; @@ -20,6 +21,72 @@ public function setUp(): void $this->service = $this->app->make(AirlineService::class); } + public function testItReturnsNullIfCallsignDoesNotMatchAirline() + { + $this->assertNull($this->service->airlineIdForCallsign('ABC123')); + } + + public function testItReturnsAirlineIdForCallsign() + { + $airline = Airline::factory()->create(); + $this->assertEquals( + $airline->id, + $this->service->airlineIdForCallsign($airline->icao_code . '123') + ); + } + + public function testItCachesAirlineIdForCallsignResult() + { + $airline = Airline::factory()->create(); + $originalIcaoCode = $airline->icao_code; + $this->assertEquals( + $airline->id, + $this->service->airlineIdForCallsign($airline->icao_code . '123') + ); + + $airline->update(['icao_code' => 'XXX']); + + $this->assertEquals( + $airline->id, + $this->service->airlineIdForCallsign($originalIcaoCode . '123') + ); + } + + public function testItClearsAirlineIdForCallsignCacheWhenAirlinesUpdated() + { + $airline = Airline::factory()->create(); + $originalIcaoCode = $airline->icao_code; + $this->assertEquals( + $airline->id, + $this->service->airlineIdForCallsign($originalIcaoCode . '123') + ); + + $airline->update(['icao_code' => 'XXX']); + $this->assertEquals($airline->id, $this->service->airlineIdForCallsign($originalIcaoCode . '123')); + $this->assertNull($this->service->airlineIdForCallsign('XXX123')); + + $this->service->airlinesUpdated(); + + $this->assertNull($this->service->airlineIdForCallsign($originalIcaoCode . '123')); + $this->assertEquals($airline->id, $this->service->airlineIdForCallsign('XXX123')); + } + + public function testItClearsAirlineIdForCallsignCacheOnEvent() + { + $airline = Airline::factory()->create(); + $originalIcaoCode = $airline->icao_code; + $this->assertEquals( + $airline->id, + $this->service->airlineIdForCallsign($airline->icao_code . '123') + ); + + event(new AirlinesUpdatedEvent()); + + $this->assertEquals($airline->id, $this->service->airlineIdForCallsign($originalIcaoCode . '123')); + $this->assertNull($this->service->airlineIdForCallsign('XXX123')); + } + + #[DataProvider('aircraftProvider')] public function testItReturnsAirlinesForAircraft(string $callsign, string $expectedAirline) { diff --git a/tests/app/Services/NetworkAircraftServiceTest.php b/tests/app/Services/NetworkAircraftServiceTest.php index 0d3fd8f6d..627cc21b6 100644 --- a/tests/app/Services/NetworkAircraftServiceTest.php +++ b/tests/app/Services/NetworkAircraftServiceTest.php @@ -34,6 +34,7 @@ protected function setUp(): void $this->getPilotData('BMI221', true, null, null, '777'), $this->getPilotData('BMI222', true, null, null, '12a4'), $this->getPilotData('BMI223', true, null, null, '7778'), + $this->getPilotData('BAW999', true, aircraftType: 'XYZ'), ]; Bus::fake(); @@ -67,6 +68,27 @@ public function testItAddsNewAircraftFromDataFeed() ); } + public function testItAddsNewAircraftWithUnknownAircraftType() + { + Event::fake(); + $this->fakeNetworkDataReturn(); + $this->service->updateNetworkData(); + $this->assertDatabaseHas( + 'network_aircraft', + array_merge( + $this->getTransformedPilotData( + 'BAW999', + aircraftType: 'XYZ' + ), + [ + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + 'transponder_last_updated_at' => Carbon::now() + ] + ), + ); + } + public function testItUpdatesExistingAircraftFromDataFeed() { Event::fake(); @@ -340,9 +362,9 @@ private function getPilotData( bool $hasFlightplan, float $latitude = null, float $longitude = null, - string $transponder = null - ): array - { + string $transponder = null, + string $aircraftType = 'B738', + ): array { return [ 'callsign' => $callsign, 'cid' => self::ACTIVE_USER_CID, @@ -353,8 +375,8 @@ private function getPilotData( 'transponder' => $transponder ?? '0457', 'flight_plan' => $hasFlightplan ? [ - 'aircraft' => 'H/B738/M', - 'aircraft_short' => 'B738', + 'aircraft' => sprintf('H/%s/M', $aircraftType), + 'aircraft_short' => $aircraftType, 'departure' => 'EGKK', 'arrival' => 'EGPH', 'altitude' => '15001', @@ -369,10 +391,10 @@ private function getPilotData( private function getTransformedPilotData( string $callsign, bool $hasFlightplan = true, - string $transponder = null - ): array - { - $pilot = $this->getPilotData($callsign, $hasFlightplan, null, null, $transponder); + string $transponder = null, + string $aircraftType = 'B738' + ): array { + $pilot = $this->getPilotData($callsign, $hasFlightplan, null, null, $transponder, $aircraftType); $baseData = [ 'callsign' => $pilot['callsign'], 'cid' => $pilot['cid'], @@ -394,6 +416,14 @@ private function getTransformedPilotData( 'planned_altitude' => $pilot['flight_plan']['altitude'], 'planned_flighttype' => $pilot['flight_plan']['flight_rules'], 'planned_route' => $pilot['flight_plan']['route'], + 'remarks' => $pilot['flight_plan']['remarks'], + 'aircraft_id' => $pilot['flight_plan']['aircraft_short'] === 'B738' ? 1 : null, + 'airline_id' => match (Str::substr($pilot['callsign'], 0, 3)) { + 'BAW' => 1, + 'SHT' => 2, + 'VIR' => 3, + default => null, + }, ] ); } diff --git a/tests/app/Services/Stand/ArrivalAllocationServiceTest.php b/tests/app/Services/Stand/ArrivalAllocationServiceTest.php index 5a7ae29c4..8e61588e1 100644 --- a/tests/app/Services/Stand/ArrivalAllocationServiceTest.php +++ b/tests/app/Services/Stand/ArrivalAllocationServiceTest.php @@ -4,15 +4,15 @@ use App\Allocator\Stand\AirlineAircraftArrivalStandAllocator; use App\Allocator\Stand\AirlineAircraftTerminalArrivalStandAllocator; -use App\Allocator\Stand\AirlineArrivalStandAllocator; +use App\Allocator\Stand\AirlineGeneralArrivalStandAllocator; use App\Allocator\Stand\AirlineCallsignArrivalStandAllocator; use App\Allocator\Stand\AirlineCallsignSlugArrivalStandAllocator; use App\Allocator\Stand\AirlineCallsignSlugTerminalArrivalStandAllocator; use App\Allocator\Stand\AirlineCallsignTerminalArrivalStandAllocator; use App\Allocator\Stand\AirlineDestinationArrivalStandAllocator; use App\Allocator\Stand\AirlineDestinationTerminalArrivalStandAllocator; -use App\Allocator\Stand\AirlineTerminalArrivalStandAllocator; -use App\Allocator\Stand\ArrivalStandAllocatorInterface; +use App\Allocator\Stand\AirlineGeneralTerminalArrivalStandAllocator; +use App\Allocator\Stand\ArrivalStandAllocator; use App\Allocator\Stand\CallsignFlightplanReservedArrivalStandAllocator; use App\Allocator\Stand\CargoAirlineFallbackStandAllocator; use App\Allocator\Stand\CargoFlightArrivalStandAllocator; @@ -26,11 +26,14 @@ use App\Events\StandAssignedEvent; use App\Events\StandUnassignedEvent; use App\Models\Aircraft\Aircraft; +use App\Models\Airline\Airline; use App\Models\Stand\Stand; use App\Models\Stand\StandAssignment; use App\Models\Stand\StandReservation; +use App\Models\Vatsim\NetworkAircraft; use App\Services\NetworkAircraftService; use Carbon\Carbon; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; @@ -38,12 +41,15 @@ class ArrivalAllocationServiceTest extends BaseFunctionalTestCase { private readonly ArrivalAllocationService $service; + private readonly Airline $bmi; + public function setUp(): void { parent::setUp(); Event::fake(); $this->service = $this->app->make(ArrivalAllocationService::class); DB::table('network_aircraft')->delete(); + $this->bmi = Airline::factory()->create(['icao_code' => 'BMI']); } public function testItDeallocatesStandForDivertingAircraft() @@ -62,6 +68,8 @@ public function testItDeallocatesStandForDivertingAircraft() // London 'latitude' => 51.487202, 'longitude' => -0.466667, + 'airline_id' => $this->bmi->id, + 'aircraft_id' => 1, ] ); @@ -86,6 +94,8 @@ public function testItAllocatesANewStandForDivertingAircraft() // London 'latitude' => 51.487202, 'longitude' => -0.466667, + 'airline_id' => $this->bmi->id, + 'aircraft_id' => 1, ] ); @@ -111,6 +121,8 @@ public function testItDoesntDeallocateStandIfAircraftNotDiverting() // London 'latitude' => 51.487202, 'longitude' => -0.466667, + 'airline_id' => $this->bmi->id, + 'aircraft_id' => 1, ] ); @@ -135,6 +147,8 @@ public function testItDoesntDeallocateStandIfForDepartureAirport() // London 'latitude' => 51.487202, 'longitude' => -0.466667, + 'airline_id' => $this->bmi->id, + 'aircraft_id' => 1, ] ); @@ -156,6 +170,8 @@ public function testItDoesntDeallocateStandIfNoStandToDeallocate() // London 'latitude' => 51.487202, 'longitude' => -0.466667, + 'airline_id' => $this->bmi->id, + 'aircraft_id' => 1, ] ); @@ -176,19 +192,19 @@ public function testItHasAllocatorPreference() AirlineCallsignSlugArrivalStandAllocator::class, AirlineAircraftArrivalStandAllocator::class, AirlineDestinationArrivalStandAllocator::class, - AirlineArrivalStandAllocator::class, + AirlineGeneralArrivalStandAllocator::class, AirlineCallsignTerminalArrivalStandAllocator::class, AirlineCallsignSlugTerminalArrivalStandAllocator::class, AirlineAircraftTerminalArrivalStandAllocator::class, AirlineDestinationTerminalArrivalStandAllocator::class, - AirlineTerminalArrivalStandAllocator::class, + AirlineGeneralTerminalArrivalStandAllocator::class, CargoAirlineFallbackStandAllocator::class, OriginAirfieldStandAllocator::class, DomesticInternationalStandAllocator::class, FallbackArrivalStandAllocator::class, ], array_map( - fn(ArrivalStandAllocatorInterface $allocator) => get_class($allocator), + fn(ArrivalStandAllocator $allocator) => get_class($allocator), $this->service->getAllocators() ) ); @@ -219,6 +235,8 @@ public function testItAllocatesAStandFromAllocator() // London 'latitude' => 51.487202, 'longitude' => -0.466667, + 'airline_id' => $this->bmi->id, + 'aircraft_id' => 1, ] ); @@ -241,6 +259,8 @@ public function testItDoesntAllocateStandIfTimedOut() // London 'latitude' => 51.487202, 'longitude' => -0.466667, + 'airline_id' => $this->bmi->id, + 'aircraft_id' => 1, ] ); $aircraft->updated_at = Carbon::now()->subMinutes(30); @@ -265,6 +285,8 @@ public function testItDoesntAllocateStandIfPerformingCircuits() // London 'latitude' => 51.487202, 'longitude' => -0.466667, + 'airline_id' => $this->bmi->id, + 'aircraft_id' => 1, ] ); @@ -287,6 +309,8 @@ public function testItDoesntPerformAllocationIfStandTooFarFromAirfield() // Lambourne 'latitude' => 51.646099, 'longitude' => 0.151667, + 'airline_id' => $this->bmi->id, + 'aircraft_id' => 1, ] ); @@ -309,6 +333,8 @@ public function testItDoesntPerformAllocationIfAircraftHasNoGroundspeed() // Lambourne 'latitude' => 51.646099, 'longitude' => 0.151667, + 'airline_id' => $this->bmi->id, + 'aircraft_id' => 1, ] ); @@ -337,6 +363,8 @@ public function testItDoesntPerformAllocationIfNoStandAllocated() // Lambourne 'latitude' => 51.646099, 'longitude' => 0.151667, + 'airline_id' => $this->bmi->id, + 'aircraft_id' => 1, ] ); @@ -359,6 +387,8 @@ public function testItDoesntPerformAllocationIfStandAlreadyAssigned() // Lambourne 'latitude' => 51.646099, 'longitude' => 0.151667, + 'airline_id' => $this->bmi->id, + 'aircraft_id' => 1, ] ); StandAssignment::create( @@ -387,6 +417,8 @@ public function testItDoesntReturnAllocationIfAirfieldNotFound() // Lambourne 'latitude' => 51.646099, 'longitude' => 0.151667, + 'airline_id' => $this->bmi->id, + 'aircraft_id' => 1, ] ); @@ -409,6 +441,8 @@ public function testItDoesntPerformAllocationIfUnknownAircraftType() // Lambourne 'latitude' => 51.646099, 'longitude' => 0.151667, + 'airline_id' => $this->bmi->id, + 'aircraft_id' => null, ] ); @@ -433,6 +467,8 @@ public function testItDoesntPerformAllocationIfAircraftTypeNotStandAssignable() // Lambourne 'latitude' => 51.646099, 'longitude' => 0.151667, + 'airline_id' => $this->bmi->id, + 'aircraft_id' => 1, ] ); @@ -451,4 +487,95 @@ private function addStandAssignment(string $callsign, int $standId): void ] ); } + + public function testItReturnsRankedStandAllocations() + { + // Delete other stand + DB::table('stands')->delete(); + + $aircraft = new NetworkAircraft( + [ + 'callsign' => 'BAW221', + 'cid' => 1234, + 'planned_aircraft' => 'B738', + 'planned_aircraft_short' => 'B738', + 'planned_destairport' => 'EGLL', + 'planned_depairport' => 'LFPG', + 'groundspeed' => 150, + // Lambourne + 'latitude' => 51.646099, + 'longitude' => 0.151667, + 'airline_id' => 1, + 'aircraft_id' => 1, + ] + ); + + // Callsign specific + $stand1 = Stand::factory()->create(['airfield_id' => 1, 'assignment_priority' => 1]); + $stand1->airlines()->sync([1 => ['full_callsign' => '221']]); + $stand2 = Stand::factory()->create(['airfield_id' => 1, 'assignment_priority' => 1]); + $stand2->airlines()->sync([1 => ['full_callsign' => '221']]); + + // Callsign specfic, but with a lower priorityy + $stand3 = Stand::factory()->create(['airfield_id' => 1, 'assignment_priority' => 2]); + $stand3->airlines()->sync([1 => ['full_callsign' => '221', 'priority' => 101]]); + + // Generic + $stand4 = Stand::factory()->create(['airfield_id' => 1, 'assignment_priority' => 3]); + $stand4->airlines()->sync([1]); + + $expected = [ + AirlineCallsignArrivalStandAllocator::class => [ + 0 => [ + $stand1->id, + $stand2->id, + ], + 1 => [ + $stand3->id, + ], + ], + AirlineCallsignSlugArrivalStandAllocator::class => [], + AirlineAircraftArrivalStandAllocator::class => [], + AirlineDestinationArrivalStandAllocator::class => [], + AirlineGeneralArrivalStandAllocator::class => [ + 0 => [ + $stand4->id, + ], + ], + AirlineCallsignTerminalArrivalStandAllocator::class => [], + AirlineCallsignSlugTerminalArrivalStandAllocator::class => [], + AirlineAircraftTerminalArrivalStandAllocator::class => [], + AirlineDestinationTerminalArrivalStandAllocator::class => [], + AirlineGeneralTerminalArrivalStandAllocator::class => [], + CargoAirlineFallbackStandAllocator::class => [], + OriginAirfieldStandAllocator::class => [], + DomesticInternationalStandAllocator::class => [], + FallbackArrivalStandAllocator::class => [ + 0 => [ + $stand1->id, + $stand2->id, + ], + 1 => [ + $stand3->id, + ], + 2 => [ + $stand4->id, + ], + ], + ]; + + $this->assertEquals( + $expected, + $this->service->getAllocationRankingForAircraft($aircraft) + ->map( + fn(Collection $stands) => + $stands->map( + fn(Collection $standsForRank) => + $standsForRank->sortBy('id') + ->map(fn(Stand $stand) => $stand->id)->values() + ) + ) + ->toArray() + ); + } } diff --git a/tests/app/Services/Stand/StandStatusServiceTest.php b/tests/app/Services/Stand/StandStatusServiceTest.php index d9225b6fb..33ad8457e 100644 --- a/tests/app/Services/Stand/StandStatusServiceTest.php +++ b/tests/app/Services/Stand/StandStatusServiceTest.php @@ -5,7 +5,6 @@ use App\BaseFunctionalTestCase; use App\Models\Stand\Stand; use App\Models\Stand\StandAssignment; -use App\Models\Stand\StandRequest; use App\Models\Stand\StandReservation; use App\Models\Vatsim\NetworkAircraft; use App\Services\NetworkAircraftService;