From 63250628b5b8ff2b7ca51ca923ed84bf0badbef4 Mon Sep 17 00:00:00 2001 From: Ammar Fathin Date: Thu, 22 Oct 2026 02:46:16 +0800 Subject: [PATCH] fix: detect postgraduate final-project courses by jenjang (BUG-003) --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 2 + app/Controllers/Graduation.php | 157 ++++++++++++++-------- app/Views/graduation/wizard.php | 6 +- docs/IMPROVEMENTS.md | 11 ++ tests/unit/Controllers/GraduationTest.php | 119 ++++++++++++++++ 6 files changed, 239 insertions(+), 58 deletions(-) create mode 100644 tests/unit/Controllers/GraduationTest.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f61bd8b..31c0d4d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -318,7 +318,7 @@ Validation fails fast with aggregated errors; the step re-renders with input pre ### Transcript Completeness Check (ENH-020) -`Graduation::checkTranscriptCompleteness()` scans `GetTranskripMahasiswa` rows for a thesis/skripsi course (name matching `/skripsi|tugas\s*akhir|thesis|disertasi/i`) with a non-empty grade (`nilai_huruf` or `nilai_angka`). Returns `{complete: bool, reason: string}`. This is a heuristic — confirm naming against real data and tighten pattern if needed. +`Graduation::checkTranscriptCompleteness()` scans transcript rows from the Neo Feeder cloud "Cek Transkrip Mahasiswa" endpoints (`NeoFeeder::getCekTranskripMahasiswa()`) for a final-project course with a non-empty grade (`nilai_huruf` or `nilai_angka`). Course names differ per program studi, so the pattern is selected from `nama_program_studi` (persisted in wizard progress): S1 matches `skripsi|tugas akhir|thesis|tesis`, S2 matches `tesis|skripsi|tugas akhir|thesis|ujian hasil penelitian dan tutup|ujian tutup`, and S3 matches only `ujian terbuka|promosi`. S3's `Ujian Tutup` is an intermediate milestone, so it is deliberately excluded there. Returns `{complete: bool, reason: string}`. The per-course `choosed` marker is informational only and does not gate completeness. ### PISN Eligibility (Stubbed) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfd652f..9e27fcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,8 @@ _None yet._ - **Graduation wizard academic edits silently rejected by Neo Feeder (#117):** `Graduation::finish()` pushed per-semester academic corrections (status / IPK / IPS) as a *partial* `UpdatePerkuliahanMahasiswa` record (only `id_status_mahasiswa`, `ips`, `ipk`). Neo Feeder rejects partial records — it enforces `id_status_mahasiswa`, `biaya_kuliah_smt` and `id_pembiayaan` even though the GetDictionary schema lists only `id_status_mahasiswa` as NOT NULL — so every academic edit failed and the last-semester IPK/status was never actually updated. The failure was also invisible because the response was discarded (no result row). `finish()` now sends the **full** activity row (minus the composite key) plus `id_pembiayaan` resolved via `GetListRiwayatPendidikanMahasiswa`, with only the edited fields overridden — the same record shape as the Verifikasi IPK menu — and records a per-semester success/failure result so the guidance page shows what actually happened — #117 - **Graduation wizard transcript completeness (BUG-001, #89):** the "Kelengkapan Transkrip" check is now sourced from the Neo Feeder "Cek Transkrip Mahasiswa" menu data (cloud REST `/ws/transkrip/nilai_mahasiswa`, using BASE's existing WS `act` token as Bearer — no separate cloud login) instead of `GetTranskripMahasiswa`. Completeness now requires the thesis/skripsi course to exist with a non-empty grade; the per-course `choosed` transcript-inclusion marker is shown as status detail (badge "Nilai ada · masuk transkrip"/"belum masuk transkrip"/"Tanpa nilai") but no longer gates the result. New `NeoFeeder::getCekTranskripMahasiswa()` + `cloudGet()` helper; card 2b renders the detected thesis course (MK, kode, semester, nilai, status). + +- **Graduation wizard final-project detection for S2/S3 (BUG-003, #130):** the transcript check only matched undergraduate course names (`skripsi|tugas akhir|thesis|disertasi`), so postgraduate students — whose final requirement is `Ujian Tutup` (S2 Akuntansi), `Ujian Hasil Penelitian dan Tutup` (S2 Manajemen), or `Ujian Terbuka (Promosi)` (S3) — were always reported as missing it, `transcript_ok` never auto-checked, and a missing grade was never filled from Excel. Detection now selects a pattern from the program studi (`S2`/`S3` prefix, S1 fallback) and excludes proposal/methodology/results-exam milestones; S3 deliberately rejects `Ujian Tutup` because it is only an intermediate milestone there. The program name is persisted in wizard progress so `finish()` uses the same pattern. `transcript_ok` requires a grade without the `choosed` gate, which is `false` on every sampled postgraduate final-project row. Card 3 wording now says "karya akhir". - **FA6 icon consistency:** `header.php` logout button and `DESIGN.md` reference still used FA5 deprecated names (`fa-sign-out-alt` → `fa-right-from-bracket`, `fa-tachometer-alt` → `fa-gauge-high`) — aligned to canonical FA6 names to match `sidebar.php` - **Sidebar active state:** fixed route detection — `getPath()` returned `index.php/dashboard` instead of `dashboard`; now uses `current_url()` with `basename()` which handles both URL formats diff --git a/app/Controllers/Graduation.php b/app/Controllers/Graduation.php index ea042e5..701c071 100644 --- a/app/Controllers/Graduation.php +++ b/app/Controllers/Graduation.php @@ -22,8 +22,8 @@ class Graduation extends BaseController * Excel column -> internal key map (header row is matched case-insensitively). * * Minimal template: NIM (lookup), Nama (optional KTP-name validation - * source), Tanggal Keluar, IPK, and a letter grade for the thesis/skripsi - * course (nilai_skripsi). + * source), Tanggal Keluar, IPK, and a letter grade for the final-project + * course (nilai_skripsi; kept for template compatibility). * jenis_keluar is always "Lulus" and periode_keluar is derived from tgl_keluar. */ private const EXCEL_HEADERS = [ @@ -34,6 +34,24 @@ class Graduation extends BaseController 'nilai_skripsi' => 'nilai_skripsi', ]; + /** + * Final-project course names per jenjang, verified live: + * - S1: Skripsi / THESIS / Tugas Akhir. + * - S2: Tesis, "Ujian Hasil Penelitian dan Tutup" (Manajemen), and "Ujian + * Tutup" (Akuntansi). Plain "Ujian Hasil Penelitian" is an intermediate + * milestone and must not match. + * - S3: only "Ujian Terbuka (Promosi)". S3 also uses "Ujian Tutup" as an + * intermediate milestone before the open exam, so it cannot be accepted. + * + * Generic "disertasi"/"penelitian" is deliberately excluded so intermediate + * milestones are not mistaken for the final requirement. + */ + private const FINAL_PROJECT_PATTERNS = [ + 'S1' => '/skripsi|tugas\s*akhir|thesis|\btesis\b/i', + 'S2' => '/\btesis\b|skripsi|tugas\s*akhir|thesis|ujian\s+hasil\s+penelitian\s+dan\s+tutup|\bujian\s+tutup\b/i', + 'S3' => '/ujian\s+terbuka|promosi/i', + ]; + private WizardProgress $wizard; private NeoFeeder $neoFeeder; private PisnService $pisn; @@ -195,6 +213,19 @@ public function step() $error = $idResp['error_msg'] ?? 'Gagal memuat data mahasiswa.'; } + // The final-project course name differs per jenjang/prodi (S2 + // Akuntansi vs S2 Manajemen vs S3), so keep the program name in the + // wizard state for the completeness check and for finish(). + $programStudi = (string) ($identity['nama_program_studi'] ?? ($student['program_studi'] ?? '')); + if ($programStudi !== '' && ($progress['students'][$idx]['program_studi'] ?? '') !== $programStudi) { + $progress['students'][$idx]['program_studi'] = $programStudi; + $student['program_studi'] = $programStudi; + $resumeToken = $this->currentToken(); + if ($resumeToken !== null) { + $this->wizard->save($resumeToken, $progress); + } + } + $acResp = $this->neoFeeder->getAktivitasKuliahMahasiswa($apiToken, [ 'filter' => "nim='{$nimSafe}'", 'order' => 'id_semester asc', @@ -211,7 +242,7 @@ public function step() $trResp = $this->neoFeeder->getCekTranskripMahasiswa($apiToken, (string) $student['nim']); if (($trResp['error_code'] ?? -1) === 0 && isset($trResp['data'])) { $transcript = $trResp['data']; - $completeness = $this->checkTranscriptCompleteness($transcript); + $completeness = $this->checkTranscriptCompleteness($transcript, $programStudi); } elseif (($trResp['error_code'] ?? -1) !== 0 && empty($error)) { $error = $trResp['error_msg'] ?? 'Gagal memuat data transkrip.'; } @@ -324,20 +355,12 @@ public function step() && $lastStatusVal === (string) $activeCode; } - // Step 3: a thesis course exists with a grade AND is included in the - // transcript (choosed:true). choosed is informational for the completeness - // badge but required for this auto-check gate (ENH-008 spec). - $transcriptOk = false; - if (! empty($completeness['complete'])) { - foreach (($completeness['thesis'] ?? []) as $t) { - if (! empty($t['hasGrade']) && ! empty($t['choosed'])) { - $transcriptOk = true; - break; - } - } - } + // Step 3: a final-project course exists with a grade. `choosed` stays + // informational only (BUG-001): live postgraduate rows are all + // choosed:false, so requiring it would keep this gate permanently off. + $transcriptOk = ! empty($completeness['complete']); - // ENH-010 fix: only the ungraded thesis/skripsi row in the LAST semester + // ENH-010 fix: only the ungraded final-project row in the LAST semester // (largest id_smt) is the one that will have its grade filled. $targetThesisSemester = $this->lastUngradedThesisSemester($completeness['thesis'] ?? []); @@ -374,25 +397,42 @@ public function step() ]); } + /** + * Selects the final-project pattern from the program name ("S2 Ilmu + * Akuntansi", "S3 Ilmu Manajemen"). Unknown/blank names fall back to the S1 + * pattern, which never matches the postgraduate-only "Ujian ..." names. + */ + private static function finalProjectPattern(string $programStudi): string + { + if (preg_match('/^\s*S3\b/i', $programStudi) === 1) { + return self::FINAL_PROJECT_PATTERNS['S3']; + } + if (preg_match('/^\s*S2\b/i', $programStudi) === 1) { + return self::FINAL_PROJECT_PATTERNS['S2']; + } + + return self::FINAL_PROJECT_PATTERNS['S1']; + } + /** * Checks whether a student's transcript is complete for graduation. * - * Completeness rule (per BUG-001): the thesis/skripsi/tugas akhir course - * must exist in the transcript AND carry a non-empty grade. The `choosed` - * marker (course "included in transcript" per the cloud Cek Transkrip - * Mahasiswa menu) is informational only — it does NOT block completeness. + * Completeness rule (per BUG-001): the final-project course must exist in + * the transcript AND carry a non-empty grade. The `choosed` marker (course + * "included in transcript" per the cloud Cek Transkrip Mahasiswa menu) is + * informational only — it does NOT block completeness. * - * The thesis course is detected by name - * (/skripsi|tugas\s*akhir|thesis|disertasi/i) because the WS/cloud - * transcript payloads carry no type flag. + * The course is detected by name (FINAL_PROJECT_PATTERNS selected from the + * program studi) because the WS/cloud transcript payloads carry no type flag. * - * @param array $transcript Rows from getCekTranskripMahasiswa() - * (cloud nilai_mahasiswa rows, kebab nm_mk/kode_mk/id_smt). + * @param array $transcript Rows from getCekTranskripMahasiswa() + * (cloud nilai_mahasiswa rows, kebab nm_mk/kode_mk/id_smt). + * @param string $programStudi Program name, e.g. "S2 Ilmu Akuntansi". * * @return array{complete:bool, reason:string, thesis:array} - * thesis = detail rows of each detected thesis course. + * thesis = detail rows of each detected final-project course. */ - private function checkTranscriptCompleteness(array $transcript): array + private function checkTranscriptCompleteness(array $transcript, string $programStudi = ''): array { if (empty($transcript)) { return [ @@ -402,12 +442,12 @@ private function checkTranscriptCompleteness(array $transcript): array ]; } - $thesisPattern = '/skripsi|tugas\s*akhir|thesis|disertasi/i'; - $thesisRows = []; + $pattern = self::finalProjectPattern($programStudi); + $thesisRows = []; foreach ($transcript as $row) { $name = $row['nm_mk'] ?? ($row['nama_mata_kuliah'] ?? ''); - if (preg_match($thesisPattern, $name)) { + if (preg_match($pattern, $name)) { $nilai = $row['nilai_huruf'] ?? ($row['nilai_angka'] ?? null); $hasGrade = ($nilai !== null && $nilai !== ''); $thesisRows[] = [ @@ -424,7 +464,7 @@ private function checkTranscriptCompleteness(array $transcript): array if (empty($thesisRows)) { return [ 'complete' => false, - 'reason' => 'MK skripsi/tugas akhir tidak ditemukan di transkrip.', + 'reason' => 'MK karya akhir (skripsi/tesis/ujian akhir) tidak ditemukan di transkrip.', 'thesis' => [], ]; } @@ -441,41 +481,42 @@ private function checkTranscriptCompleteness(array $transcript): array return [ 'complete' => false, - 'reason' => 'Nilai MK skripsi/tugas akhir belum tersedia.', + 'reason' => 'Nilai MK karya akhir (skripsi/tesis/ujian akhir) belum tersedia.', 'thesis' => $thesisRows, ]; } /** - * Finds the thesis/skripsi course row that is registered but still carries + * Finds the final-project course row that is registered but still carries * no grade and sits in the LAST semester (largest id_smt), returning its * composite key for UpdateNilaiPerkuliahanKelas. * - * A student may have several thesis/skripsi rows across semesters (e.g. + * A student may have several final-project rows across semesters (e.g. * repeating the course). Only the row in the last semester may receive the * grade, and only while that row is still ungraded — if the last semester's - * thesis row already has a grade, no row is returned (we never backfill an - * older, ungraded row). + * row already has a grade, no row is returned (we never backfill an older, + * ungraded row). * - * @param array $transcript Rows from getCekTranskripMahasiswa() - * (cloud nilai_mahasiswa rows carrying id_kls / id_reg_pd). + * @param array $transcript Rows from getCekTranskripMahasiswa() + * (cloud nilai_mahasiswa rows carrying id_kls / id_reg_pd). + * @param string $programStudi Program name, e.g. "S2 Ilmu Akuntansi". * * @return array|null The row's key as ['id_reg_pd', 'id_kls', 'id_smt'], or - * null when the last-semester thesis row is already graded - * or no thesis row exists. + * null when the last-semester row is already graded + * or no final-project row exists. */ - private function findMissingGradeThesis(array $transcript): ?array + private function findMissingGradeThesis(array $transcript, string $programStudi = ''): ?array { if (empty($transcript)) { return null; } - $thesisPattern = '/skripsi|tugas\s*akhir|thesis|disertasi/i'; - $thesisRows = []; + $pattern = self::finalProjectPattern($programStudi); + $thesisRows = []; foreach ($transcript as $row) { $name = $row['nm_mk'] ?? ($row['nama_mata_kuliah'] ?? ''); - if (! is_string($name) || ! preg_match($thesisPattern, $name)) { + if (! is_string($name) || ! preg_match($pattern, $name)) { continue; } $thesisRows[] = $row; @@ -485,12 +526,12 @@ private function findMissingGradeThesis(array $transcript): ?array return null; } - // The candidate is the thesis row in the LAST semester. + // The candidate is the final-project row in the LAST semester. $smtKey = fn (array $r) => (string) ($r['id_smt'] ?? ($r['id_semester'] ?? '')); usort($thesisRows, fn ($a, $b) => strcmp($smtKey($b), $smtKey($a))); $last = $thesisRows[0]; - // If the last-semester thesis row already has a grade, do nothing. + // If the last-semester final-project row already has a grade, do nothing. $grade = trim((string) ($last['nilai_huruf'] ?? '')); if ($grade === '') { $grade = trim((string) ($last['nilai_angka'] ?? '')); @@ -513,9 +554,9 @@ private function findMissingGradeThesis(array $transcript): ?array } /** - * Returns the semester (id_smt) of the thesis/skripsi course row that sits in + * Returns the semester (id_smt) of the final-project course row that sits in * the LAST semester, but only when that row is still ungraded. Returns null - * when the last-semester thesis row already has a grade (so nothing is tagged + * when the last-semester row already has a grade (so nothing is tagged * "(akan diisi)"). * * @param list $thesisRows Rows from checkTranscriptCompleteness(). @@ -627,7 +668,7 @@ public function stepPost() $errors[] = 'Centang konfirmasi akademik (IPK semester terakhir sesuai & status Aktif).'; } if (! $transcriptOk) { - $errors[] = 'Centang konfirmasi kelengkapan transkrip (MK skripsi bernilai & masuk transkrip).'; + $errors[] = 'Centang konfirmasi kelengkapan transkrip (MK karya akhir bernilai).'; } if ($nim === '' || $nama === '' || $jenisKeluar === '' || $tglKeluar === '' || $periodeKeluar === '' || $ipk === '') { $errors[] = 'Field kelulusan wajib diisi (No Ijazah otomatis "-").'; @@ -680,7 +721,7 @@ public function stepPost() public function finish(): RedirectResponse { // Full submission iterates the whole batch with one synchronous Neo Feeder - // call chain per student (InsertMahasiswaLulusDO + academic edits + thesis + // call chain per student (InsertMahasiswaLulusDO + academic edits + final-project // grade), which can exceed PHP's default 60s time limit on large batches. // Allow this long-running server-side job to finish. ponytail: unbounded; a // progress/queue pipeline is the upgrade if batches keep growing. @@ -792,13 +833,21 @@ public function finish(): RedirectResponse } } - // ENH-010: fill in a missing thesis/skripsi grade from the Excel value. + // ENH-010: fill in a missing final-project grade from the Excel value. $thesisGrade = trim((string) ($student['nilai_skripsi'] ?? '')); if ($thesisGrade !== '') { $nimSafe = trim((string) ($student['nim'] ?? $g['nim'])); + $programStudi = (string) ($student['program_studi'] ?? ''); + if ($programStudi === '') { + $idResp = $this->neoFeeder->getListMahasiswa($apiToken, [ + 'filter' => "nim='{$nimSafe}'", + 'limit' => 1, + ]); + $programStudi = (string) ($idResp['data'][0]['nama_program_studi'] ?? ''); + } $trResp = $this->neoFeeder->getCekTranskripMahasiswa($apiToken, $nimSafe); $thesisRow = ($trResp['error_code'] ?? -1) === 0 - ? $this->findMissingGradeThesis($trResp['data'] ?? []) + ? $this->findMissingGradeThesis($trResp['data'] ?? [], $programStudi) : null; if ($thesisRow !== null) { $gradeResp = $this->neoFeeder->updateNilaiPerkuliahanKelas( @@ -808,9 +857,9 @@ public function finish(): RedirectResponse ['nilai_huruf' => $thesisGrade] ); if (($gradeResp['error_code'] ?? -1) === 0) { - $results[] = ['nim' => $g['nim'], 'success' => true, 'msg' => 'Nilai skripsi terisi.']; + $results[] = ['nim' => $g['nim'], 'success' => true, 'msg' => 'Nilai karya akhir terisi.']; } else { - $results[] = ['nim' => $g['nim'], 'success' => false, 'msg' => $gradeResp['error_msg'] ?? 'Gagal mengisi nilai skripsi.']; + $results[] = ['nim' => $g['nim'], 'success' => false, 'msg' => $gradeResp['error_msg'] ?? 'Gagal mengisi nilai karya akhir.']; } } } diff --git a/app/Views/graduation/wizard.php b/app/Views/graduation/wizard.php index c81ba31..c52f814 100644 --- a/app/Views/graduation/wizard.php +++ b/app/Views/graduation/wizard.php @@ -159,7 +159,7 @@
- Transkrip lengkap — nilai skripsi/tugas akhir tersedia. + Transkrip lengkap — nilai karya akhir tersedia.
@@ -169,7 +169,7 @@
- MK skripsi/tugas akhir tidak ditemukan di transkrip. + MK karya akhir (skripsi/tesis/ujian akhir) tidak ditemukan di transkrip.
@@ -223,7 +223,7 @@
Jumlah nilai terload:
> - +
diff --git a/docs/IMPROVEMENTS.md b/docs/IMPROVEMENTS.md index d0d91c6..52b67df 100644 --- a/docs/IMPROVEMENTS.md +++ b/docs/IMPROVEMENTS.md @@ -240,3 +240,14 @@ The label is encoded directly in the ID prefix. When a valid item becomes a GitH - **Actual Fix:** In `README.md`: (1) update the feature/status summary and Development Status to reflect the current menus (Mahasiswa, AktivitasKuliah, MahasiswaLulusDo, PISN Graduation wizard, Verifikasi IPK) and dependencies; (2) replace the current minimal API section with a record of the Neo Feeder endpoints the project actually consumes, per endpoint noting the WS act (or cloud path), request params (act/token and, where used, filter/order/key/record), and a concise response summary (error_code/data). Include a dedicated `GetDictionary` subsection documenting it as a schema-inspection tool called ad-hoc (not part of the app's runtime request path): payload `{'act':'GetDictionary','token':...,'fungsi':''}` and a short example schema response; the endpoint list is derived from the methods in `app/Libraries/NeoFeeder.php` (WS acts: GetToken, GetProfilPT, GetListMahasiswa, GetAktivitasKuliahMahasiswa, GetListRiwayatPendidikanMahasiswa, GetStatusMahasiswa, GetJenisKeluar, GetSemester, GetListMahasiswaLulusDO, GetCountMahasiswa, GetCountAktivitasKuliahMahasiswa, GetCountMahasiswaLulusDO, GetTranskripMahasiswa, GetBiodataMahasiswa, GetDetailPerkuliahanMahasiswa, InsertMahasiswaLulusDO, Insert/Update/Delete BiodataMahasiswa, Insert/Update/Delete PerkuliahanMahasiswa, UpdateNilaiPerkuliahanKelas; plus cloud REST `/ws/transkrip/cari_mahasiswa` + `/ws/transkrip/nilai_mahasiswa`). Schema facts are summarized from prior live-verified test results (already recorded in the tracker), not re-tested. - **Actual Implemented:** Rewrote `README.md` on branch `docs/readme-api-endpoints` from `main`: (1) Development Status now lists the current features (Login/Auth, AdminLTE 4, Profil PT, Mahasiswa, Aktivitas Kuliah, Mahasiswa Lulus/DO, PISN Graduation wizard, Verifikasi IPK); (2) Directory Structure updated with the actual Controllers/Libraries/Views; (3) the Neo Feeder API section now documents the standard request/response envelope, List/Get endpoints table, Mutation endpoints table (with `key` and the complete-record requirement), the two cloud REST transcript endpoints, and a dedicated GetDictionary schema-inspection subsection. Markdown verified LF-only + no-BOM (CI markdown checks pass). - **Changes:** `README.md` now reflects the current project state and records every Neo Feeder endpoint the project consumes (WS acts, mutations, cloud REST, and GetDictionary schema inspection), so new features no longer require re-testing endpoints already in use. + +### BUG-003 — Final-project detection only recognises undergraduate course names +- **Status:** `implemented` +- **Issue:** #130 +- **Recorded:** 2026-10-21 +- **Implemented:** 2026-10-21 +- **Problem:** `checkTranscriptCompleteness()` and `findMissingGradeThesis()` detected the final-project course with `/skripsi|tugas\s*akhir|thesis|disertasi/i`. Postgraduate curricula do not register a Tesis/Skripsi/Disertasi course: S2 Akuntansi uses `Ujian Tutup`, S2 Manajemen `Ujian Hasil Penelitian dan Tutup` (5 SKS), and S3 `Ujian Terbuka (Promosi)`. A live probe of the 37 supplied S2 Akuntansi NIMs found `Ujian Tutup` in every final semester; the original pattern matched none. A second gate blocked postgraduate auto-check even after the name was fixed: `transcriptOk` required `choosed:true`, while every sampled final-project row carried `choosed:false` (58/58 S2 Akuntansi, 8/8 S3). Generic `disertasi`/`penelitian` tokens are unsafe: they match the S3 intermediate milestone `Ujian Hasil Penelitian Disertasi` and the S2 proposal courses. A global `ujian tutup` token is also unsafe because S3 uses it before the open exam (e.g. 2023250001 had `Ujian Tutup` A while `Ujian Terbuka` was still E; 2024150009 has only `Ujian Tutup` E). +- **Possible Fix:** Select the pattern from the program studi — S1 `skripsi|tugas akhir|thesis`, S2 `ujian hasil penelitian dan tutup|ujian tutup` (plus `tesis`), S3 `ujian terbuka|promosi` — deliberately excluding generic `disertasi`/`penelitian`; persist the program name in wizard progress so `finish()` uses the same pattern; drop the `choosed` requirement from `transcriptOk` per BUG-001; reword UI/validation text to "karya akhir". +- **Actual Fix:** Added private const `FINAL_PROJECT_PATTERNS` (S1/S2/S3) and `finalProjectPattern()`, which reads the `S2`/`S3` prefix of `nama_program_studi` and falls back to S1. Both `checkTranscriptCompleteness()` and `findMissingGradeThesis()` now take `$programStudi`. `step()` stores the program name in wizard progress and `finish()` falls back to `GetListMahasiswa` when an older progress record lacks it. `transcriptOk` is now `! empty($completeness['complete'])` — grade required, `choosed` informational only. Error/reason/label text changed from "skripsi/tugas akhir" to "karya akhir (skripsi/tesis/ujian akhir)". The Excel column name `nilai_skripsi` is unchanged for template compatibility. +- **Actual Implemented:** Live read-only verification of the shipped methods (10 cases, all PASS): S2 Akuntansi graded (2023240002) complete; S2 Akuntansi ungraded (2023240008) incomplete with fill target 20251; S2 Akuntansi earlier-grade (2023240006) complete with fill target 20261; S2 Manajemen graded (2023220008) complete; S2 Manajemen ungraded (2021456421278) incomplete; S3 open exam graded (2023250001) complete; S3 `Ujian Tutup`-only (2024150009) incomplete; S3 results-exam-only (2023250002) incomplete; S1 Skripsi (202010087) complete. Batch over the 37 supplied S2 Akuntansi NIMs: 37/37 detected, 32 complete, 5 incomplete (2023240008, 2024140001, 2024140008, 2024140009, 2024140014). Added `tests/unit/Controllers/GraduationTest.php` (21 tests: per-program positives plus proposal, methodology, results-exam, S3 closed-exam, publication, S1 closed-exam, and regular-course negatives, ungraded and `choosed:false` cases, S3 fill-target exclusion). Full suite 57 tests / 92 assertions green; php-cs-fixer dry-run clean. +- **Changes:** `app/Controllers/Graduation.php` (per-jenjang `FINAL_PROJECT_PATTERNS`, program persistence + `finish()` fallback, `transcriptOk` gate, messages/comments), `app/Views/graduation/wizard.php` (card 3 wording + checkbox label), `tests/unit/Controllers/GraduationTest.php` (new). Grade `E` is still treated as "has a grade" (status quo, out of scope). diff --git a/tests/unit/Controllers/GraduationTest.php b/tests/unit/Controllers/GraduationTest.php new file mode 100644 index 0000000..178eadd --- /dev/null +++ b/tests/unit/Controllers/GraduationTest.php @@ -0,0 +1,119 @@ + + */ + public static function courseProvider(): array + { + return [ + 'S1 Skripsi' => ['S1 Manajemen', 'Skripsi', true], + 'S1 THESIS' => ['S1 Manajemen', 'THESIS', true], + 'S1 Tugas Akhir' => ['S1 Manajemen', 'Tugas Akhir', true], + 'S2 Manajemen Tesis' => ['S2 Ilmu Manajemen', 'Tesis', true], + 'S2 Manajemen final exam' => ['S2 Ilmu Manajemen', 'Ujian Hasil Penelitian dan Tutup', true], + 'S2 Akuntansi Ujian Tutup' => ['S2 Ilmu Akuntansi', 'Ujian Tutup', true], + 'S2 Akuntansi results milestone' => ['S2 Ilmu Akuntansi', 'Ujian Hasil Penelitian', false], + 'S2 Akuntansi proposal milestone' => ['S2 Ilmu Akuntansi', 'Ujian Proposal Penelitian', false], + 'S3 Ujian Terbuka (Promosi)' => ['S3 Ilmu Manajemen', 'Ujian Terbuka (Promosi)', true], + 'S3 closed exam is intermediate' => ['S3 Ilmu Manajemen', 'Ujian Tutup', false], + 'S3 results-exam milestone' => ['S3 Ilmu Manajemen', 'Ujian Hasil Penelitian Disertasi', false], + 'S3 proposal milestone' => ['S3 Ilmu Manajemen', 'Ujian Seminar Proposal', false], + 'S3 publication requirement' => ['S3 Ilmu Manajemen', 'Publikasi Jurnal Internasional', false], + 'S2 results-exam milestone' => ['S2 Ilmu Manajemen', 'Ujian Hasil Penelitian Disertasi', false], + 'S2 methodology course' => ['S2 Ilmu Manajemen', 'Metodologi Penelitian Lanjutan', false], + 'S1 closed exam is not final' => ['S1 Manajemen', 'Ujian Tutup', false], + 'regular course' => ['S1 Manajemen', 'Manajemen Strategik', false], + ]; + } + + #[DataProvider('courseProvider')] + public function testFinalProjectDetectionPerProgram(string $programStudi, string $courseName, bool $expected): void + { + $result = $this->completeness([ + ['nm_mk' => $courseName, 'nilai_huruf' => 'A', 'choosed' => false], + ], $programStudi); + + $this->assertSame($expected, $result['complete'], "Course '{$courseName}' for {$programStudi}"); + } + + public function testUngradedFinalProjectIsIncomplete(): void + { + $result = $this->completeness([ + ['nm_mk' => 'Ujian Tutup', 'nilai_huruf' => '', 'nilai_angka' => null], + ], 'S2 Ilmu Akuntansi'); + + $this->assertFalse($result['complete']); + $this->assertStringContainsString('belum tersedia', $result['reason']); + } + + public function testChoosedFalseDoesNotBlockCompleteness(): void + { + $result = $this->completeness([ + ['nm_mk' => 'Ujian Tutup', 'nilai_huruf' => 'A', 'choosed' => false], + ], 'S2 Ilmu Akuntansi'); + + $this->assertTrue($result['complete']); + } + + public function testMissingGradeIgnoresS3ClosedExam(): void + { + // S3 "Ujian Tutup" is an intermediate milestone; only the open exam is final. + $this->assertNull($this->missing([ + ['nm_mk' => 'Ujian Tutup', 'id_smt' => '20261', 'nilai_huruf' => '', 'nilai_angka' => null, 'id_reg_pd' => 'reg', 'id_kls' => 'tutup'], + ], 'S3 Ilmu Manajemen')); + + $this->assertNull($this->missing([ + ['nm_mk' => 'Ujian Tutup', 'id_smt' => '20261', 'nilai_huruf' => '', 'nilai_angka' => null, 'id_reg_pd' => 'reg', 'id_kls' => 'tutup'], + ['nm_mk' => 'Ujian Terbuka (Promosi)', 'id_smt' => '20261', 'nilai_huruf' => 'A', 'nilai_angka' => '90', 'id_reg_pd' => 'reg', 'id_kls' => 'terbuka'], + ], 'S3 Ilmu Manajemen')); + } + + public function testMissingGradeTargetsS2ClosedExam(): void + { + $row = $this->missing([ + ['nm_mk' => 'Ujian Hasil Penelitian', 'id_smt' => '20251', 'nilai_huruf' => 'A', 'nilai_angka' => '90', 'id_reg_pd' => 'reg', 'id_kls' => 'results'], + ['nm_mk' => 'Ujian Tutup', 'id_smt' => '20261', 'nilai_huruf' => '', 'nilai_angka' => null, 'id_reg_pd' => 'reg', 'id_kls' => 'tutup'], + ], 'S2 Ilmu Akuntansi'); + + $this->assertSame(['id_reg_pd' => 'reg', 'id_kls' => 'tutup', 'id_smt' => '20261'], $row); + } + + /** + * @param list> $rows + * + * @return array{complete:bool, reason:string, thesis:array} + */ + private function completeness(array $rows, string $programStudi): array + { + $controller = (new ReflectionClass(Graduation::class))->newInstanceWithoutConstructor(); + $method = new ReflectionMethod(Graduation::class, 'checkTranscriptCompleteness'); + $method->setAccessible(true); + + return $method->invoke($controller, $rows, $programStudi); + } + + /** + * @param list> $rows + */ + private function missing(array $rows, string $programStudi): ?array + { + $controller = (new ReflectionClass(Graduation::class))->newInstanceWithoutConstructor(); + $method = new ReflectionMethod(Graduation::class, 'findMissingGradeThesis'); + $method->setAccessible(true); + + return $method->invoke($controller, $rows, $programStudi); + } +}