From f2bca621cd7f22e60bf2b36f30cfd87646500d3b Mon Sep 17 00:00:00 2001 From: Adam Belkacemi Date: Wed, 14 Oct 2026 00:35:52 +0200 Subject: [PATCH] =?UTF-8?q?ajoute=20la=20surveillance=20d'examen=20(chanti?= =?UTF-8?q?er=20vrai=20cockpit,=20derni=C3=A8re=20=C3=A9tape)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une séance peut être lancée en mode examen avec une durée dédiée. Réutilise le mécanisme d'expiration déjà en place (labs:expirer-sessions, expire_a) plutôt que d'en construire un nouveau. Le navigateur de l'élève signale les changements d'onglet et les sorties de plein écran (evenements_examen : un type et un horodatage, rien d'autre - pas de capture d'écran ni de webcam) ; le prof voit le compte par élève et le temps restant dans le cockpit de sa classe et la salle d'attente. Choix d'Adam après discussion des options (suivi chronométré simple / + détection d'onglet / + tentative de plein écran) : la troisième, honnêtement présentée - un navigateur ne permet pas de vraiment empêcher de sortir, seulement de le signaler. Avec cette étape, les quatre fonctions fictives du tableau de bord démo (quiz, devoirs, observations, examen) sont maintenant réelles. Co-Authored-By: Claude Sonnet 5 --- docs/journal.md | 22 +++ .../Commands/PurgerClassesArchivees.php | 7 +- .../app/Http/Controllers/ClasseController.php | 2 +- .../Controllers/EvenementExamenController.php | 35 +++++ .../app/Http/Controllers/SeanceController.php | 12 +- portail/app/Models/EvenementExamen.php | 27 ++++ portail/app/Models/LabSession.php | 16 +++ portail/app/Models/Seance.php | 3 +- ...50000_add_mode_examen_to_seances_table.php | 31 +++++ ..._150001_create_evenements_examen_table.php | 29 ++++ .../views/mes-classes/show.blade.php | 31 ++++- .../views/seances/attente-eleve.blade.php | 44 ++++++ .../resources/views/seances/attente.blade.php | 12 +- portail/routes/web.php | 6 + .../tests/Feature/SurveillanceExamenTest.php | 126 ++++++++++++++++++ 15 files changed, 392 insertions(+), 11 deletions(-) create mode 100644 portail/app/Http/Controllers/EvenementExamenController.php create mode 100644 portail/app/Models/EvenementExamen.php create mode 100644 portail/database/migrations/2026_10_14_150000_add_mode_examen_to_seances_table.php create mode 100644 portail/database/migrations/2026_10_14_150001_create_evenements_examen_table.php create mode 100644 portail/tests/Feature/SurveillanceExamenTest.php diff --git a/docs/journal.md b/docs/journal.md index 48e88dc..871b062 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -1025,3 +1025,25 @@ Retour d'Adam après avoir vu la coquille : du texte clair illisible par endroit **Suite** Adam vérifie visuellement (thème sombre, menu avatar, mobile) et me dit si autre chose cloche. Sinon : Surveillance d'examen. + +## 2026-10-14 — Surveillance d'examen (chantier « vrai cockpit », dernière étape) + +Adam confirme que le correctif du thème sombre et du menu avatar est bon, sans navigateur disponible pour le vérifier moi-même cette fois. Avant de coder la dernière fonction fictive du tableau de bord démo, j'ai demandé à Adam ce qui est acceptable de suivre pendant un examen (élèves parfois mineurs). Son choix : suivi chronométré + détection de changement d'onglet + tentative de plein écran — en étant honnête sur la limite : un navigateur ne permet pas de vraiment empêcher l'élève de sortir, seulement de le signaler. + +**Fait** + +- Une séance peut être lancée en « mode examen » avec une durée dédiée (pas celle du lab), choisie par le prof au moment de lancer. Réutilise le mécanisme d'expiration déjà en place (`labs:expirer-sessions`, `expire_a` sur `LabSession`) plutôt que d'en construire un nouveau : l'admission en mode examen pose juste `expire_a` sur la durée de l'examen au lieu de la durée du lab. +- `evenements_examen` : un type (`changement_onglet` ou `sortie_plein_ecran`) et un horodatage, rien d'autre — pas de capture d'écran, pas de webcam (minimisation, CLAUDE.md). Le navigateur de l'élève écoute `visibilitychange` et `fullscreenchange` et signale au serveur ; un bouton propose le plein écran, jamais forcé. +- Le prof voit, dans le cockpit de sa classe et dans la salle d'attente : un badge « Mode examen · durée », le nombre de sorties par élève, le temps restant. +- Purge RGPD : les événements d'examen partent avec la classe (vérifié par un test). +- 7 nouveaux tests Pest (100 au total) : durée d'examen appliquée à l'admission, un élève ne peut signaler que sur sa propre session, aucun événement possible hors mode examen, type invalide refusé, purge RGPD, JavaScript valide des deux nouveaux scripts (barre prof, page élève). + +**Comprendre** + +Le choix qui a le plus simplifié ce chantier : ne pas inventer un nouveau mécanisme de fin de session pour l'examen. `expire_a` et `labs:expirer-sessions` existaient déjà pour une autre raison (libérer les VM d'un TP normal) ; l'examen ne fait qu'y mettre une durée différente. Même vérification de temps restant côté élève (JS, toutes les 10 s) que côté prof — pas de nouveau canal de communication. + +**Vérifié sur le serveur réel** (prof et élève jetables, sans VM réelle créée — session de lab montée directement en base, comme pour vérifier Quiz flash, pour ne pas consommer de capacité OpenStack juste pour tester) : le prof voit bien « Mode examen · 45 min » et la colonne Sorties dans le cockpit de sa classe ; l'élève, sur sa page de lab actif, voit le bandeau temps restant ; un événement `changement_onglet` posté par l'élève apparaît aussitôt chez le prof (passé de 0 à 1, en rouge) ; un type d'événement invalide est refusé (422) ; un élève ne peut pas poster sur la session d'un autre (403, déjà couvert par les tests). Classe de test supprimée, rien touché chez Adam. + +**Suite** + +Chantier « vrai cockpit » terminé : les quatre fonctions fictives du tableau de bord démo (quiz, devoirs, observations, examen) sont maintenant réelles. Reprendre le plan du bloc A (CLAUDE.md, section 2) là où il en était. diff --git a/portail/app/Console/Commands/PurgerClassesArchivees.php b/portail/app/Console/Commands/PurgerClassesArchivees.php index 7f3ce47..c0b8ec2 100644 --- a/portail/app/Console/Commands/PurgerClassesArchivees.php +++ b/portail/app/Console/Commands/PurgerClassesArchivees.php @@ -11,9 +11,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, * corrections, quiz et devoirs maison (chantier « vrai cockpit »), ainsi que - * les annonces, observations et le chat élève ↔ prof (séparation vitrine/ - * application, 2026-10-13). Le compte du prof est conservé. Le journal ne - * garde que des identifiants et des nombres, jamais de nom d'élève. + * les annonces, observations, le chat élève ↔ prof (séparation vitrine/ + * application, 2026-10-13) et les événements d'examen (2026-10-14). 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). diff --git a/portail/app/Http/Controllers/ClasseController.php b/portail/app/Http/Controllers/ClasseController.php index 3d0834f..d7bf73b 100644 --- a/portail/app/Http/Controllers/ClasseController.php +++ b/portail/app/Http/Controllers/ClasseController.php @@ -44,7 +44,7 @@ class ClasseController extends Controller // d'attente, la plus récemment démarrée d'abord. $seance = $classe->seances() ->whereIn('statut', [Seance::SALLE_ATTENTE, Seance::EN_COURS]) - ->with(['lab', 'labSessions' => fn ($q) => $q->with(['eleve', 'derniereCorrection', 'machines'])]) + ->with(['lab', 'labSessions' => fn ($q) => $q->with(['eleve', 'derniereCorrection', 'machines', 'evenementsExamen'])]) ->latest('demarree_a') ->get() ->sortByDesc(fn ($s) => $s->statut === Seance::EN_COURS) diff --git a/portail/app/Http/Controllers/EvenementExamenController.php b/portail/app/Http/Controllers/EvenementExamenController.php new file mode 100644 index 0000000..e4fbe6a --- /dev/null +++ b/portail/app/Http/Controllers/EvenementExamenController.php @@ -0,0 +1,35 @@ +user(); + abort_if($labSession->eleve_id !== $eleve->id, 403); + + $seance = $labSession->seance; + abort_unless($seance->mode_examen, 403); + + $donnees = $request->validate([ + 'type' => ['required', 'in:'.implode(',', [EvenementExamen::CHANGEMENT_ONGLET, EvenementExamen::SORTIE_PLEIN_ECRAN])], + ]); + + $labSession->evenementsExamen()->create($donnees); + + return response()->json([], 201); + } +} diff --git a/portail/app/Http/Controllers/SeanceController.php b/portail/app/Http/Controllers/SeanceController.php index a797f57..341ca68 100644 --- a/portail/app/Http/Controllers/SeanceController.php +++ b/portail/app/Http/Controllers/SeanceController.php @@ -31,8 +31,12 @@ class SeanceController extends Controller $donnees = $request->validate([ 'lab_id' => ['required', 'exists:labs,id'], 'prevue_a' => ['nullable', 'date_format:Y-m-d\TH:i'], + 'mode_examen' => ['nullable', 'boolean'], + 'duree_examen_min' => ['required_if:mode_examen,1', 'nullable', 'integer', 'min:5', 'max:300'], ]); + $modeExamen = $request->boolean('mode_examen'); + if (! empty($donnees['prevue_a'])) { $prevueA = Carbon::createFromFormat('Y-m-d\TH:i', $donnees['prevue_a'], config('app.fuseau_utilisateurs'))->utc(); @@ -44,6 +48,8 @@ class SeanceController extends Controller 'lab_id' => $donnees['lab_id'], 'statut' => Seance::PLANIFIEE, 'prevue_a' => $prevueA, + 'mode_examen' => $modeExamen, + 'duree_examen_min' => $modeExamen ? $donnees['duree_examen_min'] : null, ]); return redirect()->route('mes-classes.show', $classe)->with('seance_planifiee', true); @@ -53,6 +59,8 @@ class SeanceController extends Controller 'lab_id' => $donnees['lab_id'], 'statut' => Seance::SALLE_ATTENTE, 'demarree_a' => now(), + 'mode_examen' => $modeExamen, + 'duree_examen_min' => $modeExamen ? $donnees['duree_examen_min'] : null, ]); return redirect()->route('seances.attente', $seance); @@ -74,7 +82,7 @@ class SeanceController extends Controller abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403); return view('seances.attente', [ - 'seance' => $seance->load(['classe', 'lab', 'labSessions.eleve', 'labSessions.machines', 'labSessions.derniereCorrection']), + 'seance' => $seance->load(['classe', 'lab', 'labSessions.eleve', 'labSessions.machines', 'labSessions.derniereCorrection', 'labSessions.evenementsExamen']), ]); } @@ -107,7 +115,7 @@ class SeanceController extends Controller ->update([ 'etat' => LabSession::ADMIS, 'raison_echec' => null, - 'expire_a' => now()->addMinutes($labSession->lab->duree_min ?? 45), + 'expire_a' => now()->addMinutes($seance->mode_examen ? $seance->duree_examen_min : ($labSession->lab->duree_min ?? 45)), ]); if ($pris === 0) { diff --git a/portail/app/Models/EvenementExamen.php b/portail/app/Models/EvenementExamen.php new file mode 100644 index 0000000..f7b7d01 --- /dev/null +++ b/portail/app/Models/EvenementExamen.php @@ -0,0 +1,27 @@ + */ + public function labSession(): BelongsTo + { + return $this->belongsTo(LabSession::class); + } +} diff --git a/portail/app/Models/LabSession.php b/portail/app/Models/LabSession.php index 1f861ea..c6a9da5 100644 --- a/portail/app/Models/LabSession.php +++ b/portail/app/Models/LabSession.php @@ -91,4 +91,20 @@ class LabSession extends Model { return $this->hasOne(Correction::class)->latestOfMany(); } + + /** @return HasMany */ + public function evenementsExamen(): HasMany + { + return $this->hasMany(EvenementExamen::class)->latest(); + } + + /** Nul si la session n'expire jamais ou si l'expiration est déjà passée. */ + public function minutesRestantes(): ?int + { + if ($this->expire_a === null || $this->expire_a->isPast()) { + return null; + } + + return (int) ceil(now()->diffInSeconds($this->expire_a) / 60); + } } diff --git a/portail/app/Models/Seance.php b/portail/app/Models/Seance.php index fce0886..79a4450 100644 --- a/portail/app/Models/Seance.php +++ b/portail/app/Models/Seance.php @@ -20,7 +20,7 @@ class Seance extends Model public const TERMINEE = 'terminee'; - protected $fillable = ['classe_id', 'lab_id', 'statut', 'prevue_a', 'demarree_a', 'terminee_a', 'visio_ouverte_a', 'visio_salle', 'visio_chat']; + protected $fillable = ['classe_id', 'lab_id', 'statut', 'prevue_a', 'demarree_a', 'terminee_a', 'visio_ouverte_a', 'visio_salle', 'visio_chat', 'mode_examen', 'duree_examen_min']; protected $casts = [ 'prevue_a' => 'datetime', @@ -28,6 +28,7 @@ class Seance extends Model 'terminee_a' => 'datetime', 'visio_ouverte_a' => 'datetime', 'visio_chat' => 'boolean', + 'mode_examen' => 'boolean', ]; public function visioOuverte(): bool diff --git a/portail/database/migrations/2026_10_14_150000_add_mode_examen_to_seances_table.php b/portail/database/migrations/2026_10_14_150000_add_mode_examen_to_seances_table.php new file mode 100644 index 0000000..a051297 --- /dev/null +++ b/portail/database/migrations/2026_10_14_150000_add_mode_examen_to_seances_table.php @@ -0,0 +1,31 @@ +boolean('mode_examen')->default(false)->after('statut'); + $table->unsignedSmallInteger('duree_examen_min')->nullable()->after('mode_examen'); + }); + } + + public function down(): void + { + Schema::table('seances', function (Blueprint $table) { + $table->dropColumn(['mode_examen', 'duree_examen_min']); + }); + } +}; diff --git a/portail/database/migrations/2026_10_14_150001_create_evenements_examen_table.php b/portail/database/migrations/2026_10_14_150001_create_evenements_examen_table.php new file mode 100644 index 0000000..acbfd9f --- /dev/null +++ b/portail/database/migrations/2026_10_14_150001_create_evenements_examen_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('lab_session_id')->constrained()->cascadeOnDelete(); + $table->string('type')->comment('changement_onglet, sortie_plein_ecran'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('evenements_examen'); + } +}; diff --git a/portail/resources/views/mes-classes/show.blade.php b/portail/resources/views/mes-classes/show.blade.php index 06043c9..0469cf0 100644 --- a/portail/resources/views/mes-classes/show.blade.php +++ b/portail/resources/views/mes-classes/show.blade.php @@ -153,7 +153,11 @@ {{-- Vraies données de la séance en cours : un élève, son état, sa dernière vérification. --}}
-

