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 <noreply@anthropic.com>
126 lines
4.8 KiB
PHP
126 lines
4.8 KiB
PHP
<?php
|
||
|
||
use App\Models\Classe;
|
||
use App\Models\Eleve;
|
||
use App\Models\EvenementExamen;
|
||
use App\Models\Lab;
|
||
use App\Models\LabSession;
|
||
use App\Models\Seance;
|
||
use App\Services\Labs\StockChaud;
|
||
use Illuminate\Support\Facades\Process;
|
||
use Illuminate\Support\Facades\Queue;
|
||
|
||
function labSessionEnAttenteExamen(int $dureeExamenMin = 60): LabSession
|
||
{
|
||
$classe = Classe::factory()->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('#<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([]);
|
||
});
|