From d208d68ad8d9a91adef5336961509818051c2f6a Mon Sep 17 00:00:00 2001 From: KakaPatria Date: Tue, 7 Apr 2026 17:30:55 +0700 Subject: [PATCH] fix: add Indonesian translations for password reset messages - fixes 'passwords.user' key display --- .phpunit.result.cache | 2 +- app/Http/Controllers/AdminController.php | 10 +- app/Http/Controllers/AlumniController.php | 12 +- app/Http/Controllers/BKController.php | 10 +- .../Controllers/RekomendasiController.php | 150 +++++++++-- lang/id/passwords.php | 10 + resources/views/rekomendasi/hasil.blade.php | 149 ++++++++++- tests/Feature/Auth/RegistrationTest.php | 2 + tests/Feature/CrudValidationTest.php | 116 ++++++++ .../Feature/ExplainableRecommendationTest.php | 252 ++++++++++++++++++ tests/Feature/RekomendasiTest.php | 2 +- tests/Unit/RekomendasiAlgorithmTest.php | 2 + 12 files changed, 680 insertions(+), 37 deletions(-) create mode 100644 lang/id/passwords.php create mode 100644 tests/Feature/CrudValidationTest.php create mode 100644 tests/Feature/ExplainableRecommendationTest.php diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 022401b..8c75091 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":[],"times":{"Tests\\Feature\\RekomendasiTest::test_high_math_and_coding_prefers_teknologi_informasi":17.81,"Tests\\Feature\\RekomendasiTest::test_high_language_prefers_bahasa_komunikasi":0.186}} \ No newline at end of file +{"version":2,"defects":{"Tests\\Unit\\RekomendasiAlgorithmTest::test_prestasi_scoring_tinggi":7,"Tests\\Unit\\RekomendasiAlgorithmTest::test_prestasi_scoring_sedang":7,"Tests\\Feature\\Auth\\RegistrationTest::test_new_users_can_register":7,"Tests\\Feature\\RekomendasiTest::test_high_math_and_coding_prefers_teknologi_informasi":7,"Tests\\Feature\\RekomendasiTest::test_high_language_prefers_bahasa_komunikasi":7,"Tests\\Feature\\ExplainableRecommendationTest::test_explanation_contains_meaningful_text":7,"Tests\\Feature\\ExplainableRecommendationTest::test_explanation_displayed_in_view":7,"Tests\\Feature\\Auth\\PasswordResetMessageTest::test_forgot_password_shows_indonesian_error_for_invalid_email":7,"Tests\\Feature\\Auth\\PasswordResetMessageTest::test_forgot_password_form_displays_error":8},"times":{"Tests\\Feature\\RekomendasiTest::test_high_math_and_coding_prefers_teknologi_informasi":0.064,"Tests\\Feature\\RekomendasiTest::test_high_language_prefers_bahasa_komunikasi":0.069,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.013,"Tests\\Unit\\RekomendasiAlgorithmTest::test_minat_mapping_logika_komputer":0.025,"Tests\\Unit\\RekomendasiAlgorithmTest::test_minat_mapping_alam_tanaman":0,"Tests\\Unit\\RekomendasiAlgorithmTest::test_minat_mapping_bisnis":0,"Tests\\Unit\\RekomendasiAlgorithmTest::test_nilai_kategori_tinggi":0,"Tests\\Unit\\RekomendasiAlgorithmTest::test_nilai_kategori_sedang":0,"Tests\\Unit\\RekomendasiAlgorithmTest::test_nilai_kategori_rendah":0.001,"Tests\\Unit\\RekomendasiAlgorithmTest::test_prestasi_scoring_tinggi":0.003,"Tests\\Unit\\RekomendasiAlgorithmTest::test_prestasi_scoring_sedang":0,"Tests\\Unit\\RekomendasiAlgorithmTest::test_prestasi_scoring_minimal":0.001,"Tests\\Feature\\Auth\\AuthenticationTest::test_login_screen_can_be_rendered":0.092,"Tests\\Feature\\Auth\\AuthenticationTest::test_users_can_authenticate_using_the_login_screen":0.184,"Tests\\Feature\\Auth\\AuthenticationTest::test_users_can_not_authenticate_with_invalid_password":0.234,"Tests\\Feature\\Auth\\AuthenticationTest::test_users_can_logout":0.022,"Tests\\Feature\\Auth\\EmailVerificationTest::test_email_verification_screen_can_be_rendered":0.024,"Tests\\Feature\\Auth\\EmailVerificationTest::test_email_can_be_verified":0.019,"Tests\\Feature\\Auth\\EmailVerificationTest::test_email_is_not_verified_with_invalid_hash":0.026,"Tests\\Feature\\Auth\\PasswordConfirmationTest::test_confirm_password_screen_can_be_rendered":0.042,"Tests\\Feature\\Auth\\PasswordConfirmationTest::test_password_can_be_confirmed":0.113,"Tests\\Feature\\Auth\\PasswordConfirmationTest::test_password_is_not_confirmed_with_invalid_password":0.246,"Tests\\Feature\\Auth\\PasswordResetTest::test_reset_password_link_screen_can_be_rendered":0.018,"Tests\\Feature\\Auth\\PasswordResetTest::test_reset_password_link_can_be_requested":0.047,"Tests\\Feature\\Auth\\PasswordResetTest::test_reset_password_screen_can_be_rendered":0.034,"Tests\\Feature\\Auth\\PasswordResetTest::test_password_can_be_reset_with_valid_token":0.04,"Tests\\Feature\\Auth\\PasswordUpdateTest::test_password_can_be_updated":0.12,"Tests\\Feature\\Auth\\PasswordUpdateTest::test_correct_password_must_be_provided_to_update_password":0.094,"Tests\\Feature\\Auth\\RegistrationTest::test_registration_screen_can_be_rendered":0.01,"Tests\\Feature\\Auth\\RegistrationTest::test_new_users_can_register":0.016,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.006,"Tests\\Feature\\ProfileTest::test_profile_page_is_displayed":0.016,"Tests\\Feature\\ProfileTest::test_profile_information_can_be_updated":0.024,"Tests\\Feature\\ProfileTest::test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged":0.022,"Tests\\Feature\\ProfileTest::test_user_can_delete_their_account":0.097,"Tests\\Feature\\ProfileTest::test_correct_password_must_be_provided_to_delete_account":0.095,"Tests\\Feature\\CrudValidationTest::test_admin_can_add_jurusan_data":0.027,"Tests\\Feature\\CrudValidationTest::test_bk_can_add_jurusan_data":0.02,"Tests\\Feature\\CrudValidationTest::test_admin_guru_bk_store_validates_email_and_password":0.014,"Tests\\Feature\\CrudValidationTest::test_rekomendasi_ipa_requires_all_ipa_scores":0.018,"Tests\\Feature\\CrudValidationTest::test_admin_student_detail_only_accepts_siswa_id":0.022,"Tests\\Feature\\ExplainableRecommendationTest::test_recommendation_includes_explanation":0.038,"Tests\\Feature\\ExplainableRecommendationTest::test_explanation_contains_meaningful_text":0.029,"Tests\\Feature\\ExplainableRecommendationTest::test_scoring_detail_stored_correctly":0.027,"Tests\\Feature\\ExplainableRecommendationTest::test_all_recommendations_have_explanations":0.03,"Tests\\Feature\\ExplainableRecommendationTest::test_explanation_displayed_in_view":0.022,"Tests\\Feature\\Auth\\PasswordResetMessageTest::test_forgot_password_shows_indonesian_error_for_invalid_email":0.059,"Tests\\Feature\\Auth\\PasswordResetMessageTest::test_forgot_password_form_displays_error":0.019}} \ No newline at end of file diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index e616845..2990ecf 100644 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -86,7 +86,7 @@ public function students(Request $request) public function studentDetail($id) { - $student = User::findOrFail($id); + $student = User::where('role', 'siswa')->findOrFail($id); $recommendations = Recommendation::where('user_id', $id) ->orderBy('created_at', 'desc') ->get(); @@ -99,7 +99,7 @@ public function studentDetail($id) public function chatHistory($id) { - $user = User::findOrFail($id); + $user = User::where('role', 'siswa')->findOrFail($id); $chatHistories = ChatHistory::where('user_id', $id) ->orderBy('created_at', 'asc') ->get(); @@ -380,16 +380,12 @@ public function updateProfil(Request $request) public function updatePassword(Request $request) { $request->validate([ - 'current_password' => 'required', + 'current_password' => 'required|current_password', 'password' => 'required|string|min:8|confirmed', ]); $admin = Auth::user(); - if (!Hash::check($request->current_password, $admin->password)) { - return back()->withErrors(['current_password' => 'Password lama salah.']); - } - $admin->password = Hash::make($request->password); $admin->save(); diff --git a/app/Http/Controllers/AlumniController.php b/app/Http/Controllers/AlumniController.php index c6941e5..a8c3a7b 100644 --- a/app/Http/Controllers/AlumniController.php +++ b/app/Http/Controllers/AlumniController.php @@ -49,7 +49,7 @@ public function store(Request $request) // Non-akademik 'minat' => 'nullable|string|max:255', 'cita_cita' => 'nullable|string|max:255', - 'preferensi_studi' => 'nullable|in:Praktik_Langsung,DuDi,Project_Based,Blended', + 'preferensi_studi' => 'nullable|in:Praktik_Langsung,DuDi,Project_Based,Blended,Praktik Langsung,Project Based', 'prestasi' => 'nullable|string|max:255', // Major & Outcome @@ -59,6 +59,10 @@ public function store(Request $request) 'catatan' => 'nullable|string|max:500', ]); + if (!empty($validated['preferensi_studi'])) { + $validated['preferensi_studi'] = str_replace(['Praktik Langsung', 'Project Based'], ['Praktik_Langsung', 'Project_Based'], $validated['preferensi_studi']); + } + Alumni::create($validated); return redirect()->route('admin.alumni.index')->with('success', 'Alumni berhasil ditambahkan'); @@ -101,7 +105,7 @@ public function update(Request $request, Alumni $alumni) 'minat' => 'nullable|string|max:255', 'cita_cita' => 'nullable|string|max:255', - 'preferensi_studi' => 'nullable|in:Praktik_Langsung,DuDi,Project_Based,Blended', + 'preferensi_studi' => 'nullable|in:Praktik_Langsung,DuDi,Project_Based,Blended,Praktik Langsung,Project Based', 'prestasi' => 'nullable|string|max:255', 'major_masuk' => 'required|string|max:255', @@ -110,6 +114,10 @@ public function update(Request $request, Alumni $alumni) 'catatan' => 'nullable|string|max:500', ]); + if (!empty($validated['preferensi_studi'])) { + $validated['preferensi_studi'] = str_replace(['Praktik Langsung', 'Project Based'], ['Praktik_Langsung', 'Project_Based'], $validated['preferensi_studi']); + } + $alumni->update($validated); return redirect()->route('admin.alumni.index')->with('success', 'Alumni berhasil diupdate'); diff --git a/app/Http/Controllers/BKController.php b/app/Http/Controllers/BKController.php index 79d9f68..534445a 100644 --- a/app/Http/Controllers/BKController.php +++ b/app/Http/Controllers/BKController.php @@ -86,7 +86,7 @@ public function students(Request $request) public function studentDetail($id) { - $student = User::findOrFail($id); + $student = User::where('role', 'siswa')->findOrFail($id); $recommendations = Recommendation::where('user_id', $id) ->orderBy('created_at', 'desc') ->get(); @@ -99,7 +99,7 @@ public function studentDetail($id) public function chatHistory($id) { - $user = User::findOrFail($id); + $user = User::where('role', 'siswa')->findOrFail($id); $chatHistories = ChatHistory::where('user_id', $id) ->orderBy('created_at', 'asc') ->get(); @@ -301,16 +301,12 @@ public function updateProfil(Request $request) public function updatePassword(Request $request) { $request->validate([ - 'current_password' => 'required', + 'current_password' => 'required|current_password', 'password' => 'required|string|min:8|confirmed', ]); $guru = Auth::user(); - if (!Hash::check($request->current_password, $guru->password)) { - return back()->withErrors(['current_password' => 'Password lama salah.']); - } - $guru->password = Hash::make($request->password); $guru->save(); diff --git a/app/Http/Controllers/RekomendasiController.php b/app/Http/Controllers/RekomendasiController.php index afc41ff..71e7fdf 100644 --- a/app/Http/Controllers/RekomendasiController.php +++ b/app/Http/Controllers/RekomendasiController.php @@ -26,6 +26,67 @@ public function index() return view('rekomendasi.input', compact('student')); } + /** + * Generate textual explanation untuk setiap kriteria + * Menjelaskan "mengapa jurusan ini cocok" berdasarkan scoring detail + */ + private function generateExplanation($jurusanNama, $detail, $katNilai, $kategoriMinat, $prefStudi, $prestasi) + { + $explanations = []; + + // 1. Penjelasan Nilai Akademik + $skorNilai = $detail['nilai'] ?? 0; + if ($skorNilai >= 0.8) { + $explanations['nilai'] = "✅ Nilai akademik Anda ($katNilai) sangat sesuai dengan jalur pendidikan yang dibutuhkan jurusan ini."; + } elseif ($skorNilai >= 0.6) { + $explanations['nilai'] = "✓ Nilai akademik Anda ($katNilai) cukup sesuai dengan persyaratan jurusan ini."; + } else { + $explanations['nilai'] = "⚠️ Nilai akademik Anda ($katNilai) masih perlu ditingkatkan untuk optimal di jurusan ini, namun tetap relevan."; + } + + // 2. Penjelasan Minat + $skorMinat = $detail['minat'] ?? 0; + if ($skorMinat >= 0.8) { + $explanations['minat'] = "✅ Minat Anda sangat sesuai dan cocok dengan fokus kurikulum $jurusanNama."; + } elseif ($skorMinat >= 0.6) { + $explanations['minat'] = "✓ Minat Anda cukup relevan dan sesuai dengan area pembelajaran di $jurusanNama."; + } else { + $explanations['minat'] = "ℹ️ Minat Anda memiliki kesamaan dan relevansi dengan aspek-aspek tertentu di $jurusanNama."; + } + + // 3. Penjelasan Preferensi Studi + $skorPref = $detail['pref'] ?? 0; + if ($skorPref >= 0.8) { + $explanations['pref'] = "✅ Metode pembelajaran \"$prefStudi\" yang Anda pilih sangat sesuai dengan pendekatan pembelajaran $jurusanNama."; + } elseif ($skorPref >= 0.6) { + $explanations['pref'] = "✓ Preferensi studi \"$prefStudi\" Anda cocok dengan sistem pembelajaran yang diterapkan."; + } else { + $explanations['pref'] = "ℹ️ Jurusan ini menawarkan elemen pembelajaran \"$prefStudi\" yang relevan dengan preferensi Anda."; + } + + // 4. Penjelasan Cita-cita + $skorCita = $detail['cita'] ?? 0; + if ($skorCita >= 0.8) { + $explanations['cita'] = "✅ Cita-cita karir Anda sangat sesuai dan aligned dengan standar lulusan bidang ini."; + } elseif ($skorCita >= 0.6) { + $explanations['cita'] = "✓ Cita-cita Anda memiliki potensi besar untuk dicapai melalui jurusan ini."; + } else { + $explanations['cita'] = "ℹ️ Jurusan ini membuka jalur karir yang sesuai dengan cita-cita dan aspirasi Anda."; + } + + // 5. Penjelasan Prestasi + $skorPrestasi = $detail['prestasi'] ?? 0; + if ($skorPrestasi >= 0.7) { + $explanations['prestasi'] = "✅ Prestasi Anda mencerminkan potensi kuat untuk sukses dan berkembang di jurusan ini."; + } elseif ($skorPrestasi >= 0.4) { + $explanations['prestasi'] = "✓ Prestasi Anda menunjukkan kemampuan dasar yang memadai dan relevan."; + } else { + $explanations['prestasi'] = "ℹ️ Prestasi tidak menjadi hambatan untuk mengembangkan diri dan berkembang di jurusan ini."; + } + + return $explanations; + } + /** * ============================================================ * ALGORITMA NAIVE BAYES UNTUK REKOMENDASI JURUSAN @@ -42,16 +103,57 @@ public function index() */ public function proses(Request $request) { + $validated = $request->validate([ + 'mtk' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'fisika' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'kimia' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'biologi' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'ekonomi' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'geografi' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'sosiologi' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'sejarah' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'minat' => ['required', 'string', 'max:255'], + 'pref_studi' => ['required', 'string', 'in:Sains & Teknologi,Pertanian & Lingkungan,Kesehatan & Ilmu Hayat,Bisnis & Manajemen,Sosial & Humaniora,Praktikum,Teori'], + 'cita_cita' => ['required', 'string', 'max:255'], + 'prestasi' => ['nullable', 'string', 'max:255'], + ]); + + $kelompokAsal = Auth::user()?->kelompok_asal; + if ($kelompokAsal === 'IPA') { + $request->validate([ + 'mtk' => ['required', 'numeric', 'min:0', 'max:100'], + 'fisika' => ['required', 'numeric', 'min:0', 'max:100'], + 'kimia' => ['required', 'numeric', 'min:0', 'max:100'], + 'biologi' => ['required', 'numeric', 'min:0', 'max:100'], + ]); + } elseif ($kelompokAsal === 'IPS') { + $request->validate([ + 'ekonomi' => ['required', 'numeric', 'min:0', 'max:100'], + 'geografi' => ['required', 'numeric', 'min:0', 'max:100'], + 'sosiologi' => ['required', 'numeric', 'min:0', 'max:100'], + 'sejarah' => ['required', 'numeric', 'min:0', 'max:100'], + ]); + } + $epsilon = 1e-9; // ================================================================ // LANGKAH 1: INPUT DATA // ================================================================ - $scores = $request->only(['mtk', 'fisika', 'kimia', 'biologi', 'ekonomi', 'geografi', 'sosiologi', 'sejarah']); - $minatRaw = strtolower(trim($request->minat ?? '')); - $prefStudi = $request->pref_studi ?? 'Sains & Teknologi'; - $citaRaw = strtolower(trim($request->cita_cita ?? '')); - $prestasiRaw = strtolower(trim($request->prestasi ?? '')); + $scores = [ + 'mtk' => $validated['mtk'] ?? null, + 'fisika' => $validated['fisika'] ?? null, + 'kimia' => $validated['kimia'] ?? null, + 'biologi' => $validated['biologi'] ?? null, + 'ekonomi' => $validated['ekonomi'] ?? null, + 'geografi' => $validated['geografi'] ?? null, + 'sosiologi' => $validated['sosiologi'] ?? null, + 'sejarah' => $validated['sejarah'] ?? null, + ]; + $minatRaw = strtolower(trim($validated['minat'] ?? '')); + $prefStudi = $validated['pref_studi'] ?? 'Sains & Teknologi'; + $citaRaw = strtolower(trim($validated['cita_cita'] ?? '')); + $prestasiRaw = strtolower(trim($validated['prestasi'] ?? '')); // ================================================================ // LANGKAH 2: PREPROCESSING DATA @@ -178,10 +280,20 @@ public function proses(Request $request) $hasilAkhir = []; foreach ($expVals as $namaJurusan => $val) { $prob = $val / max($sumExp, $epsilon); + $detail = $detailPerJurusan[$namaJurusan]; + $explanations = $this->generateExplanation( + $namaJurusan, + $detail, + $katNilai, + $minatRaw, + $prefStudi, + $prestasiRaw + ); $hasilAkhir[] = [ 'jurusan' => $namaJurusan, 'skor' => round($prob, 4), - 'detail' => $detailPerJurusan[$namaJurusan], + 'detail' => $detail, + 'explanation' => $explanations, 'kecocokan_nilai' => $katNilai, 'kecocokan_minat' => $minatRaw, 'kecocokan_pref' => $prefStudi, @@ -200,18 +312,18 @@ public function proses(Request $request) if ($user) { $savedRec = Recommendation::create([ 'user_id' => $user->id, - 'mtk' => $request->mtk ?? null, - 'fisika' => $request->fisika ?? null, - 'kimia' => $request->kimia ?? null, - 'biologi' => $request->biologi ?? null, - 'ekonomi' => $request->ekonomi ?? null, - 'geografi' => $request->geografi ?? null, - 'sosiologi' => $request->sosiologi ?? null, - 'sejarah' => $request->sejarah ?? null, - 'minat' => $request->minat ?? null, - 'preferensi_studi' => $request->pref_studi ?? null, - 'cita_cita' => $request->cita_cita ?? null, - 'prestasi' => $request->prestasi ?? null, + 'mtk' => $validated['mtk'] ?? null, + 'fisika' => $validated['fisika'] ?? null, + 'kimia' => $validated['kimia'] ?? null, + 'biologi' => $validated['biologi'] ?? null, + 'ekonomi' => $validated['ekonomi'] ?? null, + 'geografi' => $validated['geografi'] ?? null, + 'sosiologi' => $validated['sosiologi'] ?? null, + 'sejarah' => $validated['sejarah'] ?? null, + 'minat' => $validated['minat'], + 'preferensi_studi' => $validated['pref_studi'], + 'cita_cita' => $validated['cita_cita'], + 'prestasi' => $validated['prestasi'] ?? '', 'hasil_rekomendasi' => $hasilAkhir, ]); } @@ -377,6 +489,8 @@ private function normalisasiProbabilitas(float $value, float $min = 0.10, float */ private function hitungSkorPrestasi(string $prestasiRaw): float { + $prestasiRaw = strtolower(trim($prestasiRaw)); + if (empty($prestasiRaw)) { return 0.0; } diff --git a/lang/id/passwords.php b/lang/id/passwords.php new file mode 100644 index 0000000..988efaf --- /dev/null +++ b/lang/id/passwords.php @@ -0,0 +1,10 @@ + 'Password harus minimal 8 karakter dan sama dengan konfirmasi password.', + 'reset' => 'Password Anda telah berhasil direset!', + 'sent' => 'Kami telah mengirimkan tautan reset password lewat email Anda.', + 'throttled' => 'Mohon tunggu sebelum mencoba lagi.', + 'token' => 'Token reset password ini tidak valid.', + 'user' => 'Kami tidak dapat menemukan user dengan email address tersebut.', +]; diff --git a/resources/views/rekomendasi/hasil.blade.php b/resources/views/rekomendasi/hasil.blade.php index 47890f6..aa75ed0 100644 --- a/resources/views/rekomendasi/hasil.blade.php +++ b/resources/views/rekomendasi/hasil.blade.php @@ -215,9 +215,49 @@ + +
+