{{ $seance->lab->titre }} — {{ $seance->statut === \App\Models\Seance::EN_COURS ? 'en cours' : 'salle d\'attente' }}

+

{{ $seance->lab->titre }} — {{ $seance->statut === \App\Models\Seance::EN_COURS ? 'en cours' : 'salle d\'attente' }} + @if ($seance->mode_examen) + Mode examen · {{ $seance->duree_examen_min }} min + @endif +

Salle d'attente {{ $seance->visioOuverte() ? 'Rejoindre la visio' : 'Lancer la visio' }} @@ -164,7 +168,9 @@ @else
- + + @if ($seance->mode_examen)@endif + @foreach ($seance->labSessions as $labSession) @@ -195,6 +201,15 @@ — @endif + @if ($seance->mode_examen) + + @endif
ÉlèveÉtatDernière vérification
ÉlèveÉtatDernière vérificationSorties
+ @if ($labSession->evenementsExamen->isNotEmpty()) + {{ $labSession->evenementsExamen->count() }} + @else + 0 + @endif + @if ($labSession->etat === $etats::ACTIF) Voir sa console @@ -414,7 +429,7 @@ @if (session('seance_planifiee'))

Séance planifiée.

@endif -
+ @csrf + +
@error('prevue_a')

{{ $message }}

