Paillasse/portail/tests/Feature/QuizTest.php

267 lines
13 KiB
PHP
Raw Permalink Normal View History

<?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();
});