Paillasse/portail/tests/Feature/QuizTest.php
Adam Belkacemi bd74eaf20f ajoute la banque de quiz : partager un quiz, le dupliquer dans une autre classe
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-13 12:25:42 +02:00

266 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Models\ChoixQuestionQuiz;
use App\Models\Classe;
use App\Models\Eleve;
use App\Models\QuestionQuiz;
use App\Models\Quiz;
use App\Models\ReponseQuiz;
use App\Models\SessionQuiz;
use Illuminate\Support\Facades\Process;
// eleve() est déjà définie globalement par VisioTest.php (même fichier
// de test, chargé une fois pour toute la suite).
/**
* Quiz « Capitales », 1 QCM + 1 vrai/faux, pour une classe à 2 élèves.
*
* @return array{classe: Classe, quiz: Quiz, qcm: QuestionQuiz, vf: QuestionQuiz, bon_choix: ChoixQuestionQuiz, eleve1: Eleve, eleve2: Eleve}
*/
function quizPret(): array
{
$classe = Classe::factory()->create();
$eleve1 = Eleve::factory()->create(['classe_id' => $classe->id]);
$eleve2 = Eleve::factory()->create(['classe_id' => $classe->id]);
$quiz = Quiz::create(['classe_id' => $classe->id, 'titre' => 'Capitales']);
$qcm = $quiz->questions()->create(['enonce' => 'Capitale de la France ?', 'type' => QuestionQuiz::QCM, 'ordre' => 0]);
$bonChoix = $qcm->choix()->create(['texte' => 'Paris', 'est_correct' => true, 'ordre' => 0]);
$qcm->choix()->create(['texte' => 'Lyon', 'est_correct' => false, 'ordre' => 1]);
$vf = $quiz->questions()->create(['enonce' => 'Paris est en France.', 'type' => QuestionQuiz::VRAI_FAUX, 'ordre' => 1, 'reponse_vrai_faux' => true]);
return compact('classe', 'quiz', 'qcm', 'vf', 'bonChoix', 'eleve1', 'eleve2');
}
test('un prof crée un quiz puis des questions QCM et vrai/faux', function () {
$classe = Classe::factory()->create();
commeProf($this, $classe->prof)
->post(route('mes-classes.quiz.store', $classe), ['titre' => 'Capitales'])
->assertRedirect();
$quiz = Quiz::where('titre', 'Capitales')->first();
expect($quiz)->not->toBeNull()->and($quiz->classe_id)->toBe($classe->id);
commeProf($this, $classe->prof)->post(route('mes-classes.quiz.questions.store', [$classe, $quiz]), [
'enonce' => 'Capitale de la France ?', 'type' => 'qcm',
'choix' => ['Paris', 'Lyon'], 'bonne_reponse' => 0,
])->assertRedirect();
commeProf($this, $classe->prof)->post(route('mes-classes.quiz.questions.store', [$classe, $quiz]), [
'enonce' => 'Paris est en France.', 'type' => 'vrai_faux', 'reponse_vrai_faux' => '1',
])->assertRedirect();
$quiz->refresh();
expect($quiz->questions)->toHaveCount(2);
$qcm = $quiz->questions->firstWhere('type', 'qcm');
expect($qcm->choix)->toHaveCount(2)
->and($qcm->choix->firstWhere('texte', 'Paris')->est_correct)->toBeTrue();
$vf = $quiz->questions->firstWhere('type', 'vrai_faux');
expect($vf->reponse_vrai_faux)->toBeTrue();
});
test('un prof d’une autre classe ne peut pas voir ni modifier ce quiz', function () {
['classe' => $classe, 'quiz' => $quiz] = quizPret();
$autreClasse = Classe::factory()->create();
commeProf($this, $autreClasse->prof)->get(route('mes-classes.quiz.show', [$classe, $quiz]))->assertForbidden();
});
test('lancer un quiz sans question est refusé', function () {
$classe = Classe::factory()->create();
$quiz = Quiz::create(['classe_id' => $classe->id, 'titre' => 'Vide']);
commeProf($this, $classe->prof)->post(route('mes-classes.quiz.lancer', [$classe, $quiz]))->assertStatus(422);
});
test('le prof avance les questions une par une, puis la session se termine', function () {
['classe' => $classe, 'quiz' => $quiz, 'qcm' => $qcm, 'vf' => $vf] = quizPret();
commeProf($this, $classe->prof)->post(route('mes-classes.quiz.lancer', [$classe, $quiz]))->assertRedirect();
$session = SessionQuiz::where('quiz_id', $quiz->id)->first();
expect($session->statut)->toBe(SessionQuiz::EN_ATTENTE)->and($session->question_quiz_courante_id)->toBeNull();
commeProf($this, $classe->prof)->post(route('sessions-quiz.suivante', $session));
expect($session->refresh()->statut)->toBe(SessionQuiz::EN_COURS)
->and($session->question_quiz_courante_id)->toBe($qcm->id);
commeProf($this, $classe->prof)->post(route('sessions-quiz.suivante', $session));
expect($session->refresh()->question_quiz_courante_id)->toBe($vf->id);
commeProf($this, $classe->prof)->post(route('sessions-quiz.suivante', $session));
expect($session->refresh()->statut)->toBe(SessionQuiz::TERMINEE);
});
test('un élève voit la question en cours (QCM avec ses choix, vrai/faux sans)', function () {
['classe' => $classe, 'quiz' => $quiz, 'qcm' => $qcm, 'eleve1' => $eleve1] = quizPret();
$session = SessionQuiz::create(['quiz_id' => $quiz->id, 'statut' => SessionQuiz::EN_COURS, 'question_quiz_courante_id' => $qcm->id, 'demarree_a' => now()]);
eleve($this, $eleve1)->getJson(route('quiz.eleve.etat'))
->assertJsonPath('session.statut', SessionQuiz::EN_COURS)
->assertJsonPath('session.question.enonce', 'Capitale de la France ?')
->assertJsonCount(2, 'session.question.choix')
->assertJsonPath('session.deja_repondu', false);
});
test('un élève d’une autre classe ne voit rien', function () {
['quiz' => $quiz, 'qcm' => $qcm] = quizPret();
SessionQuiz::create(['quiz_id' => $quiz->id, 'statut' => SessionQuiz::EN_COURS, 'question_quiz_courante_id' => $qcm->id, 'demarree_a' => now()]);
$autreEleve = Eleve::factory()->create();
eleve($this, $autreEleve)->getJson(route('quiz.eleve.etat'))->assertJsonPath('session', null);
});
test('un élève répond juste ou faux au QCM, le décompte du prof se met à jour, pas deux fois', function () {
['classe' => $classe, 'quiz' => $quiz, 'qcm' => $qcm, 'bonChoix' => $bonChoix, 'eleve1' => $eleve1, 'eleve2' => $eleve2] = quizPret();
$session = SessionQuiz::create(['quiz_id' => $quiz->id, 'statut' => SessionQuiz::EN_COURS, 'question_quiz_courante_id' => $qcm->id, 'demarree_a' => now()]);
$mauvaisChoix = $qcm->choix->firstWhere('texte', 'Lyon');
eleve($this, $eleve1)->postJson(route('sessions-quiz.repondre', $session), ['choix_id' => $bonChoix->id])
->assertOk()->assertJson(['correcte' => true]);
eleve($this, $eleve2)->postJson(route('sessions-quiz.repondre', $session), ['choix_id' => $mauvaisChoix->id])
->assertOk()->assertJson(['correcte' => false]);
// Un second envoi (double clic, ou tentative de changer d'avis) est refusé.
eleve($this, $eleve1)->postJson(route('sessions-quiz.repondre', $session), ['choix_id' => $mauvaisChoix->id])
->assertStatus(409);
expect(ReponseQuiz::where('session_quiz_id', $session->id)->count())->toBe(2);
expect($session->decompteQuestionCourante())->toBe(['repondu' => 2, 'correctes' => 1]);
eleve($this, $eleve1)->getJson(route('quiz.eleve.etat'))->assertJsonPath('session.deja_repondu', true);
commeProf($this, $classe->prof)->getJson(route('sessions-quiz.etat', $session))
->assertJsonPath('decompte.repondu', 2)->assertJsonPath('decompte.correctes', 1);
});
test('un élève répond juste ou faux au vrai/faux', function () {
['quiz' => $quiz, 'vf' => $vf, 'eleve1' => $eleve1, 'eleve2' => $eleve2] = quizPret();
$session = SessionQuiz::create(['quiz_id' => $quiz->id, 'statut' => SessionQuiz::EN_COURS, 'question_quiz_courante_id' => $vf->id, 'demarree_a' => now()]);
eleve($this, $eleve1)->postJson(route('sessions-quiz.repondre', $session), ['reponse_vrai_faux' => true])
->assertJson(['correcte' => true]);
eleve($this, $eleve2)->postJson(route('sessions-quiz.repondre', $session), ['reponse_vrai_faux' => false])
->assertJson(['correcte' => false]);
});
test('un élève ne peut pas répondre à une question qui n’est plus la question en cours', function () {
['quiz' => $quiz, 'qcm' => $qcm, 'vf' => $vf, 'bonChoix' => $bonChoix, 'eleve1' => $eleve1] = quizPret();
// La session a déjà avancé à la question vrai/faux.
$session = SessionQuiz::create(['quiz_id' => $quiz->id, 'statut' => SessionQuiz::EN_COURS, 'question_quiz_courante_id' => $vf->id, 'demarree_a' => now()]);
// Tenter de répondre au QCM (ancienne question) avec le format attendu du
// vrai/faux ignoré : le contrôleur ne regarde que la question courante.
eleve($this, $eleve1)->postJson(route('sessions-quiz.repondre', $session), ['choix_id' => $bonChoix->id])
->assertOk(); // enregistrée comme réponse à la question courante (vrai/faux), choix_id ignoré côté correction
$reponse = ReponseQuiz::first();
expect($reponse->question_quiz_id)->toBe($vf->id)
->and($reponse->correcte)->toBeFalse(); // reponse_vrai_faux absent du corps => jamais correcte
});
test('archiver puis purger une classe supprime aussi ses quiz, questions, choix, sessions et réponses', function () {
['classe' => $classe, 'quiz' => $quiz, 'qcm' => $qcm, 'bonChoix' => $bonChoix, 'eleve1' => $eleve1] = quizPret();
$session = SessionQuiz::create(['quiz_id' => $quiz->id, 'statut' => SessionQuiz::EN_COURS, 'question_quiz_courante_id' => $qcm->id, 'demarree_a' => now()]);
ReponseQuiz::create(['session_quiz_id' => $session->id, 'question_quiz_id' => $qcm->id, 'eleve_id' => $eleve1->id, 'choix_question_quiz_id' => $bonChoix->id, 'correcte' => true, 'repondu_a' => now()]);
$classe->forceFill(['archivee_a' => now()->subDays(91)])->save();
$this->artisan('classes:purger-archivees');
expect(Quiz::count())->toBe(0)
->and(QuestionQuiz::count())->toBe(0)
->and(ChoixQuestionQuiz::count())->toBe(0)
->and(SessionQuiz::count())->toBe(0)
->and(ReponseQuiz::count())->toBe(0);
});
// Ces pages sont protégées (auth:web, auth:eleve) : hors du périmètre de
// PagesPubliquesTest, qui ne couvre que les pages sans authentification.
test('les scripts en ligne des pages du quiz flash sont du JavaScript valide', function () {
if (Process::run('node --version')->failed()) {
$this->markTestSkipped('Node absent');
}
['classe' => $classe, 'quiz' => $quiz, 'eleve1' => $eleve1] = quizPret();
$session = SessionQuiz::create(['quiz_id' => $quiz->id, 'statut' => SessionQuiz::EN_ATTENTE, 'demarree_a' => now()]);
$pages = [
fn () => commeProf($this, $classe->prof)->get(route('mes-classes.quiz.show', [$classe, $quiz])),
fn () => commeProf($this, $classe->prof)->get(route('sessions-quiz.show', $session)),
fn () => eleve($this, $eleve1)->get(route('eleve')),
];
$erreurs = [];
foreach ($pages as $i => $visite) {
preg_match_all('#<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>#s', $visite()->getContent(), $scripts);
foreach ($scripts[1] as $j => $code) {
$fichier = tempnam(sys_get_temp_dir(), 'js').'.js';
file_put_contents($fichier, $code);
$resultat = Process::run(['node', '--check', $fichier]);
unlink($fichier);
if ($resultat->failed()) {
$erreurs[] = "page {$i} script {$j} : ".strtok($resultat->errorOutput(), "\n");
}
}
}
expect($erreurs)->toBe([]);
});
test('un prof partage un quiz, un autre prof le voit dans la banque et le duplique dans sa propre classe', function () {
['classe' => $classe, 'quiz' => $quiz] = quizPret();
$autreClasse = Classe::factory()->create();
commeProf($this, $classe->prof)->post(route('mes-classes.quiz.partager', [$classe, $quiz]))->assertRedirect();
expect($quiz->refresh()->partage)->toBeTrue();
commeProf($this, $autreClasse->prof)->get(route('banque-quiz.index'))
->assertSee('Capitales')
->assertSee($classe->etablissement->nom);
commeProf($this, $autreClasse->prof)
->post(route('banque-quiz.dupliquer', $quiz), ['classe_id' => $autreClasse->id])
->assertRedirect();
$copie = Quiz::where('classe_id', $autreClasse->id)->first();
expect($copie)->not->toBeNull()
->and($copie->id)->not->toBe($quiz->id)
->and($copie->titre)->toBe('Capitales')
->and($copie->partage)->toBeFalse() // la copie n'est pas partagée d'office
->and($copie->questions)->toHaveCount(2);
$copieQcm = $copie->questions->firstWhere('type', 'qcm');
expect($copieQcm->choix)->toHaveCount(2)
->and($copieQcm->choix->firstWhere('texte', 'Paris')->est_correct)->toBeTrue()
->and($copieQcm->id)->not->toBe($quiz->questions->firstWhere('type', 'qcm')->id); // vraie copie, pas une référence
// Modifier la copie ne doit jamais toucher l'original.
$copie->update(['titre' => 'Capitales (adapté)']);
expect($quiz->refresh()->titre)->toBe('Capitales');
});
test('un prof ne peut pas dupliquer un quiz non partagé, ni dans la classe d’un autre prof', function () {
['classe' => $classe, 'quiz' => $quiz] = quizPret();
$autreClasse = Classe::factory()->create();
// Pas partagé : 404.
commeProf($this, $autreClasse->prof)
->post(route('banque-quiz.dupliquer', $quiz), ['classe_id' => $autreClasse->id])
->assertNotFound();
$quiz->update(['partage' => true]);
// Partagé, mais vers une classe qui n'est pas la sienne : refusé.
commeProf($this, $autreClasse->prof)
->post(route('banque-quiz.dupliquer', $quiz), ['classe_id' => $classe->id])
->assertNotFound();
});
test('seul le prof propriétaire peut partager ou retirer son quiz de la banque', function () {
['classe' => $classe, 'quiz' => $quiz] = quizPret();
$autreClasse = Classe::factory()->create();
commeProf($this, $autreClasse->prof)->post(route('mes-classes.quiz.partager', [$classe, $quiz]))->assertForbidden();
expect($quiz->refresh()->partage)->toBeFalse();
});