fix: add Indonesian translations for password reset messages - fixes 'passwords.user' key display

This commit is contained in:
KakaPatria 2026-04-07 17:30:55 +07:00
parent e0b0c10ddc
commit d208d68ad8
12 changed files with 680 additions and 37 deletions

File diff suppressed because one or more lines are too long

View File

@ -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();

View File

@ -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');

View File

@ -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();

View File

@ -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;
}

10
lang/id/passwords.php Normal file
View File

@ -0,0 +1,10 @@
<?php
return [
'password' => '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.',
];

View File

@ -215,9 +215,49 @@
</div>
</div>
<!-- Explanation Breakdown -->
<div class="mb-4 sm:mb-6 p-3 sm:p-4 bg-blue-50 rounded-lg border border-blue-200">
<h4 class="font-bold text-blue-900 text-sm sm:text-base mb-3 sm:mb-4 flex items-center gap-2">
<span class="text-lg">💡</span> Alasan Kenapa Jurusan Ini Cocok:
</h4>
<div class="space-y-2 sm:space-y-3">
<div class="flex gap-2 sm:gap-3">
<span class="text-lg flex-shrink-0">📊</span>
<p class="text-xs sm:text-sm text-gray-800">
<strong>Nilai Akademik:</strong> {{ $topRecommendation['explanation']['nilai'] ?? '-' }}
</p>
</div>
<div class="flex gap-2 sm:gap-3">
<span class="text-lg flex-shrink-0">❤️</span>
<p class="text-xs sm:text-sm text-gray-800">
<strong>Minat & Bakat:</strong> {{ $topRecommendation['explanation']['minat'] ?? '-' }}
</p>
</div>
<div class="flex gap-2 sm:gap-3">
<span class="text-lg flex-shrink-0">🎓</span>
<p class="text-xs sm:text-sm text-gray-800">
<strong>Metode Pembelajaran:</strong> {{ $topRecommendation['explanation']['pref'] ?? '-' }}
</p>
</div>
<div class="flex gap-2 sm:gap-3">
<span class="text-lg flex-shrink-0">🎯</span>
<p class="text-xs sm:text-sm text-gray-800">
<strong>Cita-cita Karir:</strong> {{ $topRecommendation['explanation']['cita'] ?? '-' }}
</p>
</div>
<div class="flex gap-2 sm:gap-3">
<span class="text-lg flex-shrink-0">🏆</span>
<p class="text-xs sm:text-sm text-gray-800">
<strong>Prestasi Akademik:</strong> {{ $topRecommendation['explanation']['prestasi'] ?? '-' }}
</p>
</div>
</div>
</div>
<!-- Kesimpulan -->
<div class="p-3 sm:p-4 bg-yellow-50 rounded-lg border-l-4 border-yellow-400 mb-3 sm:mb-4">
<h4 class="font-bold text-maroon mb-2 text-sm sm:text-base">Penjelasan:</h4>
<h4 class="font-bold text-maroon mb-2 text-sm sm:text-base">📋 Kesimpulan:</h4>
<p class="text-gray-700 text-xs sm:text-sm leading-relaxed">
Berdasarkan profil Anda dengan <strong>nilai akademik {{ $katNilai }} (rata-rata {{ number_format($average, 1) }})</strong>
dan <strong>preferensi studi {{ $prefStudi }}</strong>,
@ -236,6 +276,113 @@
</div>
@endif
<!-- Rekomendasi Alternatif & Penjelasan Detail -->
@if(count($hasilAkhir) > 1)
<div class="bg-white rounded-lg shadow-lg p-5 sm:p-8 mb-6 sm:mb-8">
<h3 class="text-lg sm:text-xl font-bold text-maroon mb-4 sm:mb-6 flex items-center gap-2">
<span class="text-2xl">🔍</span> Rekomendasi Alternatif & Penjelasan Detail
</h3>
<div class="space-y-3 sm:space-y-4">
@foreach($hasilAkhir as $index => $rec)
@if($index > 0)
<details class="border border-gray-300 rounded-lg overflow-hidden">
<summary class="cursor-pointer p-4 hover:bg-gray-50 flex justify-between items-center">
<div class="flex items-center gap-3">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm">
{{ $index + 1 }}
</span>
<div>
<p class="font-semibold text-gray-900 text-sm sm:text-base">{{ $rec['jurusan'] ?? '-' }}</p>
<p class="text-xs sm:text-sm text-gray-600">Skor: {{ number_format(($rec['skor'] ?? 0) * 100, 1) }}%</p>
</div>
</div>
<span class="text-gray-400"></span>
</summary>
<div class="p-4 bg-gray-50 border-t border-gray-300 space-y-4">
<!-- Score Breakdown -->
<div class="bg-white p-3 rounded-lg">
<p class="text-xs sm:text-sm font-bold text-gray-700 mb-3">Scoring per Kriteria:</p>
<div class="space-y-2">
<div class="flex justify-between items-center text-xs">
<span class="text-gray-600">📊 Nilai (40%)</span>
<span class="font-semibold text-maroon">{{ number_format(($rec['detail']['nilai'] ?? 0) * 100, 1) }}%</span>
</div>
<div class="w-full bg-gray-200 rounded h-1.5">
<div class="bg-red-500 rounded h-1.5 transition-all" style="width: {{ number_format(($rec['detail']['nilai'] ?? 0) * 100, 1) }}%"></div>
</div>
<div class="flex justify-between items-center text-xs mt-2">
<span class="text-gray-600">❤️ Minat (35%)</span>
<span class="font-semibold text-maroon">{{ number_format(($rec['detail']['minat'] ?? 0) * 100, 1) }}%</span>
</div>
<div class="w-full bg-gray-200 rounded h-1.5">
<div class="bg-pink-500 rounded h-1.5 transition-all" style="width: {{ number_format(($rec['detail']['minat'] ?? 0) * 100, 1) }}%"></div>
</div>
<div class="flex justify-between items-center text-xs mt-2">
<span class="text-gray-600">🎓 Preferensi (15%)</span>
<span class="font-semibold text-maroon">{{ number_format(($rec['detail']['pref'] ?? 0) * 100, 1) }}%</span>
</div>
<div class="w-full bg-gray-200 rounded h-1.5">
<div class="bg-yellow-500 rounded h-1.5 transition-all" style="width: {{ number_format(($rec['detail']['pref'] ?? 0) * 100, 1) }}%"></div>
</div>
<div class="flex justify-between items-center text-xs mt-2">
<span class="text-gray-600">🎯 Cita-cita (5%)</span>
<span class="font-semibold text-maroon">{{ number_format(($rec['detail']['cita'] ?? 0) * 100, 1) }}%</span>
</div>
<div class="w-full bg-gray-200 rounded h-1.5">
<div class="bg-blue-500 rounded h-1.5 transition-all" style="width: {{ number_format(($rec['detail']['cita'] ?? 0) * 100, 1) }}%"></div>
</div>
<div class="flex justify-between items-center text-xs mt-2">
<span class="text-gray-600">🏆 Prestasi (5%)</span>
<span class="font-semibold text-maroon">{{ number_format(($rec['detail']['prestasi'] ?? 0) * 100, 1) }}%</span>
</div>
<div class="w-full bg-gray-200 rounded h-1.5">
<div class="bg-green-500 rounded h-1.5 transition-all" style="width: {{ number_format(($rec['detail']['prestasi'] ?? 0) * 100, 1) }}%"></div>
</div>
</div>
</div>
<!-- Explanation -->
@if(isset($rec['explanation']) && is_array($rec['explanation']))
<div class="bg-blue-50 p-3 rounded-lg border-l-4 border-blue-400">
<p class="text-xs sm:text-sm font-bold text-blue-900 mb-2">💡 Alasan Cocok:</p>
<ul class="space-y-1.5 text-xs sm:text-sm text-gray-800">
<li class="flex gap-2">
<span class="flex-shrink-0">📊</span>
<span>{{ $rec['explanation']['nilai'] ?? '-' }}</span>
</li>
<li class="flex gap-2">
<span class="flex-shrink-0">❤️</span>
<span>{{ $rec['explanation']['minat'] ?? '-' }}</span>
</li>
<li class="flex gap-2">
<span class="flex-shrink-0">🎓</span>
<span>{{ $rec['explanation']['pref'] ?? '-' }}</span>
</li>
<li class="flex gap-2">
<span class="flex-shrink-0">🎯</span>
<span>{{ $rec['explanation']['cita'] ?? '-' }}</span>
</li>
<li class="flex gap-2">
<span class="flex-shrink-0">🏆</span>
<span>{{ $rec['explanation']['prestasi'] ?? '-' }}</span>
</li>
</ul>
</div>
@endif
</div>
</details>
@endif
@endforeach
</div>
</div>
@endif
<!-- Chatbot Confirmation Card -->
<div class="bg-white rounded-lg shadow-lg p-5 sm:p-8 mb-6 sm:mb-8 border-2 border-yellow-400">
<div class="flex flex-col sm:flex-row items-start gap-3 sm:gap-4">

View File

@ -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();

View File

@ -0,0 +1,116 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CrudValidationTest extends TestCase
{
use RefreshDatabase;
public function test_admin_can_add_jurusan_data(): void
{
$admin = User::factory()->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();
}
}

View File

@ -0,0 +1,252 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\PolijeMajor;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExplainableRecommendationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Create some jurusan data
PolijeMajor::create([
'nama_jurusan' => '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");
}
}
}

View File

@ -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');
}
}

View File

@ -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;
}