@enderror + @error('duree_examen_min') +

{{ $message }}

+ @enderror +

Mode examen : chronométré, détecte les changements d'onglet et propose le plein écran. Ça ne bloque pas vraiment l'élève — un navigateur ne le permet pas — mais tu vois les sorties.

@php($planifiees = $classe->seances()->where('statut', \App\Models\Seance::PLANIFIEE)->with('lab')->orderBy('prevue_a')->get()) @if ($planifiees->isNotEmpty()) @@ -489,6 +510,10 @@ if (!occupe) location.reload(); }, 15000); @endif + var caseModeExamen = document.getElementById('case-mode-examen'); + if (caseModeExamen) caseModeExamen.addEventListener('change', function () { + document.getElementById('champ-duree-examen').hidden = !caseModeExamen.checked; + }); document.querySelectorAll('[data-imprimer]').forEach(function (b) { b.addEventListener('click', function () { window.print(); }); }); document.querySelectorAll('form[data-confirmer]').forEach(function (f) { f.addEventListener('submit', function (e) { if (!window.confirm(f.dataset.confirmer)) e.preventDefault(); }); diff --git a/portail/resources/views/seances/attente-eleve.blade.php b/portail/resources/views/seances/attente-eleve.blade.php index 6b12963..9ecf746 100644 --- a/portail/resources/views/seances/attente-eleve.blade.php +++ b/portail/resources/views/seances/attente-eleve.blade.php @@ -26,6 +26,13 @@

Ton prof le voit et peut relancer. Cette page se mettra à jour toute seule.

@elseif ($labSession->etat === $etats::ACTIF && $labSession->machines->isNotEmpty()) + @if ($seance->mode_examen) +
+

Mode examen · temps restant : …

+ +
+

Reste sur cette page : les changements d'onglet et les sorties du plein écran sont signalés à ton prof.

+ @endif

Tes machines

@foreach ($labSession->machines as $i => $machine) @@ -96,6 +103,43 @@ @endforeach }); + @if ($seance->mode_examen) + + @endif @else