+ 💡 Alasan Kenapa Jurusan Ini Cocok: +

+ +
+
+ 📊 +

+ Nilai Akademik: {{ $topRecommendation['explanation']['nilai'] ?? '-' }} +

+
+
+ ❤️ +

+ Minat & Bakat: {{ $topRecommendation['explanation']['minat'] ?? '-' }} +

+
+
+ 🎓 +

+ Metode Pembelajaran: {{ $topRecommendation['explanation']['pref'] ?? '-' }} +

+
+
+ 🎯 +

+ Cita-cita Karir: {{ $topRecommendation['explanation']['cita'] ?? '-' }} +

+
+
+ 🏆 +

+ Prestasi Akademik: {{ $topRecommendation['explanation']['prestasi'] ?? '-' }} +

+
+
+
+
-

Penjelasan:

+

📋 Kesimpulan:

Berdasarkan profil Anda dengan nilai akademik {{ $katNilai }} (rata-rata {{ number_format($average, 1) }}) dan preferensi studi {{ $prefStudi }}, @@ -236,6 +276,113 @@

@endif + + @if(count($hasilAkhir) > 1) +
+

+ 🔍 Rekomendasi Alternatif & Penjelasan Detail +

+ +
+ @foreach($hasilAkhir as $index => $rec) + @if($index > 0) +
+ +
+ + {{ $index + 1 }} + +
+

