# How Test System Works

## Arsitektur Umum

Sistem psikotes terdiri dari 4 test berurutan: **Test1 (PAPIKOSTIK)** → **Test2 (DISC)** → **Test3 (Ishihara)** → **Test4 (WPT)**.  
Setiap test memiliki sesi (`test_sessions`), soal (`mst_test1`–`mst_test4`), dan jawaban (`applicant_answers`).

Alur:
1. Applicant menerima undangan (`applications_invitations` dengan `topic='Psikotes'`)
2. Memulai test → `TestSession` dibuat (`status='in_progress'`, `deadline_at` dihitung)
3. Menjawab soal → jawaban disimpan via AJAX ke `applicant_answers`
4. Selesai → `finishExam` → sesi di-set `status='completed'`
5. Report diakses via `RptestController` → merender tab per test + psikogram

---

## Database Schema

### `test_sessions` — Sesi Ujian

| Kolom | Tipe | Fungsi |
|---|---|---|
| `id` | bigint PK | Auto increment |
| `id_applicant` | bigint FK → mst_applicants.id | Pelamar yang mengerjakan |
| `id_invitation` | bigint FK → applications_invitations.id | Undangan terkait |
| `test_type` | enum('test1','test2','test3','test4') | Jenis test |
| `started_at` | timestamp | Waktu mulai (server-side) |
| `deadline_at` | timestamp | **Batas akhir** (server-side) — dihitung dari `started_at + durasi` |
| `finished_at` | timestamp | Waktu selesai |
| `remaining_seconds` | int | Sisa waktu jika di-pause (ketika `deadline_at` di-null-kan) |
| `status` | enum('in_progress','completed','abandoned') | Status sesi |
| `isactive` | enum('0','1') | Soft delete flag |

### `applicant_answers` — Jawaban Peserta

| Kolom | Tipe | Fungsi |
|---|---|---|
| `id` | bigint PK | Auto increment |
| `test_session_id` | bigint FK → test_sessions.id | Sesi terkait |
| `question_id` | bigint FK → mst_questions.id | Soal yang dijawab |
| `answer` | text | Jawaban (test1/test3/test4: "1"/"2"/angka/"TIDAK_ADA"; test4: pilihan/teks) |
| `raw_option` | tinyint | Test1: pilihan 1/2 |
| `most_option` | tinyint | **Test2 khusus**: index opsi yang dipilih sebagai "Paling Sesuai" |
| `least_option` | tinyint | **Test2 khusus**: index opsi yang dipilih sebagai "Tidak Sesuai" |

### `mst_questions` — Mapping Soal (Bridge Table)

| Kolom | Tipe | Fungsi |
|---|---|---|
| `id` | bigint PK | Auto increment |
| `test_type` | varchar | 'test1','test2','test3','test4' |
| `original_id` | bigint | FK ke `mst_test1/2/3/4.id` |

Model `Question` punya accessor `getDetailAttribute()` yang otomatis fetch detail dari tabel asal berdasarkan `test_type`+`original_id`.

### `mst_test1` — Soal Test 1 (PAPIKOSTIK)

| Kolom | Tipe | Fungsi |
|---|---|---|
| `id` | bigint PK | Auto increment |
| `question_number` | int | Nomor urut soal (1-90) |
| `option1` | varchar | Pernyataan opsi 1 |
| `option2` | varchar | Pernyataan opsi 2 |
| `page_num` | int | Halaman (10 soal per halaman → 9 halaman) |
| `isactive` | enum('0','1') | |

Setiap soal Test 1 adalah pilihan antara 2 pernyataan. Masing-masing opsi dikaitkan ke kode PAPIKOSTIK via tabel `mst_test1_codes`.

### `mst_test1_codes` — Mapping Opsi → Kode PAPIKOSTIK

| Kolom | Tipe | Fungsi |
|---|---|---|
| `id` | bigint PK | |
| `mst_test1_id` | bigint FK → mst_test1.id | Soal terkait |
| `code` | varchar(1) | Kode PAPI (A-Z, 20 kode: N,G,A,L,P,I,T,V,S,B,O,X,C,D,R,Z,E,K,F,W) |
| `option` | enum('1','2') | Opsi 1 atau 2 |

Fungsi: Jika user memilih option 1 pada soal X, maka kode yang tertera di `mst_test1_codes` untuk `mst_test1_id=X AND option='1'` mendapat +1 score.

