diff --git a/app/Enums/ExamResultEnum.php b/app/Enums/ExamResultEnum.php index 0888148d7a..5f49dc93d1 100644 --- a/app/Enums/ExamResultEnum.php +++ b/app/Enums/ExamResultEnum.php @@ -5,6 +5,7 @@ enum ExamResultEnum: string { case Pass = 'P'; + case PartialPass = 'S'; case Fail = 'F'; case Incomplete = 'N'; @@ -12,8 +13,27 @@ public function human(): string { return match ($this) { self::Pass => 'Pass', + self::PartialPass => 'Partial Pass', self::Fail => 'Fail', self::Incomplete => 'Incomplete', }; } + + public static function atcOptions(): array + { + return [ + self::Pass->value => self::Pass->human(), + self::Fail->value => self::Fail->human(), + self::Incomplete->value => self::Incomplete->human(), + ]; + } + + public static function pilotOptions(): array + { + return [ + self::Pass->value => self::Pass->human(), + self::PartialPass->value => self::PartialPass->human(), + self::Fail->value => self::Fail->human(), + ]; + } } diff --git a/app/Enums/PilotExamType.php b/app/Enums/PilotExamType.php new file mode 100644 index 0000000000..5a564cf282 --- /dev/null +++ b/app/Enums/PilotExamType.php @@ -0,0 +1,38 @@ + 'P1_PPL(A)', + self::P2 => 'P2_SEIR(A)', + self::P3 => 'P3_CMEL(A)', + }; + } + + public function prerequisiteRating(): string + { + return match ($this) { + self::P1 => 'P0', + self::P2 => 'P1', + self::P3 => 'P2', + }; + } + + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + public static function labelFor(string $type): string + { + return self::from($type)->label(); + } +} diff --git a/app/Filament/Training/Pages/Exam/ConductExam.php b/app/Filament/Training/Pages/Exam/ConductExam.php index 8e97e42390..94b27ef306 100644 --- a/app/Filament/Training/Pages/Exam/ConductExam.php +++ b/app/Filament/Training/Pages/Exam/ConductExam.php @@ -219,11 +219,10 @@ public function examResultForm(Form $form): Form Select::make('exam_result') ->label('Result') - ->options([ - ExamResultEnum::Pass->value => ExamResultEnum::Pass->human(), - ExamResultEnum::Fail->value => ExamResultEnum::Fail->human(), - ExamResultEnum::Incomplete->value => ExamResultEnum::Incomplete->human(), - ]) + ->options(fn () => $this->examBooking->isPilotExam() + ? ExamResultEnum::pilotOptions() + : ExamResultEnum::atcOptions() + ) ->live() ->columnSpan(3) ->required(), @@ -284,6 +283,11 @@ public function completeExam() public function validateGradesBeforeSubmission(string $result): bool { + // Pilot exams should pass validation always + if ($this->examBooking->isPilotExam()) { + return true; + } + $formData = collect($this->form->getState())['form']; $hasNotAssessed = collect($formData)->contains( @@ -321,7 +325,7 @@ public function save($withNotification = true): void { $this->isSaving = true; - $formData = collect($this->form->getState())['form']; + $formData = collect($this->form->getState())['form'] ?? []; $flattenedFormData = collect($formData)->map( fn ($item, $key) => [ diff --git a/app/Filament/Training/Pages/Exam/ExamHistory.php b/app/Filament/Training/Pages/Exam/ExamHistory.php index 616f9167ae..7db75e5bbd 100644 --- a/app/Filament/Training/Pages/Exam/ExamHistory.php +++ b/app/Filament/Training/Pages/Exam/ExamHistory.php @@ -2,6 +2,7 @@ namespace App\Filament\Training\Pages\Exam; +use App\Enums\PilotExamType; use App\Filament\Training\Pages\Exam\Widgets\ExamOverview; use App\Services\Training\ExamHistoryService; use Filament\Forms; @@ -59,7 +60,7 @@ public function table(Table $table): Table Forms\Components\DatePicker::make('exam_date_to')->label('To'), ])->query(fn ($query, array $data) => $examHistoryService->applyExamDateFilter($query, $data))->label('Exam date'), Filter::make('position')->form([ - Forms\Components\Select::make('position') + Forms\Components\Select::make('atc_positions') ->options([ 'OBS' => 'Observer', 'TWR' => 'Tower', @@ -67,7 +68,14 @@ public function table(Table $table): Table 'CTR' => 'Enroute', ]) ->multiple() - ->label('Position'), + ->label('ATC position'), + Forms\Components\Select::make('pilot_positions') + ->options(collect(PilotExamType::cases()) + ->mapWithKeys(fn ($type) => [$type->label() => $type->label()]) + ->toArray() + ) + ->multiple() + ->label('Pilot rating'), ])->query(fn ($query, array $data) => $examHistoryService->applyPositionFilter($query, $data))->label('Position'), Filter::make('conducted_by_me')->form([ Forms\Components\Checkbox::make('conducted_by_me') diff --git a/app/Filament/Training/Pages/Exam/ExamSetup.php b/app/Filament/Training/Pages/Exam/ExamSetup.php index ee64c83554..37d6f08fbd 100644 --- a/app/Filament/Training/Pages/Exam/ExamSetup.php +++ b/app/Filament/Training/Pages/Exam/ExamSetup.php @@ -2,9 +2,11 @@ namespace App\Filament\Training\Pages\Exam; +use App\Enums\PilotExamType; use App\Models\Atc\Position; use App\Models\Cts\Member; use App\Models\Cts\Position as CtsPosition; +use App\Models\Mship\Account; use App\Models\Training\TrainingPosition\TrainingPosition; use App\Repositories\Cts\ExamResultRepository; use App\Repositories\Cts\SessionRepository; @@ -39,10 +41,13 @@ public static function canAccess(): bool public ?array $dataOBS = []; + public ?array $dataPilot = []; + public function mount(): void { $this->form->fill(); $this->formOBS->fill(); + $this->formPilot->fill(); } protected function getForms(): array @@ -50,6 +55,7 @@ protected function getForms(): array return [ 'formOBS', 'form', + 'formPilot', ]; } @@ -197,6 +203,74 @@ public function form(Form $form): Form ->statePath('data'); } + public function setupExamPilot() + { + $validated = $this->validate([ + 'dataPilot.exam_type' => 'required', + 'dataPilot.student_pilot' => 'required', + ]); + + $ctsMember = Member::where('id', $validated['dataPilot']['student_pilot'])->first(); + + $service = new ExamForwardingService; + $service->forwardForPilotExam($ctsMember, $validated['dataPilot']['exam_type'], Auth::user()->id); + $service->notifySuccess($validated['dataPilot']['exam_type']); + + return redirect()->route('filament.training.pages.exam-setup'); + } + + public function formPilot(Form $form): Form + { + return $form + ->schema([ + Section::make('Exam Setup - Pilot') + ->schema([ + Select::make('exam_type') + ->label('Exam') + ->options(collect(PilotExamType::cases()) + ->mapWithKeys(fn ($type) => [$type->value => $type->label()]) + ->toArray() + ) + ->required() + ->live() + ->afterStateUpdated(fn (callable $set) => $set('student_pilot', null)), + + Select::make('student_pilot') + ->label('Student') + ->getSearchResultsUsing(function (string $search, Get $get): array { + $examType = $get('exam_type'); + if (! $examType) { + return []; + } + + $prerequisiteRating = PilotExamType::from($examType)->prerequisiteRating(); + + $eligibleCids = Account::whereHas('qualifications', fn ($q) => $q + ->where('type', 'pilot') + ->where('code', $prerequisiteRating) + )->pluck('id'); + + return Member::whereIn('cid', $eligibleCids) + ->where(fn ($query) => $query + ->where('name', 'LIKE', "%{$search}%") + ->orWhere('cid', 'LIKE', "%{$search}%") + ) + ->limit(25) + ->get() + ->mapWithKeys(fn ($member) => [$member->id => "{$member->name} ({$member->cid})"]) + ->toArray(); + }) + ->getOptionLabelUsing(fn ($value): ?string => Member::find($value)?->name) + ->searchable() + ->placeholder('Select an exam type first') + ->disabled(fn (Get $get): bool => ! $get('exam_type')) + ->required() + ->live(), + ]), + ]) + ->statePath('dataPilot'); + } + protected function generateStudentOptions(string $positionCallsign, int $daysConsideredRecent, Collection $recentPassedStudentIds, ?Collection $pendingStudentIds = null): Collection { $recentCompletedSessions = (new SessionRepository)->getRecentCompletedSessionsForPosition($positionCallsign, daysConsideredRecent: $daysConsideredRecent); diff --git a/app/Filament/Training/Pages/Exam/ViewExamReport.php b/app/Filament/Training/Pages/Exam/ViewExamReport.php index 8cebec7a8b..fb13f40482 100644 --- a/app/Filament/Training/Pages/Exam/ViewExamReport.php +++ b/app/Filament/Training/Pages/Exam/ViewExamReport.php @@ -92,25 +92,24 @@ public function infolist(Infolist $infolist): Infolist Select::make('previous_exam_result') ->label('Previous Result') ->default($this->practicalResult->result) - ->options([ - ExamResultEnum::Pass->value => ExamResultEnum::Pass->human(), - ExamResultEnum::Fail->value => ExamResultEnum::Fail->human(), - ExamResultEnum::Incomplete->value => ExamResultEnum::Incomplete->human(), - ]) + ->options(fn () => $this->practicalResult->examBooking->isPilotExam() + ? ExamResultEnum::pilotOptions() + : ExamResultEnum::atcOptions() + ) ->required() ->disabled() ->columns(1) ->dehydrated(true), + Select::make('exam_result') ->label('New Result') ->default($this->practicalResult->result) ->live() ->columns(1) - ->options([ - ExamResultEnum::Pass->value => ExamResultEnum::Pass->human(), - ExamResultEnum::Fail->value => ExamResultEnum::Fail->human(), - ExamResultEnum::Incomplete->value => ExamResultEnum::Incomplete->human(), - ]) + ->options(fn () => $this->practicalResult->examBooking->isPilotExam() + ? ExamResultEnum::pilotOptions() + : ExamResultEnum::atcOptions() + ) ->required(), Textarea::make('reason') ->label('Reason for exam result change') @@ -120,7 +119,8 @@ public function infolist(Infolist $infolist): Infolist ]), \Filament\Forms\Components\Section::make('Exam Criteria') - ->visible(fn ($get) => $get('exam_result') !== $this->practicalResult->result + ->visible(fn ($get) => ! $this->practicalResult->examBooking->isPilotExam() + && $get('exam_result') !== $this->practicalResult->result ) ->schema(function () { $criteria = ExamCriteria::byType($this->practicalResult->examBooking->exam)->get(); @@ -162,6 +162,7 @@ public function infolist(Infolist $infolist): Infolist ->schema([ TextEntry::make('result')->label('Result')->badge()->color(fn ($state) => match ($state) { 'Passed' => 'success', + 'Partial Pass' => 'warning', 'Failed' => 'danger', 'Incomplete' => 'warning', default => 'gray', diff --git a/app/Filament/Training/Pages/Exam/Widgets/ExamOverview.php b/app/Filament/Training/Pages/Exam/Widgets/ExamOverview.php index 113881ac11..61f3fb3aba 100644 --- a/app/Filament/Training/Pages/Exam/Widgets/ExamOverview.php +++ b/app/Filament/Training/Pages/Exam/Widgets/ExamOverview.php @@ -37,7 +37,7 @@ protected function getCards(): array ->description("$overall->passed Passed, $overall->failed Failed, $overall->incomplete Incomplete"), ]; - $examOrder = ['OBS', 'TWR', 'APP', 'CTR']; + $examOrder = ['OBS', 'TWR', 'APP', 'CTR', 'P1', 'P2', 'P3']; foreach ($examOrder as $exam) { $results = $examStats->get($exam, collect()); diff --git a/app/Livewire/Training/AcceptedExamsTable.php b/app/Livewire/Training/AcceptedExamsTable.php index 2f179bf7ae..d780ae5fa6 100644 --- a/app/Livewire/Training/AcceptedExamsTable.php +++ b/app/Livewire/Training/AcceptedExamsTable.php @@ -59,7 +59,7 @@ public function table(Table $table): Table if ($examBooking->exam === 'OBS') { return false; } - // use CTS member ID rather than Core acocunt ID. + // use CTS member ID rather than Core account ID. $memberId = auth()->user()->member->id; return app(ExamAnnouncementService::class)->canPostAnnouncement($examBooking, $memberId); diff --git a/app/Livewire/Training/ExamRequestsTable.php b/app/Livewire/Training/ExamRequestsTable.php index d6928f4df0..59b08b6f2e 100644 --- a/app/Livewire/Training/ExamRequestsTable.php +++ b/app/Livewire/Training/ExamRequestsTable.php @@ -420,7 +420,7 @@ protected function getFilteredExamRequestsQuery() */ protected function getAllowedExamLevels(): array { - $examLevels = ['OBS', 'TWR', 'APP', 'CTR']; // Common exam levels + $examLevels = ['OBS', 'TWR', 'APP', 'CTR', 'P1', 'P2', 'P3']; // Common exam levels $allowedLevels = []; foreach ($examLevels as $level) { diff --git a/app/Models/Cts/ExamBooking.php b/app/Models/Cts/ExamBooking.php index 663ce37aa4..b351bb736d 100644 --- a/app/Models/Cts/ExamBooking.php +++ b/app/Models/Cts/ExamBooking.php @@ -2,6 +2,7 @@ namespace App\Models\Cts; +use App\Enums\PilotExamType; use App\Models\Mship\Account; use App\Models\Mship\Qualification; use Carbon\Carbon; @@ -60,10 +61,17 @@ public function endDate(): Attribute public function studentQualification(): Attribute { return Attribute::make( - get: fn ($value) => Qualification::ofType('atc')->where('vatsim', $this->student_rating)->first() + get: fn ($value) => $this->isPilotExam() + ? Qualification::ofType('pilot')->where('vatsim', $this->student_rating)->first() + : Qualification::ofType('atc')->where('vatsim', $this->student_rating)->first() ); } + public function isPilotExam(): bool + { + return in_array($this->exam, PilotExamType::values()); + } + #[Scope] protected function conductable() { diff --git a/app/Models/Cts/ExaminerSettings.php b/app/Models/Cts/ExaminerSettings.php index 6a71bb604f..7c4f2b8318 100644 --- a/app/Models/Cts/ExaminerSettings.php +++ b/app/Models/Cts/ExaminerSettings.php @@ -55,6 +55,21 @@ public function scopeCtr($query) return $query->where('S3', '=', 1); } + public function scopeP1($query) + { + return $query->where('P1', '=', 1); + } + + public function scopeP2($query) + { + return $query->where('P2', '=', 1); + } + + public function scopeP3($query) + { + return $query->where('P3', '=', 1); + } + public function scopeAtc($query) { return $query->where('OBS', '=', 1) // OBS to S1 examiner diff --git a/app/Models/Cts/PracticalResult.php b/app/Models/Cts/PracticalResult.php index 82e114ffa8..b156ab59f1 100644 --- a/app/Models/Cts/PracticalResult.php +++ b/app/Models/Cts/PracticalResult.php @@ -17,6 +17,8 @@ class PracticalResult extends Model public const PASSED = 'P'; + public const PARTIAL_PASS = 'S'; + public const FAILED = 'F'; public const INCOMPLETE = 'N'; @@ -43,6 +45,7 @@ public function resultHuman(): string { return match ($this->result) { self::PASSED => 'Passed', + self::PARTIAL_PASS => 'Partial Pass', self::FAILED => 'Failed', self::INCOMPLETE => 'Incomplete', self::FAILRESUBMIT => 'Failed - Resubmit', diff --git a/app/Services/Training/ExamAnnouncementService.php b/app/Services/Training/ExamAnnouncementService.php index 25b4a1d91f..2a1421e0d4 100644 --- a/app/Services/Training/ExamAnnouncementService.php +++ b/app/Services/Training/ExamAnnouncementService.php @@ -2,6 +2,7 @@ namespace App\Services\Training; +use App\Enums\PilotExamType; use App\Libraries\Discord; use App\Models\Cts\ExamBooking; use Carbon\CarbonImmutable; @@ -33,6 +34,15 @@ public function postAnnouncement(ExamBooking $examBooking, array $data): void } public function buildMessage(ExamBooking $examBooking, array $data): string + { + if ($examBooking->isPilotExam()) { + return $this->buildPilotMessage($examBooking, $data); + } + + return $this->buildAtcMessage($examBooking, $data); + } + + public function buildAtcMessage(ExamBooking $examBooking, array $data) { $startUtc = CarbonImmutable::parse($examBooking->start_date)->utc(); $unix = $startUtc->getTimestamp(); @@ -48,6 +58,24 @@ public function buildMessage(ExamBooking $examBooking, array $data): string .$notesBlock; } + public function buildPilotMessage(ExamBooking $examBooking, array $data): string + { + $startUtc = CarbonImmutable::parse($examBooking->start_date)->utc(); + $unix = $startUtc->getTimestamp(); + + $mentions = $this->buildMentions($data); + + $notes = trim($data['notes'] ?? ''); + $notesBlock = $notes !== '' ? "\n\n**Notes:**\n{$notes}" : ''; + + $label = PilotExamType::labelFor($examBooking->exam); + + return ($mentions !== '' ? $mentions."\n" : '') + ."**Upcoming {$label} Exam**\n" + ."There will be a **{$label}** pilot exam on **** ()" + .$notesBlock; + } + private function buildMentions(array $data): string { $pilotRoleId = config('training.discord.exam_pilot_role_id'); diff --git a/app/Services/Training/ExamForwardingService.php b/app/Services/Training/ExamForwardingService.php index f330ae1b89..ff4671445f 100644 --- a/app/Services/Training/ExamForwardingService.php +++ b/app/Services/Training/ExamForwardingService.php @@ -2,6 +2,7 @@ namespace App\Services\Training; +use App\Enums\PilotExamType; use App\Models\Cts\ExamBooking; use App\Models\Cts\ExamSetup; use App\Models\Cts\Member; @@ -105,6 +106,48 @@ public function forwardForObsExam(Member $ctsMember, TrainingPosition $trainingP ]; } + /** + * Forward a member for a pilot exam by creating exam setup and booking records. + * + * @param Member $ctsMember The CTS member to forward for exam + * @param string $examType The pilot exam type (P1, P2, P3) + * @param int $setupByUserId The ID of the user setting up the exam + * @return array Array containing 'setup' and 'examBooking' keys + * + * @throws \Exception + */ + public function forwardForPilotExam(Member $ctsMember, string $examType, int $setupByUserId): array + { + $setup = ExamSetup::create([ + 'rts_id' => 13, + 'student_id' => $ctsMember->id, + 'position_1' => PilotExamType::labelFor($examType), + 'position_2' => null, + 'exam' => $examType, + 'setup_by' => $setupByUserId, + 'setup_date' => Carbon::now()->format('Y-m-d H:i:s'), + 'response' => 1, + 'dealt_by' => $setupByUserId, + 'dealt_date' => Carbon::now()->format('Y-m-d H:i:s'), + ]); + + $examBooking = ExamBooking::create([ + 'rts_id' => 13, + 'student_id' => $ctsMember->id, + 'student_rating' => $ctsMember->account->qualification_pilot?->vatsim, + 'position_1' => PilotExamType::labelFor($examType), + 'position_2' => null, + 'exam' => $examType, + ]); + + $setup->update(['bookid' => $examBooking->id]); + + return [ + 'setup' => $setup, + 'examBooking' => $examBooking, + ]; + } + /** * Notify about successful exam forwarding */ diff --git a/app/Services/Training/ExamHistoryService.php b/app/Services/Training/ExamHistoryService.php index 3c41786654..524df4a857 100644 --- a/app/Services/Training/ExamHistoryService.php +++ b/app/Services/Training/ExamHistoryService.php @@ -23,6 +23,9 @@ public function getTypesToShow(Account $user): Collection 'twr' => 'training.exams.conduct.twr', 'app' => 'training.exams.conduct.app', 'ctr' => 'training.exams.conduct.ctr', + 'p1' => 'training.exams.conduct.p1', + 'p2' => 'training.exams.conduct.p2', + 'p3' => 'training.exams.conduct.p3', ]; return collect($permissionMap) @@ -35,6 +38,7 @@ public function getResultBadgeColor(string $result): string return match ($result) { 'Passed' => 'success', 'Failed' => 'danger', + 'Partial Pass' => 'warning', 'Incomplete' => 'warning', 'Failed - Resubmit' => 'danger', default => 'gray', @@ -50,7 +54,9 @@ public function applyExamDateFilter(Builder $query, array $data): Builder public function applyPositionFilter(Builder $query, array $data): Builder { - return $query->when($data['position'] ?? null, function (Builder $query, array $positions) { + $positions = array_merge($data['atc_positions'] ?? [], $data['pilot_positions'] ?? []); + + return $query->when(! empty($positions), function (Builder $query) use ($positions) { $query->whereHas('examBooking', function (Builder $q) use ($positions) { $q->where(function (Builder $subQuery) use ($positions) { foreach ($positions as $position) { diff --git a/app/Services/Training/ExamResubmissionService.php b/app/Services/Training/ExamResubmissionService.php index 8acd1d986d..f801ecb98e 100644 --- a/app/Services/Training/ExamResubmissionService.php +++ b/app/Services/Training/ExamResubmissionService.php @@ -11,14 +11,16 @@ class ExamResubmissionService // Handles resubmitting a member for an exam if they recieve an incomplete result public function handle(ExamBooking $examBooking, string $result, int $userId): void { - if ($result !== ExamResultEnum::Incomplete->value) { + if (! in_array($result, [ExamResultEnum::Incomplete->value, ExamResultEnum::PartialPass->value])) { return; } $service = new ExamForwardingService; $student = $examBooking->student; - if ($examBooking->exam === 'OBS') { + if ($examBooking->isPilotExam()) { + $service->forwardForPilotExam($student, $examBooking->exam, $userId); + } elseif ($examBooking->exam === 'OBS') { $trainingPosition = TrainingPosition::whereJsonContains('cts_positions', $examBooking->position_1)->firstOrFail(); $service->forwardForObsExam($student, $trainingPosition); } else { diff --git a/resources/views/filament/training/pages/exam-setup.blade.php b/resources/views/filament/training/pages/exam-setup.blade.php index 8187576ec7..c809679966 100644 --- a/resources/views/filament/training/pages/exam-setup.blade.php +++ b/resources/views/filament/training/pages/exam-setup.blade.php @@ -14,4 +14,12 @@ Setup exam + +
+ {{ $this->formPilot }} + +
+ Setup exam +
+
diff --git a/resources/views/filament/training/pages/view-exam-report.blade.php b/resources/views/filament/training/pages/view-exam-report.blade.php index 7fc8809da0..ba4117b959 100644 --- a/resources/views/filament/training/pages/view-exam-report.blade.php +++ b/resources/views/filament/training/pages/view-exam-report.blade.php @@ -1,7 +1,9 @@ {{ $this->infolist }} -

Grades

+ @if (!$this->practicalResult->examBooking->isPilotExam()) +

Grades

- {{ $this->criteriaInfoList}} -
+ {{ $this->criteriaInfoList }} + @endif + \ No newline at end of file diff --git a/tests/Feature/TrainingPanel/Exams/ConductExamPilotTest.php b/tests/Feature/TrainingPanel/Exams/ConductExamPilotTest.php new file mode 100644 index 0000000000..77ba448a7c --- /dev/null +++ b/tests/Feature/TrainingPanel/Exams/ConductExamPilotTest.php @@ -0,0 +1,210 @@ +withQualification()->create(); + $student = Member::factory()->create(['id' => $account->id, 'cid' => $account->id]); + + $exam = ExamBooking::factory()->create([ + 'taken' => 1, + 'finished' => ExamBooking::NOT_FINISHED_FLAG, + 'exam' => $examType, + 'student_id' => $student->id, + 'student_rating' => Qualification::ofType('pilot')->where('code', 'P0')->first()?->vatsim ?? 0, + ]); + + $exam->examiners()->create([ + 'examid' => $exam->id, + 'senior' => $this->panelUser->id, + ]); + + return [$account, $student, $exam]; + } + + #[Test] + public function it_loads_pilot_exam_if_authorised() + { + [$account, $student, $exam] = $this->createPilotExamBooking('P1'); + $this->panelUser->givePermissionTo('training.exams.conduct.p1'); + + Livewire::actingAs($this->panelUser) + ->test(ConductExam::class, ['examId' => $exam->id]) + ->assertSuccessful(); + } + + #[Test] + public function it_does_not_load_pilot_exam_without_permission() + { + [$account, $student, $exam] = $this->createPilotExamBooking('P1'); + + Livewire::actingAs($this->panelUser) + ->test(ConductExam::class, ['examId' => $exam->id]) + ->assertForbidden(); + } + + #[Test] + public function it_does_not_load_p2_exam_with_only_p1_permission() + { + [$account, $student, $exam] = $this->createPilotExamBooking('P2'); + $this->panelUser->givePermissionTo('training.exams.conduct.p1'); + + Livewire::actingAs($this->panelUser) + ->test(ConductExam::class, ['examId' => $exam->id]) + ->assertForbidden(); + } + + #[Test] + public function it_shows_pilot_result_options_including_partial_pass() + { + [$account, $student, $exam] = $this->createPilotExamBooking('P1'); + $this->panelUser->givePermissionTo('training.exams.conduct.p1'); + + $component = Livewire::actingAs($this->panelUser) + ->test(ConductExam::class, ['examId' => $exam->id]) + ->assertSuccessful(); + + // Partial Pass should be available for pilot exams + $component->assertSee('Partial Pass'); + } + + #[Test] + public function it_does_not_show_partial_pass_for_atc_exams() + { + $account = Account::factory()->create(); + $student = Member::factory()->create(['id' => $account->id, 'cid' => $account->id]); + $exam = ExamBooking::factory()->create([ + 'taken' => 1, + 'finished' => ExamBooking::NOT_FINISHED_FLAG, + 'exam' => 'TWR', + 'student_id' => $student->id, + 'student_rating' => Qualification::code('S1')->first()->vatsim, + ]); + $exam->examiners()->create(['examid' => $exam->id, 'senior' => $this->panelUser->id]); + $this->panelUser->givePermissionTo('training.exams.conduct.twr'); + + $component = Livewire::actingAs($this->panelUser) + ->test(ConductExam::class, ['examId' => $exam->id]) + ->assertSuccessful(); + + $component->assertDontSee('Partial Pass'); + } + + #[Test] + public function it_can_submit_pilot_exam_with_pass_result() + { + [$account, $student, $exam] = $this->createPilotExamBooking('P1'); + $this->panelUser->givePermissionTo('training.exams.conduct.p1'); + + Livewire::actingAs($this->panelUser) + ->test(ConductExam::class, ['examId' => $exam->id]) + ->set('examResultData.exam_result', ExamResultEnum::Pass->value) + ->set('examResultData.additional_comments', 'Student passed all sections.') + ->call('completeExam') + ->assertHasNoFormErrors(formName: 'examResultForm'); + + $this->assertDatabaseHas('practical_results', connection: 'cts', data: [ + 'examid' => $exam->id, + 'student_id' => $student->id, + 'result' => ExamResultEnum::Pass->value, + 'notes' => 'Student passed all sections.', + 'exam' => 'P1', + ]); + + Event::assertDispatched(PracticalExamCompleted::class, fn ($event) => $event->examBooking->id === $exam->id); + } + + #[Test] + public function it_can_submit_pilot_exam_with_partial_pass_result() + { + [$account, $student, $exam] = $this->createPilotExamBooking('P1'); + $this->panelUser->givePermissionTo('training.exams.conduct.p1'); + + Livewire::actingAs($this->panelUser) + ->test(ConductExam::class, ['examId' => $exam->id]) + ->set('examResultData.exam_result', ExamResultEnum::PartialPass->value) + ->set('examResultData.additional_comments', 'Some sections not completed.') + ->call('completeExam') + ->assertHasNoFormErrors(formName: 'examResultForm'); + + $this->assertDatabaseHas('practical_results', connection: 'cts', data: [ + 'examid' => $exam->id, + 'student_id' => $student->id, + 'result' => ExamResultEnum::PartialPass->value, + 'exam' => 'P1', + ]); + } + + #[Test] + public function it_can_submit_pilot_exam_with_fail_result() + { + [$account, $student, $exam] = $this->createPilotExamBooking('P2'); + $this->panelUser->givePermissionTo('training.exams.conduct.p2'); + + Livewire::actingAs($this->panelUser) + ->test(ConductExam::class, ['examId' => $exam->id]) + ->set('examResultData.exam_result', ExamResultEnum::Fail->value) + ->set('examResultData.additional_comments', 'Did not meet required standard.') + ->call('completeExam') + ->assertHasNoFormErrors(formName: 'examResultForm'); + + $this->assertDatabaseHas('practical_results', connection: 'cts', data: [ + 'examid' => $exam->id, + 'student_id' => $student->id, + 'result' => ExamResultEnum::Fail->value, + 'exam' => 'P2', + ]); + } + + #[Test] + public function it_marks_exam_as_finished_after_submission() + { + [$account, $student, $exam] = $this->createPilotExamBooking('P1'); + $this->panelUser->givePermissionTo('training.exams.conduct.p1'); + + Livewire::actingAs($this->panelUser) + ->test(ConductExam::class, ['examId' => $exam->id]) + ->set('examResultData.exam_result', ExamResultEnum::Pass->value) + ->set('examResultData.additional_comments', '') + ->call('completeExam'); + + $exam->refresh(); + $this->assertEquals(ExamBooking::FINISHED_FLAG, $exam->finished); + } + + #[Test] + public function it_fires_practical_exam_completed_event_for_pilot_exam() + { + [$account, $student, $exam] = $this->createPilotExamBooking('P1'); + $this->panelUser->givePermissionTo('training.exams.conduct.p1'); + + Livewire::actingAs($this->panelUser) + ->test(ConductExam::class, ['examId' => $exam->id]) + ->set('examResultData.exam_result', ExamResultEnum::Pass->value) + ->set('examResultData.additional_comments', '') + ->call('completeExam'); + + Event::assertDispatched(PracticalExamCompleted::class); + } +} diff --git a/tests/Feature/TrainingPanel/Exams/ExamHistoryTest.php b/tests/Feature/TrainingPanel/Exams/ExamHistoryTest.php index 7ae0c50c0b..d0f34349c0 100644 --- a/tests/Feature/TrainingPanel/Exams/ExamHistoryTest.php +++ b/tests/Feature/TrainingPanel/Exams/ExamHistoryTest.php @@ -412,7 +412,7 @@ public function it_can_filter_by_position() ->test(ExamHistory::class) ->assertSuccessful() ->filterTable('position', [ - 'position' => ['TWR'], + 'atc_positions' => ['TWR'], ]); // Should find the TWR exam @@ -425,7 +425,7 @@ public function it_can_filter_by_position() // Reset and filter for multiple positions $component->resetTableFilters() ->filterTable('position', [ - 'position' => ['OBS', 'APP'], + 'atc_positions' => ['OBS', 'APP'], ]); // Should find OBS and APP exams diff --git a/tests/Unit/Training/ExamHistoryServiceTest.php b/tests/Unit/Training/ExamHistoryServiceTest.php index b9f52abaf1..673275a5a4 100644 --- a/tests/Unit/Training/ExamHistoryServiceTest.php +++ b/tests/Unit/Training/ExamHistoryServiceTest.php @@ -20,6 +20,9 @@ public function test_get_types_to_show_returns_only_levels_user_can_conduct(): v $user->shouldReceive('can')->with('training.exams.conduct.twr')->andReturn(false); $user->shouldReceive('can')->with('training.exams.conduct.app')->andReturn(true); $user->shouldReceive('can')->with('training.exams.conduct.ctr')->andReturn(false); + $user->shouldReceive('can')->with('training.exams.conduct.p1')->andReturn(false); + $user->shouldReceive('can')->with('training.exams.conduct.p2')->andReturn(false); + $user->shouldReceive('can')->with('training.exams.conduct.p3')->andReturn(false); $this->assertSame(['obs', 'app'], $service->getTypesToShow($user)->all()); } @@ -32,6 +35,7 @@ public function test_get_result_badge_color_maps_known_exam_results(): void $this->assertSame('success', $service->getResultBadgeColor('Passed')); $this->assertSame('danger', $service->getResultBadgeColor('Failed')); $this->assertSame('warning', $service->getResultBadgeColor('Incomplete')); + $this->assertSame('warning', $service->getResultBadgeColor('Partial Pass')); $this->assertSame('gray', $service->getResultBadgeColor('Unknown')); } }