{{ $rec['jurusan'] ?? '-' }}

+

Skor: {{ number_format(($rec['skor'] ?? 0) * 100, 1) }}%

+
+
+ +
+ +
+ +
+

Scoring per Kriteria:

+
+
+ 📊 Nilai (40%) + {{ number_format(($rec['detail']['nilai'] ?? 0) * 100, 1) }}% +
+
+
+
+ +
+ ❤️ Minat (35%) + {{ number_format(($rec['detail']['minat'] ?? 0) * 100, 1) }}% +
+
+
+
+ +
+ 🎓 Preferensi (15%) + {{ number_format(($rec['detail']['pref'] ?? 0) * 100, 1) }}% +
+
+
+
+ +
+ 🎯 Cita-cita (5%) + {{ number_format(($rec['detail']['cita'] ?? 0) * 100, 1) }}% +
+
+
+
+ +
+ 🏆 Prestasi (5%) + {{ number_format(($rec['detail']['prestasi'] ?? 0) * 100, 1) }}% +
+
+
+
+
+
+ + + @if(isset($rec['explanation']) && is_array($rec['explanation'])) +
+

💡 Alasan Cocok:

+
    +
  • + 📊 + {{ $rec['explanation']['nilai'] ?? '-' }} +
  • +
  • + ❤️ + {{ $rec['explanation']['minat'] ?? '-' }} +
  • +
  • + 🎓 + {{ $rec['explanation']['pref'] ?? '-' }} +
  • +
  • + 🎯 + {{ $rec['explanation']['cita'] ?? '-' }} +
  • +
  • + 🏆 + {{ $rec['explanation']['prestasi'] ?? '-' }} +
  • +