Cette séance est terminée pour toi

diff --git a/portail/resources/views/seances/attente.blade.php b/portail/resources/views/seances/attente.blade.php index 30da541..03227ad 100644 --- a/portail/resources/views/seances/attente.blade.php +++ b/portail/resources/views/seances/attente.blade.php @@ -8,7 +8,11 @@

{{ $seance->classe->nom }} · {{ $seance->lab->titre }}

-

Salle d'attente

+

Salle d'attente + @if ($seance->mode_examen) + Mode examen · {{ $seance->duree_examen_min }} min + @endif +

La page se rafraîchit toutes les 10 secondes.

{{ $seance->visioOuverte() ? 'Retourner à la visio' : 'Visio de la classe' }}

@@ -38,6 +42,12 @@ @endif Voir sa console + @if ($seance->mode_examen) + {{ $labSession->evenementsExamen->count() }} sortie(s) + @if ($labSession->minutesRestantes() !== null) + {{ $labSession->minutesRestantes() }} min restantes + @endif + @endif @break @case($etats::ECHEC) Échec du lancement (3 essais) diff --git a/portail/routes/web.php b/portail/routes/web.php index 1fbd9a4..1570ece 100644 --- a/portail/routes/web.php +++ b/portail/routes/web.php @@ -15,6 +15,7 @@ use App\Http\Controllers\DevoirController; use App\Http\Controllers\DevoirEleveController; use App\Http\Controllers\EleveTableauDeBordController; use App\Http\Controllers\EmploiDuTempsController; +use App\Http\Controllers\EvenementExamenController; use App\Http\Controllers\InscriptionController; use App\Http\Controllers\MaClasseController; use App\Http\Controllers\MesCoursController; @@ -140,6 +141,11 @@ Route::middleware(['auth:eleve', 'session.unique:eleve', 'maj.activite'])->group ->name('lab-sessions.corriger'); Route::post('/lab-sessions/{labSession}/machines/{labSessionMachine}/console', [ConsoleController::class, 'pourEleve'])->name('console.eleve'); + // Chantier « vrai cockpit », dernière étape : Surveillance d'examen. + Route::post('/lab-sessions/{labSession}/evenements-examen', [EvenementExamenController::class, 'store']) + ->middleware('throttle:20,1') + ->name('lab-sessions.evenements-examen'); + // 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']) diff --git a/portail/tests/Feature/SurveillanceExamenTest.php b/portail/tests/Feature/SurveillanceExamenTest.php new file mode 100644 index 0000000..a05bdc8 --- /dev/null +++ b/portail/tests/Feature/SurveillanceExamenTest.php @@ -0,0 +1,126 @@ +create(); + $lab = Lab::factory()->create(['identifiant' => '08-deux-lan-routeur']); + $seance = Seance::create([ + 'classe_id' => $classe->id, + 'lab_id' => $lab->id, + 'statut' => Seance::SALLE_ATTENTE, + 'demarree_a' => now(), + 'mode_examen' => true, + 'duree_examen_min' => $dureeExamenMin, + ]); + $eleve = Eleve::factory()->create(['classe_id' => $classe->id]); + + return LabSession::create(['seance_id' => $seance->id, 'eleve_id' => $eleve->id, 'lab_id' => $lab->id, 'etat' => LabSession::EN_ATTENTE]); +} + +test("admettre un élève en mode examen utilise la durée de l'examen, pas celle du lab", function () { + Queue::fake(); + $this->mock(StockChaud::class, function ($stock) { + $stock->shouldReceive('prendre')->once()->andReturn(null); + }); + $session = labSessionEnAttenteExamen(dureeExamenMin: 90); + + admettre($this, $session)->assertRedirect(); + + $session->refresh(); + expect(now()->diffInMinutes($session->expire_a))->toBeGreaterThan(85) + ->and(now()->diffInMinutes($session->expire_a))->toBeLessThanOrEqual(90); +}); + +test("un élève signale un événement d'examen sur sa propre session", function () { + $session = labSessionEnAttenteExamen(); + $session->update(['etat' => LabSession::ACTIF]); + + eleve($this, $session->eleve) + ->postJson(route('lab-sessions.evenements-examen', $session), ['type' => 'changement_onglet']) + ->assertCreated(); + + expect($session->evenementsExamen()->count())->toBe(1) + ->and($session->evenementsExamen()->first()->type)->toBe(EvenementExamen::CHANGEMENT_ONGLET); +}); + +test("un élève ne peut pas signaler un événement sur la session d'un autre élève", function () { + $session = labSessionEnAttenteExamen(); + $intrus = Eleve::factory()->create(); + + eleve($this, $intrus) + ->postJson(route('lab-sessions.evenements-examen', $session), ['type' => 'changement_onglet']) + ->assertForbidden(); + + expect($session->evenementsExamen()->count())->toBe(0); +}); + +test("pas d'événement d'examen possible sur une séance normale (mode examen désactivé)", function () { + $session = sessionEnAttente(); + $session->update(['etat' => LabSession::ACTIF]); + + eleve($this, $session->eleve) + ->postJson(route('lab-sessions.evenements-examen', $session), ['type' => 'changement_onglet']) + ->assertForbidden(); +}); + +test('type invalide refusé', function () { + $session = labSessionEnAttenteExamen(); + $session->update(['etat' => LabSession::ACTIF]); + + eleve($this, $session->eleve) + ->postJson(route('lab-sessions.evenements-examen', $session), ['type' => 'webcam_allumee']) + ->assertUnprocessable(); +}); + +test('purger une classe archivée supprime aussi les événements d’examen', function () { + $session = labSessionEnAttenteExamen(); + $session->update(['etat' => LabSession::TERMINE]); + $session->evenementsExamen()->create(['type' => EvenementExamen::SORTIE_PLEIN_ECRAN]); + + $classe = $session->seance->classe; + $classe->forceFill(['archivee_a' => now()->subDays(91)])->save(); + $this->artisan('classes:purger-archivees'); + + expect(EvenementExamen::count())->toBe(0); +}); + +test('les scripts en ligne du mode examen (barre prof et page élève) sont du JavaScript valide', function () { + if (Process::run('node --version')->failed()) { + $this->markTestSkipped('Node absent'); + } + + $session = labSessionEnAttenteExamen(); + $session->update(['etat' => LabSession::ACTIF]); + $session->machines()->create(['nom' => 'pc-a', 'instance_id' => 'uuid-a']); + + $pages = [ + fn () => prof($this, $session->seance->classe->prof)->get(route('mes-classes.show', $session->seance->classe)), + fn () => eleve($this, $session->eleve)->get(route('seances.eleve.attente', $session->seance)), + ]; + + $erreurs = []; + foreach ($pages as $i => $visite) { + preg_match_all('#]*\bsrc=)[^>]*>(.*?)#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([]); +});