### `ref_test1_uraian` — Interpretasi Score PAPIKOSTIK

| Kolom | Tipe | Fungsi |
|---|---|---|
| `id` | bigint PK | |
| `code` | varchar(2) | Kode PAPI (A-Z) |
| `min` | tinyint | Batas bawah score |
| `max` | tinyint | Batas atas score |
| `line1`–`line4` | text | Deskripsi interpretasi per rentang score |

Contoh: Kode A, score 0-4 → "Tidak kompetitif...", score 5-7 → "Tahu akan tujuan...", score 8-9 → "Sangat berambisi..."

### `mst_test2` — Soal Test 2 (DISC)

| Kolom | Tipe | Fungsi |
|---|---|---|
| `id` | bigint PK | |
| `question_number` | int | |
| `options` | longtext | JSON array of option objects: `[{"text":"...","type":"D"}, ...]` |
| `page_num` | int | Halaman (1 soal per halaman) |

Setiap soal DISC memiliki 4 opsi pernyataan. Masing-masing opsi punya `type`: **D**, **I**, **S**, **C**, atau **\*** (netral).  
User memilih 1 **Paling Sesuai (Most)** dan 1 **Tidak Sesuai (Least)**.

### `ref_disc_grafik` — Grafik Mapping DISC

| Kolom | Tipe | Fungsi |
|---|---|---|
| `id` | bigint PK | |
| `tipe` | varchar(1) | 'M' (Most), 'L' (Least), 'C' (Change) |
| `nomor` | varchar(5) | Jumlah pilihan mentah (0-9) |
| `val_d` | varchar(5) | Value grafik untuk D |
| `val_i` | varchar(5) | Value grafik untuk I |
| `val_s` | varchar(5) | Value grafik untuk S |
| `val_c` | varchar(5) | Value grafik untuk C |

Fungsi: Menerjemahkan pilihan mentah (raw count) ke nilai grafik standar DISC.  
Contoh: Most D=5 → cari `tipe='M' AND nomor='5'` → ambil `val_d`.

### `ref_disc_rumus_def` — Rumus Penentuan Karakter DISC

| Kolom | Tipe | Fungsi |
|---|---|---|
| `id` | bigint PK | |
| `kode` | varchar(15) | Kode karakter (C, D, D/C-D, D/I-D, DC, CD, DLL) |
| `line1` | text | **Rumus PHP eval** untuk Mask Public Self |
| `line2` | text | Rumus untuk Core Private Self |
| `line3` | text | Rumus untuk Mirror Perceived Self |

Rumus menggunakan variable: `$D1, $I1, $S1, $C1` (Most grafik), `$D2, $I2, $S2, $C2` (Least grafik), `$D3, $I3, $S3, $C3` (Change grafik).

Contoh rumus: `$D1 > 0 && $I1 <= 0 && $S1 <= 0 && $C1 <= 0`  
Jika dievaluasi `true`, maka karakter tersebut cocok. **Last match wins** — semua rumus dievaluasi berurutan dan yang terakhir cocok diambil.

### `ref_disc_result` — Deskripsi Karakter DISC

| Kolom | Tipe | Fungsi |
|---|---|---|
| `id` | bigint PK | |
| `kode` | varchar(20) | Sama dengan `ref_disc_rumus_def.kode` |
| `desc1` | varchar(50) | Nama karakter (misal: "LOGICAL THINKER") |
| `desc2` | text | Deskripsi detail karakter |
| `desc3` | text | **Job Match** — daftar pekerjaan yang cocok |
| `desc4` | text | **Deskripsi Kepribadian** — paragraf naratif |

### `mst_test3` — Soal Test 3 (Ishihara / Buta Warna)

| Kolom | Tipe | Fungsi |
|---|---|---|
| `id` | bigint PK | |
| `question_number` | int | 1-24 |
| `image_url` | text | Path gambar lingkaran Ishihara |
| `answer_key` | varchar | Jawaban benar (angka yang terlihat) |
| `page_num` | int | Halaman (1 soal per halaman) |

### `mst_test4` — Soal Test 4 (WPT — Wonderlic Personnel Test)

