feat(quiz): strict focus-loss anti-cheat + audit attempt
Kebijakan strict: kehilangan fokus saat kuis aktif langsung mengakhiri kuis
dengan penalti, satu attempt per kuis tercatat audit, pembahasan disembunyikan
saat focus_lost, badge pelanggaran muncul di laporan guru.
Backend:
- tabel quiz_attempts (migration 0002) + endpoint POST /quiz-attempts/submit
idempotent dengan unique constraint (satu attempt per token+lesson, 409
untuk duplikat), bounded termination_reason
- models/repositories/storage: QuizAttempt + record/query pelanggaran untuk
laporan guru (progress)
Frontend:
- quiz-integrity.ts: policy hub (isFocusLossEvent, terminateQuiz,
shouldShowQuizReview) + attempt_id via crypto.randomUUID()
- lesson.svelte.ts: finalisasi idempotent; sendBeacon untuk exit path yang
bisa suspend (focus_lost/page_unload), fetch keepalive untuk reason lain
- QuizTab.svelte: summary menyembunyikan pembahasan saat focus_lost
- progress/+page.svelte: badge pelanggaran focus_lost di laporan guru
- APP_VERSION 26 -> 27
Tests & docs:
- 204 backend test pass (unit host + integrasi container, DB elemes_test)
- 107 frontend test pass, svelte-check 0 error
- docs/12-quiz-integrity.md (baru), docs/07-quiz-authoring.md + README FAQ
anti-cheat diperbarui
2026-08-11 20:55:39 +07:00
|
|
|
/**
|
|
|
|
|
* 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?
|
|
|
|
|
*
|
2026-08-16 19:17:42 +07:00
|
|
|
* Strict policy: kuis yang dihentikan karena `focus_lost` atau `page_unload`
|
|
|
|
|
* TIDAK menampilkan pembahasan — siswa yang kehilangan fokus / me-refresh
|
|
|
|
|
* halaman tidak mendapat bocoran jawaban. Alasan lain (`user_exit`,
|
|
|
|
|
* `spa_navigation`, `completed`) tetap menampilkan pembahasan; `null`
|
|
|
|
|
* (belum ada termination) → tampil.
|
feat(quiz): strict focus-loss anti-cheat + audit attempt
Kebijakan strict: kehilangan fokus saat kuis aktif langsung mengakhiri kuis
dengan penalti, satu attempt per kuis tercatat audit, pembahasan disembunyikan
saat focus_lost, badge pelanggaran muncul di laporan guru.
Backend:
- tabel quiz_attempts (migration 0002) + endpoint POST /quiz-attempts/submit
idempotent dengan unique constraint (satu attempt per token+lesson, 409
untuk duplikat), bounded termination_reason
- models/repositories/storage: QuizAttempt + record/query pelanggaran untuk
laporan guru (progress)
Frontend:
- quiz-integrity.ts: policy hub (isFocusLossEvent, terminateQuiz,
shouldShowQuizReview) + attempt_id via crypto.randomUUID()
- lesson.svelte.ts: finalisasi idempotent; sendBeacon untuk exit path yang
bisa suspend (focus_lost/page_unload), fetch keepalive untuk reason lain
- QuizTab.svelte: summary menyembunyikan pembahasan saat focus_lost
- progress/+page.svelte: badge pelanggaran focus_lost di laporan guru
- APP_VERSION 26 -> 27
Tests & docs:
- 204 backend test pass (unit host + integrasi container, DB elemes_test)
- 107 frontend test pass, svelte-check 0 error
- docs/12-quiz-integrity.md (baru), docs/07-quiz-authoring.md + README FAQ
anti-cheat diperbarui
2026-08-11 20:55:39 +07:00
|
|
|
*/
|
|
|
|
|
export function shouldShowQuizReview(reason: QuizTerminationReason | null): boolean {
|
2026-08-16 19:17:42 +07:00
|
|
|
return reason !== 'focus_lost' && reason !== 'page_unload';
|
feat(quiz): strict focus-loss anti-cheat + audit attempt
Kebijakan strict: kehilangan fokus saat kuis aktif langsung mengakhiri kuis
dengan penalti, satu attempt per kuis tercatat audit, pembahasan disembunyikan
saat focus_lost, badge pelanggaran muncul di laporan guru.
Backend:
- tabel quiz_attempts (migration 0002) + endpoint POST /quiz-attempts/submit
idempotent dengan unique constraint (satu attempt per token+lesson, 409
untuk duplikat), bounded termination_reason
- models/repositories/storage: QuizAttempt + record/query pelanggaran untuk
laporan guru (progress)
Frontend:
- quiz-integrity.ts: policy hub (isFocusLossEvent, terminateQuiz,
shouldShowQuizReview) + attempt_id via crypto.randomUUID()
- lesson.svelte.ts: finalisasi idempotent; sendBeacon untuk exit path yang
bisa suspend (focus_lost/page_unload), fetch keepalive untuk reason lain
- QuizTab.svelte: summary menyembunyikan pembahasan saat focus_lost
- progress/+page.svelte: badge pelanggaran focus_lost di laporan guru
- APP_VERSION 26 -> 27
Tests & docs:
- 204 backend test pass (unit host + integrasi container, DB elemes_test)
- 107 frontend test pass, svelte-check 0 error
- docs/12-quiz-integrity.md (baru), docs/07-quiz-authoring.md + README FAQ
anti-cheat diperbarui
2026-08-11 20:55:39 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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()}`;
|
|
|
|
|
}
|