diff --git a/docs/journal.md b/docs/journal.md index 511f88f..f7267a9 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -829,3 +829,22 @@ Le piège de ce chantier n'était pas technique mais une tentation facile : reco **Suite** Chantier « vrai cockpit », étape 2 : Quiz flash (prochaine fonction à construire pour de vrai). + +## 2026-10-12 — Chantier « vrai cockpit », étape 1 (suite) : plus près de la démo, toujours sans rien inventer + +Adam a demandé d'aller plus loin dans la fidélité à `/cockpit`. Trois ajouts, tous à partir de données déjà réelles : + +**Fait** + +- **Avancement du lab** : une barre par vérification du lab (équivalent réel de la « répartition par étape » de la démo), comptée sur les vraies dernières corrections de la séance — titres et ordre lus dans le `lab.yaml` (`DefinitionLab`), jamais les vérifications bonus (elles ajoutent des points, elles ne font pas partie du parcours normal). +- **Panneau « Mains levées »** : la même file que la page de visio (premier arrivé en haut), avec la **vraie action** « Donner la parole » (`seances.visio.parole`) directement depuis le tableau de bord — pas une liste en lecture seule. +- **Progression de la séance** (barre latérale) : temps écoulé rapporté à la durée estimée du lab (`Lab::duree_min`) — absente si l'une des deux manque, jamais une estimation inventée. +- `VisioController::mainsLevees()` (nouveau, public, à côté de `nombreMainsLevees()`) : la liste complète (nom, depuis), pas seulement le compte. +- Le calcul de l'avancement a buté deux fois sur l'analyse statique (Larastan) : `Correction::$details` n'avait pas de type précis (ajouté en `@property-read`), et repasser par une requête SQL séparée pour les corrections (au lieu de réutiliser le chargement déjà fait) perdait le type des modèles pour l'outil. Les deux corrigés à la racine, pas contournés. +- 2 nouveaux tests Pest (61 au total) : avancement qui exclut bien les bonus, mains levées avec la vraie action. + +**Vérifié sur le serveur réel** (scénario à deux élèves, corrections différentes, main levée en cache — supprimé ensuite) : avancement affiché par vérification (2/2 puis 1/2), mains levées avec « Yanis » et le lien exact vers `seances.visio.parole`, barre de progression du temps. Classe réelle d'Adam vérifiée intacte après coup (1 classe, 2 élèves). + +**Suite** + +Chantier « vrai cockpit », étape 2 : Quiz flash. diff --git a/portail/app/Http/Controllers/ClasseController.php b/portail/app/Http/Controllers/ClasseController.php index 0a909a4..aa7ee1c 100644 --- a/portail/app/Http/Controllers/ClasseController.php +++ b/portail/app/Http/Controllers/ClasseController.php @@ -3,11 +3,13 @@ namespace App\Http\Controllers; use App\Models\Classe; +use App\Models\Correction; use App\Models\DemandeInscription; use App\Models\Eleve; use App\Models\LabSession; use App\Models\Seance; use App\Services\GenerateurCompteEleve; +use App\Services\Labs\DefinitionLab; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -48,17 +50,44 @@ class ClasseController extends Controller ->sortByDesc(fn ($s) => $s->statut === Seance::EN_COURS) ->first(); + // Déjà chargées par le eager loading ci-dessus (labSessions.derniereCorrection) : + // repasser par une requête séparée perdait le type précis pour l'analyse statique. $corrections = $seance - ? LabSession::query()->whereIn('id', $seance->labSessions->pluck('id'))->whereHas('derniereCorrection')->with('derniereCorrection')->get()->pluck('derniereCorrection') + ? $seance->labSessions->map(fn (LabSession $ls) => $ls->derniereCorrection)->filter()->values() : collect(); + // Répartition par vérification du lab (équivalent réel du « répartition + // par étape » de la démo) : l'ordre et les titres viennent du lab.yaml, + // le compte de réussite des vraies corrections. Jamais de bonus ici + // (ils ajoutent des points, ils ne font pas partie du parcours normal). + $avancement = collect(); + if ($seance && $corrections->isNotEmpty()) { + $definition = DefinitionLab::depuis($seance->lab->identifiant); + $avancement = collect($definition->verifications) + ->reject(fn ($v) => $v['bonus']) + ->map(fn ($v) => [ + 'titre' => $v['titre'], + 'reussi' => $corrections->filter(fn (Correction $c) => collect($c->details)->firstWhere('id', $v['id'])['reussi'] ?? false)->count(), + 'total' => $corrections->count(), + ]); + } + + // Progression dans le temps de la séance, d'après la durée estimée + // du lab (Lab::duree_min) : absente si l'une des deux manque, jamais + // une estimation inventée. + $progressionSeance = ($seance?->demarree_a && $seance->lab->duree_min) + ? min(100, (int) round($seance->demarree_a->diffInMinutes(now()) / $seance->lab->duree_min * 100)) + : null; + return view('mes-classes.show', [ 'classes' => Auth::guard('web')->user()->classes()->withCount('eleves')->orderBy('nom')->get(), 'classe' => $classe->load('eleves'), 'demandes' => $classe->demandesInscription()->where('statut', DemandeInscription::EN_ATTENTE)->oldest()->get(), 'seance' => $seance, + 'avancement' => $avancement, + 'progressionSeance' => $progressionSeance, 'reussiteMoyenne' => $corrections->isNotEmpty() ? (int) round($corrections->avg(fn ($c) => $c->note_max > 0 ? $c->note / $c->note_max * 100 : 0)) : null, - 'mainsLevees' => ($seance && $seance->visioOuverte()) ? VisioController::nombreMainsLevees($seance) : null, + 'mains' => ($seance && $seance->visioOuverte()) ? VisioController::mainsLevees($seance) : [], ]); } diff --git a/portail/app/Http/Controllers/VisioController.php b/portail/app/Http/Controllers/VisioController.php index de57a96..7ee16ab 100644 --- a/portail/app/Http/Controllers/VisioController.php +++ b/portail/app/Http/Controllers/VisioController.php @@ -214,7 +214,26 @@ class VisioController extends Controller public static function nombreMainsLevees(Seance $seance): int { - return count((Cache::get(self::cleEtat($seance)) ?? [])['mains'] ?? []); + return count(self::mainsLevees($seance)); + } + + /** + * Mains levées, premier arrivé en premier (même tri que etatProf) : nom + * déjà minimisé (prénom + initiale), rien de plus que ce qu'affiche + * déjà la page de visio. + * + * @return list + */ + public static function mainsLevees(Seance $seance): array + { + /** @var array $mains */ + $mains = (Cache::get(self::cleEtat($seance)) ?? [])['mains'] ?? []; + + return collect($mains) + ->sortBy('depuis') + ->map(fn ($m, $id) => ['eleve_id' => (int) $id, 'nom' => $m['nom'], 'depuis' => $m['depuis']]) + ->values() + ->all(); } private function cle(Seance $seance): string diff --git a/portail/app/Models/Correction.php b/portail/app/Models/Correction.php index c5c0325..73c59f9 100644 --- a/portail/app/Models/Correction.php +++ b/portail/app/Models/Correction.php @@ -9,6 +9,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * Une passe de vérification d'une session de lab (bouton « Vérifier mon * travail »). details : une entrée par vérification du lab.yaml, avec * id, titre, points, reussi et message (message_echec du lab si échouée). + * + * @property-read array $details */ class Correction extends Model { diff --git a/portail/resources/views/mes-classes/show.blade.php b/portail/resources/views/mes-classes/show.blade.php index 4788bf1..8f6c0f2 100644 --- a/portail/resources/views/mes-classes/show.blade.php +++ b/portail/resources/views/mes-classes/show.blade.php @@ -36,6 +36,10 @@

