ajoute Quiz flash (chantier vrai cockpit, étape 2) : QCM et vrai/faux, lancé en direct, indépendant d'une séance de lab

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-13 12:14:30 +02:00
parent 45f8a69e26
commit 16d1293911
24 changed files with 1249 additions and 5 deletions

View file

@ -887,3 +887,29 @@ Le choix qui a le plus simplifié ce chantier n'était pas technique mais de mé
**Suite**
Dette retirée. Reste dans `docs/dette.md` : le test de bascule TURN en navigateur réel, et l'image Jitsi/Kuma à faire évoluer vers un tag versionné. Chantier « vrai cockpit » : Quiz flash, toujours en attente.
## 2026-10-13 — Chantier « vrai cockpit », étape 2 : Quiz flash
Deuxième fonction réelle du chantier (après le tableau de bord). Deux décisions d'Adam avant de coder : formats QCM **et** Vrai/Faux dès la version 1 ; le quiz se lance **indépendamment** d'une séance de lab (pas besoin de code à rejoindre : l'élève est déjà un compte réel de sa classe, le quiz apparaît sur son tableau de bord).
**Fait**
- 5 tables (`quizzes`, `questions_quiz`, `choix_questions_quiz`, `sessions_quiz`, `reponses_quiz`) et leurs modèles. Une banque de questions par classe (`Quiz`), réutilisable ; un lancement en direct (`SessionQuiz`) avance question par question, se termine seule après la dernière.
- Prof : `/mes-classes/{classe}/quiz` (créer un quiz, ajouter des questions une par une — même esprit que « ajouter un élève »), panneau de contrôle en direct (`/sessions-quiz/{session}`) avec décompte des réponses en temps réel (qui a répondu, combien de bonnes réponses), bouton « Suivante »/« Terminer ».
- Élève : rien à rejoindre. Une zone sur `/eleve`, cachée par défaut, interrogée toutes les 2 s (`/quiz-flash/etat`) ; apparaît dès qu'un quiz est lancé pour sa classe, avec la question et un formulaire. Une réponse par question (contrainte unique en base, vérifiée aussi côté application) : pas de changement d'avis, pas de double clic compté deux fois.
- Menu : les liens « Quiz flash » de la vitrine (mégamenu et pied de page) pointent désormais vers `/mes-classes` pour un prof connecté, au lieu de la démo — même correctif que pour le tableau de bord.
- 16 tests Pest (72 au total), dont un qui rend les vraies pages et vérifie leurs scripts avec Node (même piège que `/ide` à l'étape 14, évité ici dès la construction) et un qui vérifie que la purge RGPD d'une classe supprime bien ses quiz, questions, choix, sessions et réponses.
**Un vrai bug de migration, trouvé par les tests avant le serveur réel**
`constrained()` devine le nom de la table à partir du nom de la colonne, en la mettant au pluriel anglais standard. Nos tables ont des noms différents de cette convention (`questions_quiz`, pas `question_quizzes` ; `sessions_quiz`, pas `session_quizzes` ; `choix_questions_quiz`, pas `choix_question_quizzes`) : trois clés étrangères pointaient vers des tables qui n'existaient pas. Corrigé en précisant le nom de chaque table explicitement (`constrained('questions_quiz')`, etc.) — seul `quiz_id` → `quizzes` correspondait déjà à la convention (vérifié avec `Str::plural('quiz')`, pas supposé).
**Critère vérifié sur le serveur réel** (scénario à 2 élèves, nettoyé ensuite) : un prof a créé un quiz (1 QCM, 1 vrai/faux), l'a lancé ; un élève a répondu juste (Paris), l'autre faux (Lyon) ; le panneau du prof a montré en direct « 2 ont répondu, 1 correcte » ; une deuxième réponse du même élève a été refusée (409, « Déjà répondu ») ; passage à la question suivante, l'élève l'a vue sans son ancien « déjà répondu » ; fin du quiz, l'élève ne voit plus rien.
**Comprendre**
Le bug de migration est un bon rappel que les conventions de Laravel ne devinent pas, elles supposent — et l'hypothèse casse silencieusement dès qu'on s'éloigne un peu du nom de table standard (ici, un ordre « pluriel-puis-singulier » choisi pour que les noms de table se lisent bien en français). Heureusement, les tests Pest l'ont trouvé avant même d'atteindre le serveur : la bonne nouvelle de ce chantier n'est pas qu'il n'y a pas eu de bug, mais que chaque bug a été trouvé au bon moment — jamais après coup.
**Suite**
Chantier « vrai cockpit », étape 3 : Devoirs maison (prochaine fonction).

View file

@ -9,9 +9,10 @@ use Illuminate\Support\Facades\Log;
/**
* RGPD (CLAUDE.md, étape 14) : supprime les classes archivées depuis
* 90 jours, avec en cascade leurs élèves, séances, sessions de lab, machines
* et corrections. Le compte du prof est conservé. Le journal ne garde que
* des identifiants et des nombres, jamais de nom d'élève.
* 90 jours, avec en cascade leurs élèves, séances, sessions de lab, machines,
* corrections et quiz (chantier « vrai cockpit », étape 2). Le compte du
* prof est conservé. Le journal ne garde que des identifiants et des
* nombres, jamais de nom d'élève.
*
* Les sauvegardes chiffrées contenant encore ces données expirent d'elles-
* mêmes au plus 4 semaines plus tard (rotation, docs/runbooks/sauvegardes.md).

View file

@ -0,0 +1,112 @@
<?php
namespace App\Http\Controllers;
use App\Models\Classe;
use App\Models\QuestionQuiz;
use App\Models\Quiz;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
/**
* Chantier « vrai cockpit », étape 2 : Quiz flash. La banque de questions
* (ce contrôleur) est distincte de son lancement en direct
* (SessionQuizController) : un quiz se prépare à l'avance, se relance
* autant de fois qu'on veut.
*/
class QuizController extends Controller
{
public function index(Classe $classe): View
{
$this->verifierProprietaire($classe);
return view('quiz.index', ['classe' => $classe, 'quizzes' => $classe->quizzes()->withCount('questions')->get()]);
}
public function store(Request $request, Classe $classe): RedirectResponse
{
$this->verifierProprietaire($classe);
$donnees = $request->validate(['titre' => ['required', 'string', 'max:150']]);
$quiz = $classe->quizzes()->create($donnees);
return redirect()->route('mes-classes.quiz.show', [$classe, $quiz]);
}
public function show(Classe $classe, Quiz $quiz): View
{
$this->verifierProprietaire($classe);
$this->verifierAppartenance($classe, $quiz);
return view('quiz.show', ['classe' => $classe, 'quiz' => $quiz->load('questions.choix')]);
}
public function destroy(Classe $classe, Quiz $quiz): RedirectResponse
{
$this->verifierProprietaire($classe);
$this->verifierAppartenance($classe, $quiz);
$quiz->delete();
return redirect()->route('mes-classes.quiz.index', $classe);
}
/**
* Une question à la fois (même esprit que « ajouter un élève ») : plus
* simple à valider et à tester qu'un formulaire unique avec un nombre
* de lignes variable.
*/
public function storeQuestion(Request $request, Classe $classe, Quiz $quiz): RedirectResponse
{
$this->verifierProprietaire($classe);
$this->verifierAppartenance($classe, $quiz);
$donnees = $request->validate([
'enonce' => ['required', 'string', 'max:500'],
'type' => ['required', 'in:'.QuestionQuiz::QCM.','.QuestionQuiz::VRAI_FAUX],
'reponse_vrai_faux' => ['required_if:type,'.QuestionQuiz::VRAI_FAUX, 'boolean'],
'choix' => ['required_if:type,'.QuestionQuiz::QCM, 'array', 'min:2', 'max:5'],
'choix.*' => ['required_if:type,'.QuestionQuiz::QCM, 'string', 'max:200'],
'bonne_reponse' => ['required_if:type,'.QuestionQuiz::QCM, 'integer'],
]);
DB::transaction(function () use ($donnees, $quiz) {
$question = $quiz->questions()->create([
'enonce' => $donnees['enonce'],
'type' => $donnees['type'],
'ordre' => $quiz->questions()->count(),
'reponse_vrai_faux' => $donnees['type'] === QuestionQuiz::VRAI_FAUX ? $donnees['reponse_vrai_faux'] : null,
]);
if ($donnees['type'] === QuestionQuiz::QCM) {
foreach ($donnees['choix'] as $i => $texte) {
$question->choix()->create(['texte' => $texte, 'est_correct' => $i === (int) $donnees['bonne_reponse'], 'ordre' => $i]);
}
}
});
return redirect()->route('mes-classes.quiz.show', [$classe, $quiz]);
}
public function destroyQuestion(Classe $classe, Quiz $quiz, QuestionQuiz $question): RedirectResponse
{
$this->verifierProprietaire($classe);
$this->verifierAppartenance($classe, $quiz);
abort_if($question->quiz_id !== $quiz->id, 404);
$question->delete();
return redirect()->route('mes-classes.quiz.show', [$classe, $quiz]);
}
private function verifierProprietaire(Classe $classe): void
{
abort_if($classe->user_id !== Auth::guard('web')->id(), 403);
}
private function verifierAppartenance(Classe $classe, Quiz $quiz): void
{
abort_if($quiz->classe_id !== $classe->id, 404);
}
}

View file

@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers;
use App\Models\QuestionQuiz;
use App\Models\ReponseQuiz;
use App\Models\SessionQuiz;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* Côté élève du quiz flash (chantier « vrai cockpit », étape 2) : pas de
* code à saisir, pas d'invité — l'élève est déjà un compte réel de la
* classe, le quiz apparaît directement sur son tableau de bord.
*/
class QuizEleveController extends Controller
{
public function etat(): JsonResponse
{
$eleve = Auth::guard('eleve')->user();
$session = SessionQuiz::query()
->whereIn('statut', [SessionQuiz::EN_ATTENTE, SessionQuiz::EN_COURS])
->whereHas('quiz', fn ($q) => $q->where('classe_id', $eleve->classe_id))
->with('questionCourante.choix')
->latest('demarree_a')
->first();
if (! $session) {
return response()->json(['session' => null]);
}
$dejaRepondu = $session->question_quiz_courante_id
? ReponseQuiz::where('session_quiz_id', $session->id)
->where('question_quiz_id', $session->question_quiz_courante_id)
->where('eleve_id', $eleve->id)
->exists()
: false;
$question = $session->questionCourante;
return response()->json([
'session' => [
'id' => $session->id,
'statut' => $session->statut,
'deja_repondu' => $dejaRepondu,
'question' => $question ? [
'id' => $question->id,
'enonce' => $question->enonce,
'type' => $question->type,
'choix' => $question->type === QuestionQuiz::QCM
? $question->choix->map(fn ($c) => ['id' => $c->id, 'texte' => $c->texte])->values()
: [],
] : null,
],
]);
}
public function repondre(Request $request, SessionQuiz $session): JsonResponse
{
$eleve = Auth::guard('eleve')->user();
abort_if($session->quiz->classe_id !== $eleve->classe_id, 403);
abort_if($session->statut !== SessionQuiz::EN_COURS || ! $session->question_quiz_courante_id, 409, 'Aucune question en cours.');
$question = $session->questionCourante;
abort_if(
ReponseQuiz::where('session_quiz_id', $session->id)
->where('question_quiz_id', $question->id)
->where('eleve_id', $eleve->id)
->exists(),
409,
'Déjà répondu.'
);
$donnees = $request->validate([
'choix_id' => ['nullable', 'integer'],
'reponse_vrai_faux' => ['nullable', 'boolean'],
]);
$correcte = $question->estCorrecte($donnees['choix_id'] ?? null, $donnees['reponse_vrai_faux'] ?? null);
try {
ReponseQuiz::create([
'session_quiz_id' => $session->id,
'question_quiz_id' => $question->id,
'eleve_id' => $eleve->id,
'choix_question_quiz_id' => $donnees['choix_id'] ?? null,
'reponse_vrai_faux' => $donnees['reponse_vrai_faux'] ?? null,
'correcte' => $correcte,
'repondu_a' => now(),
]);
} catch (QueryException $e) {
// Double clic : la contrainte d'unicité (session, question, élève)
// a bloqué un second envoi arrivé en même temps que le premier.
abort(409, 'Déjà répondu.');
}
return response()->json(['correcte' => $correcte]);
}
}

View file

@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers;
use App\Models\Classe;
use App\Models\Quiz;
use App\Models\SessionQuiz;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* Lancement en direct d'un quiz (chantier « vrai cockpit », étape 2) :
* indépendant d'une séance de lab — le prof peut le lancer à tout moment
* pour sa classe, les élèves le voient apparaître sur leur tableau de bord.
*/
class SessionQuizController extends Controller
{
public function lancer(Classe $classe, Quiz $quiz): RedirectResponse
{
$this->verifierProprietaire($classe);
abort_if($quiz->classe_id !== $classe->id, 404);
abort_if($quiz->questions()->count() === 0, 422, 'Ce quiz n’a encore aucune question.');
$session = $quiz->sessions()->create(['statut' => SessionQuiz::EN_ATTENTE, 'demarree_a' => now()]);
return redirect()->route('sessions-quiz.show', $session);
}
public function show(SessionQuiz $session): View
{
$this->verifierProprietaireSession($session);
return view('quiz.session-prof', [
'session' => $session->load(['quiz.questions.choix', 'questionCourante.choix']),
'decompte' => $session->decompteQuestionCourante(),
'total_eleves' => $session->quiz->classe->eleves()->count(),
]);
}
public function etat(SessionQuiz $session): JsonResponse
{
$this->verifierProprietaireSession($session);
return response()->json([
'statut' => $session->statut,
'question_id' => $session->question_quiz_courante_id,
'decompte' => $session->decompteQuestionCourante(),
]);
}
public function suivante(SessionQuiz $session): RedirectResponse
{
$this->verifierProprietaireSession($session);
$session->passerALaQuestionSuivante();
return redirect()->route('sessions-quiz.show', $session);
}
public function terminer(SessionQuiz $session): RedirectResponse
{
$this->verifierProprietaireSession($session);
$session->terminer();
return redirect()->route('mes-classes.quiz.show', [$session->quiz->classe, $session->quiz]);
}
private function verifierProprietaireSession(SessionQuiz $session): void
{
abort_if($session->quiz->classe->user_id !== Auth::guard('web')->id(), 403);
}
private function verifierProprietaire(Classe $classe): void
{
abort_if($classe->user_id !== Auth::guard('web')->id(), 403);
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ChoixQuestionQuiz extends Model
{
protected $table = 'choix_questions_quiz';
protected $fillable = ['question_quiz_id', 'texte', 'est_correct', 'ordre'];
protected $casts = [
'est_correct' => 'boolean',
];
/** @return BelongsTo<QuestionQuiz, $this> */
public function question(): BelongsTo
{
return $this->belongsTo(QuestionQuiz::class, 'question_quiz_id');
}
}

View file

@ -67,6 +67,12 @@ class Classe extends Model
return $this->hasMany(Seance::class);
}
/** @return HasMany<Quiz, $this> */
public function quizzes(): HasMany
{
return $this->hasMany(Quiz::class)->latest();
}
public function estArchivee(): bool
{
return $this->archivee_a !== null;

View file

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class QuestionQuiz extends Model
{
public const QCM = 'qcm';
public const VRAI_FAUX = 'vrai_faux';
protected $table = 'questions_quiz';
protected $fillable = ['quiz_id', 'enonce', 'type', 'ordre', 'reponse_vrai_faux'];
protected $casts = [
'reponse_vrai_faux' => 'boolean',
];
/** @return BelongsTo<Quiz, $this> */
public function quiz(): BelongsTo
{
return $this->belongsTo(Quiz::class);
}
/** @return HasMany<ChoixQuestionQuiz, $this> */
public function choix(): HasMany
{
return $this->hasMany(ChoixQuestionQuiz::class)->orderBy('ordre');
}
/**
* Vrai si $choixId (QCM) ou $reponseVraiFaux (vrai/faux) est la bonne
* réponse à cette question. Un seul des deux arguments compte, selon
* le type de la question.
*/
public function estCorrecte(?int $choixId, ?bool $reponseVraiFaux): bool
{
if ($this->type === self::VRAI_FAUX) {
return $reponseVraiFaux !== null && $reponseVraiFaux === $this->reponse_vrai_faux;
}
return $choixId !== null && $this->choix()->where('id', $choixId)->where('est_correct', true)->exists();
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Banque de questions réutilisable, propre à une classe (chantier « vrai
* cockpit », étape 2 — 2026-10-13). Lancé en direct par SessionQuiz.
*/
class Quiz extends Model
{
protected $fillable = ['classe_id', 'titre'];
/** @return BelongsTo<Classe, $this> */
public function classe(): BelongsTo
{
return $this->belongsTo(Classe::class);
}
/** @return HasMany<QuestionQuiz, $this> */
public function questions(): HasMany
{
return $this->hasMany(QuestionQuiz::class)->orderBy('ordre');
}
/** @return HasMany<SessionQuiz, $this> */
public function sessions(): HasMany
{
return $this->hasMany(SessionQuiz::class);
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReponseQuiz extends Model
{
protected $table = 'reponses_quiz';
protected $fillable = ['session_quiz_id', 'question_quiz_id', 'eleve_id', 'choix_question_quiz_id', 'reponse_vrai_faux', 'correcte', 'repondu_a'];
protected $casts = [
'reponse_vrai_faux' => 'boolean',
'correcte' => 'boolean',
'repondu_a' => 'datetime',
];
/** @return BelongsTo<SessionQuiz, $this> */
public function session(): BelongsTo
{
return $this->belongsTo(SessionQuiz::class, 'session_quiz_id');
}
/** @return BelongsTo<QuestionQuiz, $this> */
public function question(): BelongsTo
{
return $this->belongsTo(QuestionQuiz::class, 'question_quiz_id');
}
/** @return BelongsTo<Eleve, $this> */
public function eleve(): BelongsTo
{
return $this->belongsTo(Eleve::class);
}
}

View file

@ -0,0 +1,96 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Lancement en direct d'un Quiz (chantier « vrai cockpit », étape 2).
* Indépendant d'une séance de lab : les élèves de la classe la voient
* apparaître sur leur tableau de bord dès qu'elle passe en_cours.
*/
class SessionQuiz extends Model
{
public const EN_ATTENTE = 'en_attente';
public const EN_COURS = 'en_cours';
public const TERMINEE = 'terminee';
protected $table = 'sessions_quiz';
protected $fillable = ['quiz_id', 'statut', 'question_quiz_courante_id', 'demarree_a', 'terminee_a'];
protected $casts = [
'demarree_a' => 'datetime',
'terminee_a' => 'datetime',
];
/** @return BelongsTo<Quiz, $this> */
public function quiz(): BelongsTo
{
return $this->belongsTo(Quiz::class);
}
/** @return BelongsTo<QuestionQuiz, $this> */
public function questionCourante(): BelongsTo
{
return $this->belongsTo(QuestionQuiz::class, 'question_quiz_courante_id');
}
/** @return HasMany<ReponseQuiz, $this> */
public function reponses(): HasMany
{
return $this->hasMany(ReponseQuiz::class);
}
/**
* Passe à la question suivante du quiz (dans l'ordre), ou termine la
* session s'il n'y en a plus. Idempotent : rejouable sans effet si déjà
* terminée.
*/
public function passerALaQuestionSuivante(): void
{
if ($this->statut === self::TERMINEE) {
return;
}
$questions = $this->quiz->questions;
$indiceActuel = $this->question_quiz_courante_id
? $questions->search(fn ($q) => $q->id === $this->question_quiz_courante_id)
: -1;
$suivante = $questions->get($indiceActuel + 1);
if ($suivante) {
$this->forceFill(['statut' => self::EN_COURS, 'question_quiz_courante_id' => $suivante->id])->save();
} else {
$this->terminer();
}
}
public function terminer(): void
{
if ($this->statut !== self::TERMINEE) {
$this->forceFill(['statut' => self::TERMINEE, 'terminee_a' => now()])->save();
}
}
/**
* Décompte des réponses à la question en cours, pour le panneau du
* prof : combien d'élèves ont déjà répondu, combien ont juste.
*
* @return array{repondu: int, correctes: int}
*/
public function decompteQuestionCourante(): array
{
if (! $this->question_quiz_courante_id) {
return ['repondu' => 0, 'correctes' => 0];
}
$reponses = $this->reponses->where('question_quiz_id', $this->question_quiz_courante_id);
return ['repondu' => $reponses->count(), 'correctes' => $reponses->where('correcte', true)->count()];
}
}

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Chantier « vrai cockpit », étape 2 : Quiz flash. Une classe a une banque
* de quiz réutilisables (créés par le prof), chacun lancé en direct quand
* il le décide (table sessions_quiz).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('quizzes', function (Blueprint $table) {
$table->id();
$table->foreignId('classe_id')->constrained()->cascadeOnDelete();
$table->string('titre');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('quizzes');
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('questions_quiz', function (Blueprint $table) {
$table->id();
$table->foreignId('quiz_id')->constrained()->cascadeOnDelete();
$table->text('enonce');
$table->string('type')->comment('qcm, vrai_faux');
$table->unsignedSmallInteger('ordre')->default(0);
// Seulement pour le type vrai_faux : les choix du QCM sont dans
// choix_questions_quiz, chacun avec son propre est_correct.
$table->boolean('reponse_vrai_faux')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('questions_quiz');
}
};

View file

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('choix_questions_quiz', function (Blueprint $table) {
$table->id();
// constrained() devinerait "question_quizzes" (pluriel anglais du
// nom de colonne) : notre table s'appelle "questions_quiz".
$table->foreignId('question_quiz_id')->constrained('questions_quiz')->cascadeOnDelete();
$table->string('texte');
$table->boolean('est_correct')->default(false);
$table->unsignedSmallInteger('ordre')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('choix_questions_quiz');
}
};

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Un lancement en direct d'un quiz (indépendant d'une séance de lab, voix
* d'Adam le 2026-10-12) : les élèves de la classe le voient apparaître sur
* leur tableau de bord sans rien faire — pas de code à saisir, ils sont
* déjà des comptes réels de la classe.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('sessions_quiz', function (Blueprint $table) {
$table->id();
$table->foreignId('quiz_id')->constrained()->cascadeOnDelete();
$table->string('statut')->default('en_attente')->comment('en_attente, en_cours, terminee');
$table->foreignId('question_quiz_courante_id')->nullable()->constrained('questions_quiz')->nullOnDelete();
$table->timestamp('demarree_a')->nullable();
$table->timestamp('terminee_a')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('sessions_quiz');
}
};

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Une réponse d'un élève à une question, pendant une session de quiz en
* direct. Un seul envoi compte (contrainte d'unicité) : un élève ne peut
* pas changer d'avis après coup, ni répondre deux fois à la même question.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('reponses_quiz', function (Blueprint $table) {
$table->id();
// constrained() devine le nom de la table à partir du nom de la
// colonne (pluriel anglais) : nos tables ont un nom différent
// (sessions_quiz, questions_quiz, choix_questions_quiz), donc
// toujours précisé explicitement ici.
$table->foreignId('session_quiz_id')->constrained('sessions_quiz')->cascadeOnDelete();
$table->foreignId('question_quiz_id')->constrained('questions_quiz')->cascadeOnDelete();
$table->foreignId('eleve_id')->constrained()->cascadeOnDelete();
$table->foreignId('choix_question_quiz_id')->nullable()->constrained('choix_questions_quiz')->nullOnDelete();
$table->boolean('reponse_vrai_faux')->nullable();
$table->boolean('correcte');
$table->timestamp('repondu_a');
$table->timestamps();
$table->unique(['session_quiz_id', 'question_quiz_id', 'eleve_id']);
});
}
public function down(): void
{
Schema::dropIfExists('reponses_quiz');
}
};

View file

@ -15,6 +15,14 @@
<a href="{{ route('labs.essai') }}" class="inline-flex items-center gap-4 border border-[#e3e5e8] rounded-2xl bg-black px-6 py-4 text-lg font-extrabold text-white transition-transform hover:bg-[#ec3013].5.5 active:shadow-none">Reprendre mon TP <span class="font-mono" aria-hidden="true">→</span></a>
</div>
</section>
{{-- Chantier « vrai cockpit », étape 2 : Quiz flash, réel, pas de démo.
Caché par défaut ; rempli et affiché par le sondage ci-dessous. --}}
<section id="quiz-flash" hidden class="mx-auto max-w-7xl px-5 pt-10 lg:px-10" aria-labelledby="t-quiz-flash">
<div class="rounded-2xl border border-[#e3e5e8] bg-[#eef0fd] p-6">
<h2 id="t-quiz-flash" class="text-2xl font-extrabold">Quiz flash</h2>
<div data-quiz-contenu class="mt-4"></div>
</div>
</section>
@php($seancesOuvertes = $eleve->classe->seances()->whereIn('statut', [\App\Models\Seance::SALLE_ATTENTE, \App\Models\Seance::EN_COURS])->with('lab')->get())
@if ($seancesOuvertes->isNotEmpty())
<section class="mx-auto max-w-7xl px-5 pt-10 lg:px-10" aria-labelledby="t-seance-ouverte">
@ -82,4 +90,86 @@
</section>
</aside>
</div>
<script nonce="{{ Vite::cspNonce() }}">
(function () {
var URL_ETAT = @json(route('quiz.eleve.etat'));
var CSRF = @json(csrf_token());
var section = document.getElementById('quiz-flash');
var contenu = section.querySelector('[data-quiz-contenu]');
var questionAffichee = null, repondu = false;
function echapper(s) {
return s.replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; });
}
function rendreQuestion(q) {
var champs = q.type === 'vrai_faux'
? '<label class="flex items-center gap-2 font-semibold"><input type="radio" name="reponse" value="vrai"> Vrai</label>' +
'<label class="flex items-center gap-2 font-semibold"><input type="radio" name="reponse" value="faux"> Faux</label>'
: q.choix.map(function (c) {
return '<label class="flex items-center gap-2 font-semibold"><input type="radio" name="reponse" value="' + c.id + '"> ' + echapper(c.texte) + '</label>';
}).join('');
contenu.innerHTML =
'<p class="text-xl font-extrabold">' + echapper(q.enonce) + '</p>' +
'<form data-form-reponse class="mt-4 grid gap-2">' + champs +
'<button type="submit" class="mt-2 inline-flex h-11 w-fit items-center rounded-full bg-black px-5 font-bold text-white">Valider ma réponse</button></form>' +
'<p data-resultat class="mt-3 hidden font-extrabold"></p>';
contenu.querySelector('[data-form-reponse]').addEventListener('submit', function (e) {
e.preventDefault();
var choisi = contenu.querySelector('input[name="reponse"]:checked');
if (!choisi) return;
var corps = q.type === 'vrai_faux'
? { reponse_vrai_faux: choisi.value === 'vrai' }
: { choix_id: parseInt(choisi.value, 10) };
fetch('/sessions-quiz/' + sessionActuelle + '/repondre', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': CSRF, 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(corps),
}).then(function (r) { return r.json(); }).then(function (d) {
repondu = true;
var form = contenu.querySelector('[data-form-reponse]');
form.hidden = true;
var p = contenu.querySelector('[data-resultat]');
p.hidden = false;
p.textContent = d.correcte ? 'Bonne réponse !' : 'Réponse envoyée.';
p.className = 'mt-3 font-extrabold ' + (d.correcte ? 'text-[#3C8A5A]' : '');
}).catch(function () {});
});
}
var sessionActuelle = null;
function sonder() {
fetch(URL_ETAT, { headers: { Accept: 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (d) {
var s = d.session;
if (!s) { section.hidden = true; questionAffichee = null; sessionActuelle = null; return; }
sessionActuelle = s.id;
section.hidden = false;
if (s.statut === 'en_attente' || !s.question) {
questionAffichee = null;
contenu.innerHTML = '<p class="font-semibold">Un quiz va commencer. Garde cette page ouverte.</p>';
return;
}
if (s.deja_repondu) {
questionAffichee = s.question.id;
contenu.innerHTML = '<p class="font-extrabold">' + echapper(s.question.enonce) + '</p><p class="mt-3 font-extrabold">Réponse envoyée. En attente de la suite…</p>';
return;
}
if (s.question.id !== questionAffichee) {
questionAffichee = s.question.id;
repondu = false;
rendreQuestion(s.question);
}
}).catch(function () {});
}
sonder();
setInterval(sonder, 2000);
})();
</script>
@endsection

File diff suppressed because one or more lines are too long

View file

@ -30,6 +30,7 @@
<li><a href="#rejoindre" class="flex items-center rounded-lg border-2 border-transparent px-3 py-2 hover:border-black">Faire rejoindre</a></li>
<li><a href="#eleves" class="flex items-center rounded-lg border-2 border-transparent px-3 py-2 hover:border-black">Élèves</a></li>
<li><a href="#seance" class="flex items-center rounded-lg border-2 border-transparent px-3 py-2 hover:border-black">{{ $classe->estArchivee() ? 'Classe archivée' : 'Séance' }}</a></li>
<li><a href="{{ route('mes-classes.quiz.index', $classe) }}" class="flex items-center rounded-lg border-2 border-transparent px-3 py-2 hover:border-black">Quiz flash</a></li>
</ul>
@if ($seance)
<div class="mt-6 rounded-lg border border-[#dadce0] bg-white p-3 text-sm font-semibold">

View file

@ -0,0 +1,39 @@
@extends('layouts.vitrine')
@section('titre', 'Quiz · '.$classe->nom)
@section('contenu')
<div class="mx-auto max-w-4xl px-5 py-14 lg:px-10">
<p class="font-mono text-sm font-semibold"><a href="{{ route('mes-classes.show', $classe) }}" class="underline underline-offset-4">{{ $classe->nom }}</a></p>
<h1 class="mt-1 text-4xl font-extrabold tracking-tight">Quiz</h1>
<p class="mt-2 font-semibold">Un quiz se prépare une fois, puis se relance en direct autant de fois que tu veux : les élèves le voient apparaître sur leur tableau de bord, sans code à saisir.</p>
<section class="mt-8 rounded-2xl border border-[#e3e5e8] bg-white p-6" aria-labelledby="t-nouveau">
<h2 id="t-nouveau" class="text-xl font-extrabold">Nouveau quiz</h2>
@error('titre')<p class="mt-2 font-bold text-[#A63D2A]">{{ $message }}</p>@enderror
<form action="{{ route('mes-classes.quiz.store', $classe) }}" method="post" class="mt-4 flex flex-wrap gap-3">
@csrf
<input name="titre" required placeholder="Ex. Adressage IPv4" class="flex-1 rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold">
<button type="submit" class="inline-flex h-11 items-center rounded-full bg-[#0b57d0] px-5 font-bold text-white">Créer</button>
</form>
</section>
<section class="mt-8" aria-labelledby="t-liste">
<h2 id="t-liste" class="text-xl font-extrabold">Mes quiz ({{ $quizzes->count() }})</h2>
@if ($quizzes->isEmpty())
<p class="mt-2 font-semibold">Aucun quiz pour l'instant.</p>
@else
<ul class="mt-4 grid gap-3">
@foreach ($quizzes as $quiz)
<li>
<a href="{{ route('mes-classes.quiz.show', [$classe, $quiz]) }}" class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[#e3e5e8] bg-white p-5 hover:bg-[#f8f9fa]">
<span class="text-lg font-extrabold">{{ $quiz->titre }}</span>
<span class="font-mono text-sm font-semibold">{{ $quiz->questions_count }} question(s)</span>
</a>
</li>
@endforeach
</ul>
@endif
</section>
</div>
@endsection

View file

@ -0,0 +1,67 @@
@extends('layouts.vitrine')
@section('titre', $session->quiz->titre.' · en direct')
@php($etats = \App\Models\SessionQuiz::class)
@section('contenu')
<div class="mx-auto max-w-3xl px-5 py-14 lg:px-10">
<p class="font-mono text-sm font-semibold">{{ $session->quiz->classe->nom }}</p>
<h1 class="mt-1 text-4xl font-extrabold tracking-tight">{{ $session->quiz->titre }}</h1>
@if ($session->statut === $etats::TERMINEE)
<div class="mt-8 rounded-2xl border border-[#e3e5e8] bg-[#e6f4ea] p-8 text-center">
<p class="text-2xl font-extrabold">Quiz terminé.</p>
<a href="{{ route('mes-classes.quiz.show', [$session->quiz->classe, $session->quiz]) }}" class="mt-4 inline-flex h-11 items-center rounded-full bg-black px-5 font-bold text-white">Retour au quiz</a>
</div>
@elseif (! $session->questionCourante)
<div class="mt-8 rounded-2xl border border-[#e3e5e8] bg-white p-8 text-center">
<p class="text-xl font-semibold">Prêt à commencer ({{ $session->quiz->questions->count() }} question(s)).</p>
<form action="{{ route('sessions-quiz.suivante', $session) }}" method="post" class="mt-4">
@csrf
<button type="submit" class="inline-flex h-12 items-center rounded-full bg-black px-6 text-lg font-bold text-white">Commencer</button>
</form>
</div>
@else
@php($question = $session->questionCourante)
@php($numero = $session->quiz->questions->search(fn ($q) => $q->id === $question->id) + 1)
<div class="mt-8 rounded-2xl border border-[#e3e5e8] bg-white p-8">
<p class="font-mono text-sm font-semibold">Question {{ $numero }} / {{ $session->quiz->questions->count() }}</p>
<p class="mt-2 text-2xl font-extrabold">{{ $question->enonce }}</p>
@if ($question->type === \App\Models\QuestionQuiz::VRAI_FAUX)
<p class="mt-4 font-mono font-semibold">Bonne réponse : {{ $question->reponse_vrai_faux ? 'Vrai' : 'Faux' }}</p>
@else
<ul class="mt-4 grid gap-1 font-semibold">
@foreach ($question->choix as $choix)
<li class="{{ $choix->est_correct ? 'text-[#3C8A5A]' : '' }}">{{ $choix->est_correct ? '✓' : '·' }} {{ $choix->texte }}</li>
@endforeach
</ul>
@endif
<div class="mt-6 flex items-center gap-4 rounded-xl border border-[#dadce0] bg-[#f8f9fa] p-4">
<p class="font-mono text-lg font-bold"><span data-repondu>{{ $decompte['repondu'] }}</span> / {{ $total_eleves }} ont répondu</p>
<p class="font-mono text-lg font-bold text-[#3C8A5A]"><span data-correctes>{{ $decompte['correctes'] }}</span> correctes</p>
</div>
<form action="{{ route('sessions-quiz.suivante', $session) }}" method="post" class="mt-6">
@csrf
<button type="submit" class="inline-flex h-12 items-center rounded-full bg-black px-6 text-lg font-bold text-white">{{ $numero === $session->quiz->questions->count() ? 'Terminer' : 'Question suivante' }}</button>
</form>
</div>
@endif
</div>
<script nonce="{{ Vite::cspNonce() }}">
(function () {
var questionId = @json($session->question_quiz_courante_id);
setInterval(function () {
fetch('{{ route('sessions-quiz.etat', $session) }}', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (e) {
if (e.question_id !== questionId || e.statut === 'terminee') { location.reload(); return; }
var r = document.querySelector('[data-repondu]'); if (r) r.textContent = e.decompte.repondu;
var c = document.querySelector('[data-correctes]'); if (c) c.textContent = e.decompte.correctes;
}).catch(function () {});
}, 2000);
})();
</script>
@endsection

View file

@ -0,0 +1,99 @@
@extends('layouts.vitrine')
@section('titre', $quiz->titre)
@section('contenu')
<div class="mx-auto max-w-4xl px-5 py-14 lg:px-10">
<p class="font-mono text-sm font-semibold"><a href="{{ route('mes-classes.quiz.index', $classe) }}" class="underline underline-offset-4">Quiz · {{ $classe->nom }}</a></p>
<div class="mt-1 flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-extrabold tracking-tight">{{ $quiz->titre }}</h1>
<form action="{{ route('mes-classes.quiz.lancer', [$classe, $quiz]) }}" method="post">
@csrf
<button type="submit" class="inline-flex h-11 items-center rounded-full bg-black px-5 font-bold text-white" @disabled($quiz->questions->isEmpty())>Lancer ce quiz</button>
</form>
</div>
@error('questions')<p class="mt-2 font-bold text-[#A63D2A]">Ajoute au moins une question avant de lancer.</p>@enderror
<section class="mt-8" aria-labelledby="t-questions">
<h2 id="t-questions" class="text-xl font-extrabold">Questions ({{ $quiz->questions->count() }})</h2>
@if ($quiz->questions->isEmpty())
<p class="mt-2 font-semibold">Aucune question pour l'instant.</p>
@else
<ol class="mt-4 grid gap-3">
@foreach ($quiz->questions as $question)
<li class="rounded-2xl border border-[#e3e5e8] bg-white p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<p class="text-lg font-extrabold">{{ $loop->iteration }}. {{ $question->enonce }}</p>
<form action="{{ route('mes-classes.quiz.questions.destroy', [$classe, $quiz, $question]) }}" method="post" data-confirmer="Supprimer cette question ?">
@csrf
<button type="submit" class="font-bold text-[#A63D2A] underline underline-offset-4">Supprimer</button>
</form>
</div>
@if ($question->type === \App\Models\QuestionQuiz::VRAI_FAUX)
<p class="mt-2 font-mono text-sm font-semibold">Bonne réponse : {{ $question->reponse_vrai_faux ? 'Vrai' : 'Faux' }}</p>
@else
<ul class="mt-2 grid gap-1 font-semibold">
@foreach ($question->choix as $choix)
<li class="{{ $choix->est_correct ? 'text-[#3C8A5A]' : '' }}">{{ $choix->est_correct ? '✓' : '·' }} {{ $choix->texte }}</li>
@endforeach
</ul>
@endif
</li>
@endforeach
</ol>
@endif
</section>
<section class="mt-8 rounded-2xl border border-[#e3e5e8] bg-white p-6" aria-labelledby="t-ajout">
<h2 id="t-ajout" class="text-xl font-extrabold">Ajouter une question</h2>
<form action="{{ route('mes-classes.quiz.questions.store', [$classe, $quiz]) }}" method="post" class="mt-4 grid gap-4" id="form-question">
@csrf
<label class="grid gap-1 font-bold">Énoncé
<textarea name="enonce" required rows="2" class="rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold"></textarea>
</label>
<fieldset class="grid gap-1 font-bold">
<legend>Type de question</legend>
<label class="inline-flex items-center gap-2 font-semibold"><input type="radio" name="type" value="qcm" data-type-radio checked> QCM (une bonne réponse)</label>
<label class="inline-flex items-center gap-2 font-semibold"><input type="radio" name="type" value="vrai_faux" data-type-radio> Vrai / Faux</label>
</fieldset>
<div data-bloc-qcm class="grid gap-2">
<p class="font-bold">Choix (coche la bonne réponse)</p>
@for ($i = 0; $i < 4; $i++)
<label class="flex items-center gap-2 font-semibold">
<input type="radio" name="bonne_reponse" value="{{ $i }}" @required($i === 0) aria-label="Choix {{ $i + 1 }} correct">
<input name="choix[]" placeholder="Choix {{ $i + 1 }}{{ $i < 2 ? '' : ' (facultatif)' }}" @required($i < 2) class="flex-1 rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold">
</label>
@endfor
</div>
<div data-bloc-vrai-faux class="grid gap-1 font-bold">
<p>Bonne réponse</p>
<label class="inline-flex items-center gap-2 font-semibold"><input type="radio" name="reponse_vrai_faux" value="1"> Vrai</label>
<label class="inline-flex items-center gap-2 font-semibold"><input type="radio" name="reponse_vrai_faux" value="0"> Faux</label>
</div>
<button type="submit" class="inline-flex h-11 w-fit items-center rounded-full bg-[#0b57d0] px-5 font-bold text-white">Ajouter la question</button>
</form>
</section>
</div>
<script nonce="{{ Vite::cspNonce() }}">
(function () {
var form = document.getElementById('form-question');
var blocQcm = form.querySelector('[data-bloc-qcm]');
var blocVraiFaux = form.querySelector('[data-bloc-vrai-faux]');
var champsQcm = blocQcm.querySelectorAll('input');
function appliquer() {
var qcm = form.querySelector('[data-type-radio]:checked').value === 'qcm';
blocQcm.hidden = !qcm;
blocVraiFaux.hidden = qcm;
champsQcm.forEach(function (c) { c.disabled = !qcm; });
}
form.querySelectorAll('[data-type-radio]').forEach(function (r) { r.addEventListener('change', appliquer); });
appliquer();
})();
document.querySelectorAll('form[data-confirmer]').forEach(function (f) {
f.addEventListener('submit', function (e) { if (!window.confirm(f.dataset.confirmer)) e.preventDefault(); });
});
</script>
@endsection

View file

@ -6,9 +6,12 @@ use App\Http\Controllers\ConsoleController;
use App\Http\Controllers\CorrectionController;
use App\Http\Controllers\EleveTableauDeBordController;
use App\Http\Controllers\InscriptionController;
use App\Http\Controllers\QuizController;
use App\Http\Controllers\QuizEleveController;
use App\Http\Controllers\RapportCspController;
use App\Http\Controllers\RejoindreClasseController;
use App\Http\Controllers\SeanceController;
use App\Http\Controllers\SessionQuizController;
use App\Http\Controllers\VisioController;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Support\Facades\Route;
@ -96,6 +99,12 @@ Route::middleware(['auth:eleve', 'session.unique:eleve'])->group(function () {
->middleware('throttle:6,1')
->name('lab-sessions.corriger');
Route::post('/lab-sessions/{labSession}/machines/{labSessionMachine}/console', [ConsoleController::class, 'pourEleve'])->name('console.eleve');
// Chantier « vrai cockpit », étape 2 : Quiz flash.
Route::get('/quiz-flash/etat', [QuizEleveController::class, 'etat'])->name('quiz.eleve.etat');
Route::post('/sessions-quiz/{session}/repondre', [QuizEleveController::class, 'repondre'])
->middleware('throttle:30,1')
->name('sessions-quiz.repondre');
});
// --- Espace prof (protégé, session unique) ---
@ -123,4 +132,17 @@ Route::middleware(['auth:web', 'session.unique:web'])->group(function () {
Route::post('/seances/{seance}/admettre/{labSession}', [SeanceController::class, 'admettre'])->name('seances.admettre');
Route::get('/seances/{seance}/labsessions/{labSession}/console', [SeanceController::class, 'consoleProf'])->name('seances.console.prof.page');
Route::post('/seances/{seance}/labsessions/{labSession}/machines/{labSessionMachine}/console', [ConsoleController::class, 'pourProf'])->name('console.prof');
// Chantier « vrai cockpit », étape 2 : Quiz flash.
Route::get('/mes-classes/{classe}/quiz', [QuizController::class, 'index'])->name('mes-classes.quiz.index');
Route::post('/mes-classes/{classe}/quiz', [QuizController::class, 'store'])->name('mes-classes.quiz.store');
Route::get('/mes-classes/{classe}/quiz/{quiz}', [QuizController::class, 'show'])->name('mes-classes.quiz.show');
Route::post('/mes-classes/{classe}/quiz/{quiz}/supprimer', [QuizController::class, 'destroy'])->name('mes-classes.quiz.destroy');
Route::post('/mes-classes/{classe}/quiz/{quiz}/questions', [QuizController::class, 'storeQuestion'])->name('mes-classes.quiz.questions.store');
Route::post('/mes-classes/{classe}/quiz/{quiz}/questions/{question}/supprimer', [QuizController::class, 'destroyQuestion'])->name('mes-classes.quiz.questions.destroy');
Route::post('/mes-classes/{classe}/quiz/{quiz}/lancer', [SessionQuizController::class, 'lancer'])->name('mes-classes.quiz.lancer');
Route::get('/sessions-quiz/{session}', [SessionQuizController::class, 'show'])->name('sessions-quiz.show');
Route::get('/sessions-quiz/{session}/etat', [SessionQuizController::class, 'etat'])->name('sessions-quiz.etat');
Route::post('/sessions-quiz/{session}/suivante', [SessionQuizController::class, 'suivante'])->name('sessions-quiz.suivante');
Route::post('/sessions-quiz/{session}/terminer', [SessionQuizController::class, 'terminer'])->name('sessions-quiz.terminer');
});

View file

@ -0,0 +1,209 @@
<?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([]);
});