+
+ @endif +
+
+ @endif + @endforeach +
+
+ @endif +
diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index 30829b1..070a3aa 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -24,6 +24,8 @@ public function test_new_users_can_register(): void 'email' => 'test@example.com', 'password' => 'password', 'password_confirmation' => 'password', + 'nis' => 'NIS123456', + 'kelompok_asal' => 'IPA', ]); $this->assertAuthenticated(); diff --git a/tests/Feature/CrudValidationTest.php b/tests/Feature/CrudValidationTest.php new file mode 100644 index 0000000..2f1e4ca --- /dev/null +++ b/tests/Feature/CrudValidationTest.php @@ -0,0 +1,116 @@ +create([ + 'role' => 'admin', + 'email_verified_at' => now(), + ]); + + $payload = [ + 'nama_jurusan' => 'Jurusan Uji Admin', + 'deskripsi' => 'Deskripsi jurusan uji dari admin', + 'keywords' => 'uji,admin', + 'preferensi_studi' => 'Sains & Teknologi', + 'prospek_kerja' => 'Tester aplikasi', + 'bobot_mtk' => 0.8, + 'bobot_fisika' => 0.7, + ]; + + $response = $this->actingAs($admin)->post(route('admin.jurusan.store'), $payload); + + $response->assertRedirect(route('admin.jurusan')); + $this->assertDatabaseHas('polije_majors', [ + 'nama_jurusan' => 'Jurusan Uji Admin', + ]); + } + + public function test_bk_can_add_jurusan_data(): void + { + $bk = User::factory()->create([ + 'role' => 'bk', + 'email_verified_at' => now(), + ]); + + $payload = [ + 'nama_jurusan' => 'Jurusan Uji BK', + 'deskripsi' => 'Deskripsi jurusan uji dari BK', + 'keywords' => 'uji,bk', + 'preferensi_studi' => 'Bisnis & Manajemen', + 'prospek_kerja' => 'Konsultan BK', + 'bobot_ekonomi' => 0.9, + 'bobot_sosiologi' => 0.6, + ]; + + $response = $this->actingAs($bk)->post(route('bk.jurusan.store'), $payload); + + $response->assertRedirect(route('bk.jurusan')); + $this->assertDatabaseHas('polije_majors', [ + 'nama_jurusan' => 'Jurusan Uji BK', + ]); + } + + public function test_admin_guru_bk_store_validates_email_and_password(): void + { + $admin = User::factory()->create([ + 'role' => 'admin', + 'email_verified_at' => now(), + ]); + + $response = $this->actingAs($admin)->from(route('admin.guru-bk.create'))->post(route('admin.guru-bk.store'), [ + 'name' => 'Guru BK Uji', + 'email' => 'email-tidak-valid', + 'password' => '123', + 'password_confirmation' => '123', + ]); + + $response->assertRedirect(route('admin.guru-bk.create')); + $response->assertSessionHasErrors(['email', 'password']); + } + + public function test_rekomendasi_ipa_requires_all_ipa_scores(): void + { + $siswa = User::factory()->create([ + 'role' => 'siswa', + 'kelompok_asal' => 'IPA', + 'email_verified_at' => now(), + ]); + + $response = $this->actingAs($siswa)->from(route('rekomendasi.index'))->post(route('rekomendasi.proses'), [ + 'mtk' => 90, + 'minat' => 'coding', + 'pref_studi' => 'Sains & Teknologi', + 'cita_cita' => 'programmer', + ]); + + $response->assertRedirect(route('rekomendasi.index')); + $response->assertSessionHasErrors(['fisika', 'kimia', 'biologi']); + } + + public function test_admin_student_detail_only_accepts_siswa_id(): void + { + $admin = User::factory()->create([ + 'role' => 'admin', + 'email_verified_at' => now(), + ]); + + $bk = User::factory()->create([ + 'role' => 'bk', + 'email_verified_at' => now(), + ]); + + $this->actingAs($admin) + ->get(route('admin.student.detail', $bk->id)) + ->assertNotFound(); + } +} diff --git a/tests/Feature/ExplainableRecommendationTest.php b/tests/Feature/ExplainableRecommendationTest.php new file mode 100644 index 0000000..dc0f07a --- /dev/null +++ b/tests/Feature/ExplainableRecommendationTest.php @@ -0,0 +1,252 @@ + 'Teknologi Informasi', + 'singkatan' => 'TI', + 'tujuan_kompetensi' => 'Menghasilkan profesional IT', + 'prospek_kerja' => 'Software Developer, Software Engineer, IT Consultant', + 'kelompok_asal' => 'IPA', + 'mtk' => 30, 'fisika' => 20, 'kimia' => 10, 'biologi' => 5, + 'minat1' => 'Logika Komputer', 'minat2' => '', 'minat3' => '', + 'pref_sains' => 70, 'pref_pertanian' => 20, 'pref_kesehatan' => 10, + 'pref_bisnis' => 15, 'pref_sosial' => 10, 'pref_praktik' => 50, 'pref_teori' => 50, + ]); + + PolijeMajor::create([ + 'nama_jurusan' => 'Akuntansi', + 'singkatan' => 'AK', + 'tujuan_kompetensi' => 'Menghasilkan profesional akuntansi', + 'prospek_kerja' => 'Accountant, Financial Analyst, Tax Consultant', + 'kelompok_asal' => 'IPS', + 'ekonomi' => 30, 'geografi' => 15, 'sosiologi' => 10, 'sejarah' => 5, + 'minat1' => 'Bisnis', 'minat2' => '', 'minat3' => '', + 'pref_bisnis' => 80, 'pref_sosial' => 15, 'pref_pertanian' => 5, + 'pref_sains' => 10, 'pref_kesehatan' => 5, 'pref_praktik' => 60, 'pref_teori' => 40, + ]); + } + + /** + * Test bahwa response recommendation mencakup explanation + */ + public function test_recommendation_includes_explanation() + { + $user = User::factory()->create([ + 'role' => 'siswa', + 'kelompok_asal' => 'IPA', + ]); + + $this->actingAs($user)->post(route('rekomendasi.proses'), [ + 'mtk' => 90, + 'fisika' => 85, + 'kimia' => 80, + 'biologi' => 75, + 'minat' => 'Logika Komputer', + 'pref_studi' => 'Sains & Teknologi', + 'cita_cita' => 'Software Developer', + 'prestasi' => 'Juara Kompetisi Coding Nasional', + ]); + + // Verify recommendation is stored + $lastRecommendation = \App\Models\Recommendation::latest()->first(); + $this->assertNotNull($lastRecommendation); + + // Verify hasil_rekomendasi contains explanation + $hasil = $lastRecommendation->hasil_rekomendasi; + $this->assertIsArray($hasil); + $this->assertNotEmpty($hasil); + + // Check top recommendation has explanation field + $topRec = $hasil[0]; + $this->assertArrayHasKey('explanation', $topRec); + $this->assertIsArray($topRec['explanation']); + + // Verify all 5 explanation keys exist + $expectedKeys = ['nilai', 'minat', 'pref', 'cita', 'prestasi']; + foreach ($expectedKeys as $key) { + $this->assertArrayHasKey($key, $topRec['explanation']); + $this->assertIsString($topRec['explanation'][$key]); + $this->assertNotEmpty($topRec['explanation'][$key]); + } + } + + /** + * Test bahwa explanation berisi teks yang meaningful + */ + public function test_explanation_contains_meaningful_text() + { + $user = User::factory()->create([ + 'role' => 'siswa', + 'kelompok_asal' => 'IPA', + ]); + + $this->actingAs($user)->post(route('rekomendasi.proses'), [ + 'mtk' => 95, + 'fisika' => 90, + 'kimia' => 88, + 'biologi' => 92, + 'minat' => 'Logika Komputer', + 'pref_studi' => 'Sains & Teknologi', + 'cita_cita' => 'Software Engineer', + 'prestasi' => 'Juara Olimpiade Komputer', + ]); + + $lastRecommendation = \App\Models\Recommendation::latest()->first(); + $hasil = $lastRecommendation->hasil_rekomendasi; + $topRec = $hasil[0]; + $explanation = $topRec['explanation']; + + // Verify explanation contains meaningful indicators and text + $this->assertStringContainsString('Nilai akademik', $explanation['nilai']); + $this->assertStringContainsString('Minat', $explanation['minat']); + $this->assertStringContainsString('pembelajaran', $explanation['pref']); + $this->assertStringContainsString('cita', $explanation['cita']); + $this->assertStringContainsString('prestasi', strtolower($explanation['prestasi'])); + + // Verify checkmarks or indicators are present + $combinedExplanation = implode(' ', $explanation); + $this->assertTrue( + str_contains($combinedExplanation, '✅') || + str_contains($combinedExplanation, '✓') || + str_contains($combinedExplanation, 'sesuai') || + str_contains($combinedExplanation, 'cocok') + ); + } + + /** + * Test scoring detail (nilai, minat, pref, cita, prestasi) tersimpan dengan tepat + */ + public function test_scoring_detail_stored_correctly() + { + $user = User::factory()->create([ + 'role' => 'siswa', + 'kelompok_asal' => 'IPA', + ]); + + $this->actingAs($user)->post(route('rekomendasi.proses'), [ + 'mtk' => 88, + 'fisika' => 82, + 'kimia' => 85, + 'biologi' => 80, + 'minat' => 'Logika Komputer dan Pemrograman', + 'pref_studi' => 'Sains & Teknologi', + 'cita_cita' => 'Software Engineer', + 'prestasi' => 'Sertifikat Oracle Java', + ]); + + $lastRecommendation = \App\Models\Recommendation::latest()->first(); + $hasil = $lastRecommendation->hasil_rekomendasi; + + // Verify detail breakdown exists for top recommendation + $topRec = $hasil[0]; + $this->assertArrayHasKey('detail', $topRec); + $detail = $topRec['detail']; + + // Verify all 5 scoring components exist + $scores = ['nilai', 'minat', 'pref', 'cita', 'prestasi']; + foreach ($scores as $score) { + $this->assertArrayHasKey($score, $detail); + $this->assertIsNumeric($detail[$score]); + $this->assertGreaterThanOrEqual(0, $detail[$score]); + $this->assertLessThanOrEqual(1, $detail[$score]); + } + } + + /** + * Test bahwa setiap jurusan dalam hasil punya explanation + */ + public function test_all_recommendations_have_explanations() + { + $user = User::factory()->create([ + 'role' => 'siswa', + 'kelompok_asal' => 'IPA', + ]); + + $this->actingAs($user)->post(route('rekomendasi.proses'), [ + 'mtk' => 80, + 'fisika' => 75, + 'kimia' => 78, + 'biologi' => 76, + 'minat' => 'Sains dan Teknologi', + 'pref_studi' => 'Sains & Teknologi', + 'cita_cita' => 'Profesional Teknologi', + 'prestasi' => 'Aktif dalam kegiatan STEM', + ]); + + $lastRecommendation = \App\Models\Recommendation::latest()->first(); + $hasil = $lastRecommendation->hasil_rekomendasi; + + // Verify each recommendation has explanation and detail + foreach ($hasil as $rec) { + $this->assertArrayHasKey('explanation', $rec); + $this->assertArrayHasKey('detail', $rec); + $this->assertIsArray($rec['explanation']); + $this->assertIsArray($rec['detail']); + + // Count should match (5 criteria) + $this->assertCount(5, $rec['explanation']); + $this->assertCount(5, $rec['detail']); + } + } + + /** + * Test explanation rendered in view + */ + public function test_explanation_displayed_in_view() + { + $user = User::factory()->create([ + 'role' => 'siswa', + 'kelompok_asal' => 'IPA', + ]); + + $response = $this->actingAs($user)->post(route('rekomendasi.proses'), [ + 'mtk' => 85, + 'fisika' => 80, + 'kimia' => 78, + 'biologi' => 82, + 'minat' => 'Logika Komputer', + 'pref_studi' => 'Sains & Teknologi', + 'cita_cita' => 'Software Developer', + 'prestasi' => 'Juara Informatika', + ]); + + $response->assertStatus(200); + + // View should contain explanation indicators + $response->assertViewHas('hasilAkhir'); + $hasil = $response->viewData('hasilAkhir'); + + // Check explanation is in view data + $this->assertNotEmpty($hasil); + $topRec = $hasil[0]; + $this->assertArrayHasKey('explanation', $topRec); + $explanation = $topRec['explanation']; + + // Verify explanation content in response + foreach ($explanation as $key => $text) { + $this->assertNotEmpty($text); + // Check that text contains expected keywords + $hasContent = str_contains($text, '✅') || + str_contains($text, '✓') || + str_contains($text, 'sesuai') || + str_contains($text, 'cocok') || + str_contains($text, 'relevan'); + $this->assertTrue($hasContent, "Explanation for $key should have meaningful content"); + } + } +} diff --git a/tests/Feature/RekomendasiTest.php b/tests/Feature/RekomendasiTest.php index d8ee571..72b2326 100644 --- a/tests/Feature/RekomendasiTest.php +++ b/tests/Feature/RekomendasiTest.php @@ -45,6 +45,6 @@ public function test_high_language_prefers_bahasa_komunikasi() $response = $this->actingAs($user)->post(route('rekomendasi.proses'), $payload); $response->assertStatus(200); - $response->assertSee('Bahasa, Komunikasi dan Pariwisata'); + $response->assertSee('Bahasa, Komunikasi, dan Pariwisata'); } } diff --git a/tests/Unit/RekomendasiAlgorithmTest.php b/tests/Unit/RekomendasiAlgorithmTest.php index 4e35788..73982fa 100644 --- a/tests/Unit/RekomendasiAlgorithmTest.php +++ b/tests/Unit/RekomendasiAlgorithmTest.php @@ -113,6 +113,8 @@ private function mapMinat(string $minatRaw): string private function scorePrestasiScore(string $prestasiRaw): float { + $prestasiRaw = strtolower(trim($prestasiRaw)); + if (empty($prestasiRaw)) { return 0.0; }