Séance en cours

{{ $seance->lab->titre }}

{{ $seance->statut === \App\Models\Seance::EN_COURS ? 'En cours' : 'Salle d\'attente' }}

+ @if ($progressionSeance !== null) +
+

{{ $progressionSeance }}% du temps estimé ({{ $seance->lab->duree_min }} min)

+ @endif @endif @@ -78,15 +82,68 @@

{{ $reussiteMoyenne }}%

@endif - @if ($mainsLevees !== null) + @if ($seance->visioOuverte())
  • Mains levées (visio)

    -

    {{ $mainsLevees }}

    +

    {{ count($mains) }}

  • @endif @endif + @if ($seance && $avancement->isNotEmpty()) + {{-- Équivalent réel de la « répartition par étape » de la démo : + une barre par vérification du lab, comptée sur les vraies + dernières corrections (pas de fausse étape numérotée). --}} +
    +
    +

    Avancement du lab « {{ $seance->lab->titre }} »

    +

    {{ $avancement->count() }} vérifications · {{ $avancement->first()['total'] }} élève(s) corrigé(s)

    +
    +
      + @foreach ($avancement as $v) +
    • + {{ $v['titre'] }} + + {{ $v['reussi'] }}/{{ $v['total'] }} +
    • + @endforeach +
    +
    + @endif + + @if ($seance && $seance->visioOuverte()) + {{-- Équivalent réel du panneau « Mains levées » de la démo : la + même file que la page de visio, avec la même action (donner + la parole), juste accessible sans quitter le tableau de bord. --}} +
    +
    +

    Mains levées

    + ordre d'arrivée +
    + @if (empty($mains)) +

    Aucune main levée pour l'instant.

    + @else +
      + @foreach ($mains as $i => $main) +
    1. + + {{ $i + 1 }} + {{ $main['nom'] }} + @php($attente = max(0, time() - $main['depuis'])) + · depuis {{ intdiv($attente, 60) }} min {{ str_pad((string) ($attente % 60), 2, '0', STR_PAD_LEFT) }} s + +
      + @csrf + +
      +
    2. + @endforeach +
    + @endif +
    + @endif + @if ($seance) {{-- Vraies données de la séance en cours : un élève, son état, sa dernière vérification. --}}
    diff --git a/portail/tests/Feature/TableauDeBordProfTest.php b/portail/tests/Feature/TableauDeBordProfTest.php index 9bc87e7..2691dd2 100644 --- a/portail/tests/Feature/TableauDeBordProfTest.php +++ b/portail/tests/Feature/TableauDeBordProfTest.php @@ -6,6 +6,7 @@ use App\Models\Eleve; use App\Models\Lab; use App\Models\LabSession; use App\Models\Seance; +use Illuminate\Support\Facades\Cache; test('le tableau de bord d’une classe vide affiche les cartes réelles, sans aucune donnée inventée', function () { $classe = Classe::factory()->create(['nom' => 'Classe A']); @@ -23,7 +24,7 @@ test('le tableau de bord d’une classe vide affiche les cartes réelles, sans a test('une séance en cours montre les vrais élèves, leur état et leur dernière correction', function () { $classe = Classe::factory()->create(); - $lab = Lab::factory()->create(['titre' => 'Lab 08']); + $lab = Lab::factory()->create(['identifiant' => '08-deux-lan-routeur', 'titre' => 'Lab 08']); $seance = Seance::create(['classe_id' => $classe->id, 'lab_id' => $lab->id, 'statut' => Seance::EN_COURS, 'demarree_a' => now()]); $eleveActif = Eleve::factory()->create(['classe_id' => $classe->id, 'prenom' => 'Léa']); $eleveEchec = Eleve::factory()->create(['classe_id' => $classe->id, 'prenom' => 'Yanis']); @@ -46,6 +47,51 @@ test('une séance en cours montre les vrais élèves, leur état et leur derniè ->assertDontSee('Mains levées'); // pas de visio ouverte : la carte n'existe pas }); +test('l’avancement du lab compte les vraies vérifications réussies, jamais les bonus', function () { + $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::EN_COURS, 'demarree_a' => now()]); + $eleve = Eleve::factory()->create(['classe_id' => $classe->id]); + $session = LabSession::create(['seance_id' => $seance->id, 'eleve_id' => $eleve->id, 'lab_id' => $lab->id, 'etat' => LabSession::ACTIF]); + Correction::create(['lab_session_id' => $session->id, 'reussi' => false, 'note' => 1, 'note_max' => 7, 'details' => [ + ['id' => 'ip-pc-a', 'titre' => 'Adresse de PC-A', 'points' => 1, 'bonus' => false, 'reussi' => true, 'message' => null], + ['id' => 'forwarding', 'titre' => 'Routage activé', 'points' => 1, 'bonus' => false, 'reussi' => false, 'message' => 'Regarde net.ipv4.ip_forward.'], + ['id' => 'bonus-permanent', 'titre' => 'Routage permanent', 'points' => 1, 'bonus' => true, 'reussi' => true, 'message' => null], + ]]); + + commeProf($this, $classe->prof) + ->get(route('mes-classes.show', $classe)) + ->assertOk() + ->assertSee('Avancement du lab') + ->assertSee('Adresse de PC-A') + ->assertSee('1/1') // réussi par le seul élève corrigé + ->assertSee('Routage activé') + ->assertSee('0/1') // pas réussi + ->assertDontSee('Routage permanent'); // bonus : hors du parcours normal +}); + +test('les mains levées de la visio apparaissent sur le tableau de bord, avec l’action réelle « donner la parole »', function () { + $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::EN_COURS, 'demarree_a' => now(), + 'visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-test', 'visio_chat' => false, + ]); + $eleve = Eleve::factory()->create(['classe_id' => $classe->id, 'prenom' => 'Léa']); + Cache::put("visio:{$seance->id}", [ + 'mains' => [$eleve->id => ['nom' => 'Léa', 'depuis' => time() - 90]], + 'participants' => [], 'presences' => [], + ], 3600); + + commeProf($this, $classe->prof) + ->get(route('mes-classes.show', $classe)) + ->assertOk() + ->assertSee('Mains levées') + ->assertSee('Léa') + ->assertSee('1 min 30 s') + ->assertSee(route('seances.visio.parole', [$seance, $eleve]), false); +}); + test('un prof ne voit pas la séance en cours d’une autre classe', function () { $classe = Classe::factory()->create(); $autreClasse = Classe::factory()->create();