| Kolom | Tipe | Fungsi |
|---|---|---|
| `id` | bigint PK | |
| `question_number` | int | |
| `question` | text | Teks soal |
| `image_url` | text | Gambar pendukung (nullable) |
| `options` | longtext | JSON array pilihan ganda (nullable) |
| `answer_key` | varchar | Kunci jawaban |
| `page_num` | int | |

Tipe soal: A (gambar + pilihan ganda), B (teks + pilihan ganda), C (teks + input manual).

### `ref_psikogram` — Matriks Assessment Psikogram

| Kolom | Tipe | Fungsi |
|---|---|---|
| `id` | bigint PK | |
| `aspek` | varchar(50) | Nama aspek (Kecerdasan Umum, Gaya Kerja, Tempo Kerja, dll — 14 aspek) |
| `deskripsi` | text | Penjelasan aspek |
| `persen` | decimal | Bobot persentase aspek (total 14 aspek ~ 4.00) |
| `kategori_aspek` | varchar(50) | Kelompok (Intelektual, Sikap Kerja, Hubungan Sosial, dll) |
| `pembagian_aspek` | varchar(3) | Pembagi (jumlah aspek dalam satu grup) |
| `nilai_aspek` | varchar(15) | Rumus PAPI yang digunakan (misal: "(C, D, R)") |
| `nilai_ideal` | int | Target ideal (default 4, bisa dioverride per posisi) |
| `sr`–`st` | text | **Rumus PHP eval** untuk setiap kategori (SR=sangat rendah, R=rendah, C-=dibawah rata-rata, C=rata-rata, C+=diatas rata-rata, T=tinggi, ST=sangat tinggi) |

Contoh rumus `ref_psikogram`:  
- `sr`: `round(($C + $D + $R) / 3) <= 2`  
- `c`: `round(($C + $D + $R) / 3) == 5`  
- `st`: `round(($C + $D + $R) / 3) >= 8`

Variable PAPI: `$A, $B, $C, $D, $E, $F, $G, $I, $K, $L, $N, $O, $P, $R, $S, $T, $V, $W, $X, $Z`  
(diambil dari `jumlah` = total score dari Test1 untuk kode tersebut).

### `vacancies_ideal_score` — Ideal Score per Posisi

| Kolom | Tipe | Fungsi |
|---|---|---|
| `id` | bigint PK | |
| `id_vacancy` | bigint FK → mst_vacancies.id | |
| `nama` | varchar | Nama aspek (sama dengan `ref_psikogram.aspek`) |
| `nilai` | int | Nilai ideal untuk posisi ini |

Override `nilai_ideal` default di `ref_psikogram` berdasarkan posisi yang dilamar.

---

## Alur & Scoring Tiap Test

### Test 1 — PAPIKOSTIK (90 Soal, 45 Menit)

**Soal**: 90 pasang pernyataan (option1 vs option2), 9 halaman × 10 soal.  
**Pengerjaan**: Pilih 1 dari 2 pernyataan yang paling sesuai.

**Scoring (RptestController::getTest1Data)**:
1. Ambil semua jawaban user untuk sesi test1
2. Untuk setiap jawaban, cari `mst_test1_codes` WHERE `mst_test1_id = originalId AND option = raw_option`
3. Kode yang cocok → increment score kode tersebut
4. 20 kode PAPI: N, G, A, L, P, I, T, V, S, B, O, X, C, D, R, Z, E, K, F, W
5. Cari interpretasi di `ref_test1_uraian` berdasarkan `code` dan rentang `min-max`
6. Untuk radar chart: K dan Z menggunakan nilai negatif
   Urutan radar: N, W, F, K (neg), E, Z (neg), R, D, C, X, O, B, S, V, T, I, P, L, A, G

**Output**: Array `codes[code]` (jumlah + deskripsi) dan `radar_values[code]`.

### Test 2 — DISC (60 Menit)

