79 lines
2.7 KiB
TypeScript
79 lines
2.7 KiB
TypeScript
|
|
/**
|
||
|
|
* Kebijakan strict focus-loss untuk kuis (anti-cheat).
|
||
|
|
*
|
||
|
|
* Helper MURNI tanpa DOM/global browser agar keputusan event dapat diuji
|
||
|
|
* di Vitest (environment node). Semua keputusan "apakah event ini mengakhiri
|
||
|
|
* kuis" dipusatkan di sini — jangan menulis ulang logika di komponen.
|
||
|
|
*
|
||
|
|
* Policy strict: kehilangan fokus pertama saat kuis aktif langsung mengakhiri
|
||
|
|
* kuis. Kembalinya fokus TIDAK pernah membatalkan termination.
|
||
|
|
*/
|
||
|
|
|
||
|
|
export type QuizTerminationReason =
|
||
|
|
| 'focus_lost'
|
||
|
|
| 'spa_navigation'
|
||
|
|
| 'page_unload'
|
||
|
|
| 'user_exit'
|
||
|
|
| 'completed';
|
||
|
|
|
||
|
|
/** Nilai reason yang diizinkan — dipakai juga oleh kontrak backend. */
|
||
|
|
export const QUIZ_TERMINATION_REASONS: readonly QuizTerminationReason[] = [
|
||
|
|
'focus_lost',
|
||
|
|
'spa_navigation',
|
||
|
|
'page_unload',
|
||
|
|
'user_exit',
|
||
|
|
'completed'
|
||
|
|
];
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apakah sebuah event browser dianggap kehilangan fokus (strict mode)?
|
||
|
|
*
|
||
|
|
* - `visibilitychange` dengan state `hidden` → ya (switch tab, minimize,
|
||
|
|
* dan sebagian besar switch-app mobile).
|
||
|
|
* - `blur` → ya (fallback desktop saat window kehilangan fokus tetapi
|
||
|
|
* visibility browser tidak berubah).
|
||
|
|
* - Event apa pun setelah kuis terminated → tidak pernah (idempotent).
|
||
|
|
* - `visibilitychange` kembali `visible` → tidak pernah.
|
||
|
|
*/
|
||
|
|
export function isFocusLossEvent(
|
||
|
|
eventType: 'visibilitychange' | 'blur',
|
||
|
|
visibilityState: DocumentVisibilityState,
|
||
|
|
alreadyTerminated: boolean
|
||
|
|
): boolean {
|
||
|
|
if (alreadyTerminated) return false;
|
||
|
|
if (eventType === 'blur') return true;
|
||
|
|
return visibilityState === 'hidden';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apakah pembahasan (review per-soal) boleh tampil di ringkasan setelah kuis
|
||
|
|
* berakhir?
|
||
|
|
*
|
||
|
|
* Strict policy: kuis yang dihentikan karena `focus_lost` TIDAK menampilkan
|
||
|
|
* pembahasan — siswa yang kehilangan fokus tidak mendapat bocoran jawaban.
|
||
|
|
* Alasan lain (`user_exit`, `spa_navigation`, `page_unload`, `completed`)
|
||
|
|
* tetap menampilkan pembahasan; `null` (belum ada termination) → tampil.
|
||
|
|
*/
|
||
|
|
export function shouldShowQuizReview(reason: QuizTerminationReason | null): boolean {
|
||
|
|
return reason !== 'focus_lost';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* UUID acak untuk `attempt_id` — korelasi/idempotency, BUKAN secret.
|
||
|
|
*
|
||
|
|
* Memakai `crypto.randomUUID()` bila tersedia; fallback menghasilkan string
|
||
|
|
* berformat UUID berbasis waktu + random (valid untuk correlation &
|
||
|
|
* idempotency, tidak digunakan sebagai credential).
|
||
|
|
*/
|
||
|
|
export function createAttemptId(): string {
|
||
|
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||
|
|
return crypto.randomUUID();
|
||
|
|
}
|
||
|
|
const rand = () =>
|
||
|
|
Math.floor(Math.random() * 0x10000)
|
||
|
|
.toString(16)
|
||
|
|
.padStart(4, '0');
|
||
|
|
const ts = Date.now().toString(16).padStart(12, '0');
|
||
|
|
return `${ts.slice(0, 8)}-${ts.slice(8)}-${rand()}${rand()}-${rand()}${rand()}-${rand()}${rand()}${rand()}${rand()}`;
|
||
|
|
}
|