**Soal**: Halaman per halaman, masing-masing 4 pernyataan (D/I/S/C/*).  
**Pengerjaan**: Pilih 1 Most (Paling Sesuai) + 1 Least (Tidak Sesuai). Tidak boleh sama.

**Scoring (RptestController::getTest2Data)**:

**Step 1 — Raw Count**:
- `rawMost[D/I/S/C/*]` = jumlah pilihan Most untuk tiap type
- `rawLeast[D/I/S/C/*]` = jumlah pilihan Least untuk tiap type
- `rawChange[D/I/S/C] = rawMost - rawLeast`

**Step 2 — Grafik Mapping** (`ref_disc_grafik`):
- `chartMost[D] = grafik('M', rawMost[D], 'D')` → cari di `ref_disc_grafik` WHERE `tipe='M' AND nomor=rawMost[D]`, ambil `val_d`
- Sama untuk Least (tipe 'L') dan Change (tipe 'C')
- Variable: `$D1, $I1, $S1, $C1` (Most grafik), `$D2, $I2, $S2, $C2` (Least), `$D3, $I3, $S3, $C3` (Change)

**Step 3 — Evaluasi Rumus Karakter** (`ref_disc_rumus_def`):
- Iterasi semua rumus, substitusi variable dengan nilai grafik
- `eval("return $evalExpr;")` — jika true, karakter cocok
- **Last match wins**: rumus terakhir yang cocok diambil
- line1 → Mask Public Self (Most)
- line2 → Core Private Self (Least)
- line3 → Mirror Perceived Self (Change)

**Step 4 — Deskripsi**:
- `kepribadian = matched.line3.desc4`
- `job_match = matched.line3.desc3`
- Tampilkan di 3 kartu karakter + narasi

### Test 3 — Ishihara / Buta Warna (24 Soal, 90 Menit)

**Soal**: Gambar lingkaran Ishihara, ketik angka yang terlihat atau centang "Tidak Tahu".  
**Pengerjaan**: Input angka 1-3 digit atau centang "Tidak Tahu" (= TIDAK_ADA).

**Scoring (RptestController::getTest3Data)**:

Strict logika dari aplikasi lama (msjhr Report_test3):

```
Soal 1: gate question
  - Jika salah → BUTA WARNA TOTAL (berhenti)
  - Jika benar → lanjut

Soal 2-21: hitungBenar/hitungSalah (20 soal)
Soal 22-24: hitungBenar2/hitungSalah2 (3 soal)

Penentuan hasil:
  IF hitungSalah > 3 → PARSIAL
    OR (hitungBenar <= 12 AND hitungBenar2 == 1) → PARSIAL
    OR hitungBenar2 == 2 → PARSIAL
  ELSE IF hitungBenar >= 14 → NORMAL
    OR hitungBenar2 >= 3 → NORMAL
  ELSE → TIDAK VALID
```

### Test 4 — WPT / Wonderlic (40 Soal, 60 Menit)

**Soal**: 3 tipe — A (gambar + PG), B (teks + PG), C (teks + input).  
**Pengerjaan**: Pilih jawaban atau ketik jawaban.

**Scoring (RptestController::getTest4Data)**:
- `total_benar`: jumlah jawaban cocok dengan `answer_key` (case-insensitive)
- `total_salah`: jumlah jawaban tidak cocok
- `total_tidak_jawab`: jawaban null/empty

**Kategori berdasarkan jumlah benar**:

| Benar | Kategori | Kesimpulan |
|---|---|---|
| < 10 | SR | Sangat Rendah |
| 10–14 | R | Rendah |
| 15–19 | C- | Dibawah Rata-rata |
| 20–26 | C | Rata-rata |
| 27–29 | C+ | Diatas Rata-rata |
| 30–35 | T | Tinggi |
| 36–39 | ST | Sangat Tinggi |
| ≥ 40 | ST | Jenius |

---

## Psikogram — Integrasi Semua Test

**Fungsi**: `RptestController::getPsikogramData()`  
Menggabungkan hasil Test1 (PAPI) + Test4 (WPT) + Ideal Score per posisi.

**Alur**:
1. Ekstrak 20 kode PAPI dari `test1Data['codes'][kode]['jumlah']` → variable `$A`–`$Z`
2. Ambil kategori WPT dari `test4Data['kategori']` → rank 1–7
3. Load `ref_psikogram` (14 aspek unik) + `vacancies_ideal_score` untuk posisi terkait
4. Untuk setiap aspek:

   **Jika tanpa rumus** (baris Kecerdasan Umum):
   - Aktual = rank WPT
   - Centang kategori sesuai rank

   **Jika dengan rumus PAPI**:
   - Evaluasi formula sr/r/c_minus/c/c_plus/t/st dengan substitusi variable `$A`–`$Z`
   - `eval("return $evalExpr;")` → jika true, kategori tersebut aktif
   - Aktual = rank kategori yang aktif

5. **Hitung Nilai**:
   ```
   nilai = (aktual / ideal) × persen / pembagian_aspek
   ```
   - `aktual`: rank kategori (1–7)
   - `ideal`: dari `vacancies_ideal_score` atau fallback `ref_psikogram.nilai_ideal`
   - `persen`: bobot aspek
   - `pembagian_aspek`: jumlah aspek dalam grup

6. **Job Fit**:
   ```
   total_nilai = SUM(nilai seluruh aspek)
   max_possible = SUM(persen / pembagian_aspek seluruh aspek)
   job_fit_persen = (total_nilai / max_possible) × 100%
   ```

**Contoh perhitungan (Gaya Kerja)**:
- PAPI: C=5, D=6, R=4
- Rumus: `round(($C + $D + $R) / 3)` = `round(15/3)` = 5
- Kategori: C (round(...) == 5) — tercentang
- Aktual = 4 (rank C)
- Ideal = 4 (dari ref_psikogram, atau override dari vacancies_ideal_score)
- Bobot = 0.30 (persen), pembagian = 5
- Nilai = (4/4) × 0.30 / 5 = 0.06

---

## Timer & Keamanan Test

### Client-Side Timer (test-scripts.blade.php)

- Server mengirim: `serverTimeMs` (now server) + `deadline_at` (ISO string)
- Timer menghitung: `initialDurationMs = deadlineMs - serverTimeMs`
- Setiap tick: `remainingMs = initialDurationMs - (performance.now() - pageLoadTime)`
- **`performance.now()` bersifat monotonik** — tidak terpengaruh perubahan jam Windows client
- Saat waktu habis → auto-submit via AJAX `finish-form`

### Server-Side Validation

**Setiap saveAnswer**:
```php
if (now()->greaterThan($session->deadline_at)) {
    $this->autoFinishSession($session);
    return response()->json(['success' => false, 'message' => 'Waktu habis!'], 403);
}
```

**Setiap render halaman**:
```php
if ($testSession->remaining_seconds === null && now()->greaterThan($testSession->deadline_at)) {
    $this->autoFinishSession($testSession);
    return redirect()->with('message', 'Waktu ujian telah habis.');
}
```

### Pause/Resume

Jika user navigasi keluar, `sendPauseBeacon()` menyimpan `remaining_seconds` ke database dan men-null-kan `deadline_at`.  
Saat kembali, `remaining_seconds` di-restore: `deadline_at = now() + remaining_seconds`.

---

## Report View (RptestController)

**Route**: `/rptest` → menampilkan filter pencarian (nama/NIK/posisi)  
**Setelah submit**: tampilkan `result.blade.php` dengan 5 tab:

| Tab | Partial | Sumber Data |
|---|---|---|
| Report Test 1 | `partials.test1` | `$test1Data` (codes + radar_values) |
| Report Test 2 | `partials.test2` | `$test2Data` (table + grafik + karakter) |
| Report Test 3 | `partials.test3` | `$test3Data` (total_benar/salah + details) |
| Report Test 4 | `partials.test4` | `$test4Data` (score + kategori + details) |
| Report Psikogram | `partials.psikogram` | `$psikogramData` (aspek_items + job_fit) |

Tiap tab memiliki **screen view** (chart ECharts + tabel interaktif) dan **print view** (format tabel formal untuk dicetak).

---

## Rumus Eval — Teknik Kritis

Seluruh sistem scoring menggunakan **PHP `eval()`** untuk mengevaluasi rumus:

```php
$evalExpr = str_replace(
    ['$D1', '$I1', '$S1', '$C1', /* ... */],
    [$D1, $I1, $S1, $C1, /* ... */],
    $rumus
);
if (eval("return $evalExpr;")) {
    // rumus cocok
}
```

Ini terjadi di 3 tempat:
1. **DISC Karakter** (`RptestController::getTest2Data`) — 12 variable grafik
2. **Psikogram per Aspek** (`RptestController::getPsikogramData`) — 20 variable PAPI
3. (Potensial) Ekspansi masa depan

**Catatan Keamanan**: Nilai variable adalah integer murni dari database, bukan input user. Tapi `eval()` tetaplah operasi sensitif yang dibungkus dalam `try-catch`.
