diff --git a/CHANGELOG.md b/CHANGELOG.md index 5754e50..b2a7b60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ --- +## 🧩 29 septembre 2026 — Des exercices en plusieurs étapes, vérifiés sur le terminal + +*Leçons · exercices · page de leçon* + +- **Résoudre un vrai conflit, du début à la fin.** L'exercice de la leçon « Conflits de merge » s'arrêtait après `git merge`, au moment où le travail commence. Il compte maintenant cinq étapes : lancer la fusion, lire les marqueurs dans `index.html`, garder la version de la branche, `git add`, puis `git commit`. Chaque étape s'affiche dans la leçon et dans le terminal, avec son indice. +- **Le terminal juge le résultat, pas la phrase tapée.** Une étape est réussie quand le terminal montre son résultat : une fusion en cours, un fichier sans marqueur, un commit de fusion. Toute façon d'y arriver compte, et une commande qui échoue ne compte jamais. Avant, `cat .env` tapé hors du dossier du projet affichait une erreur et validait quand même l'exercice. +- **Les erreurs classiques expliquées sur le moment.** Un `git add` avec les marqueurs encore dans le fichier est accepté par Git sans un mot : le terminal prévient et donne la commande qui répare. Après `git merge --abort`, l'exercice reprend proprement à la première étape. +- **Plus de saut automatique.** Une leçon réussie ne part plus toute seule vers la suivante au bout de 2,5 secondes, avant qu'on ait eu le temps de lire. Le message de réussite s'affiche dans la leçon et dans le terminal (le seul visible sur mobile), puis le bouton « Suivant » attend l'élève. Un exercice déjà réussi peut être refait. +- **Script sous Windows.** L'exercice propose `bash script.sh` : un script bash a besoin de bash (WSL ou Git Bash), PowerShell ne l'exécute pas lui-même. + +--- + ## 🪧 29 septembre 2026 — La page d'accueil ne promet que ce qui existe *Page d'accueil · feuille de route · un test empêche la dérive* diff --git a/STORY.md b/STORY.md index 89738c0..65ba173 100644 --- a/STORY.md +++ b/STORY.md @@ -21,6 +21,18 @@ Ce projet a été construit avec l'aide de Claude — l'IA d'Anthropic, des mod --- +## Un exercice qui s'arrêtait au moment où le travail commence (29 septembre 2026) + +La leçon sur les conflits de merge en provoquait enfin un vrai. Mais l'exercice se validait sur `git merge`, et la page partait vers la leçon suivante deux secondes et demie plus tard. L'élève voyait `CONFLICT`, puis plus rien : la résolution, qui est tout l'objet de la leçon, restait une lecture. + +Le défaut venait de plus loin. Depuis le début, un exercice ne voyait que le texte tapé, jamais ce que le terminal en faisait. Une commande qui échouait pouvait donc valider. Les exercices peuvent maintenant avoir plusieurs étapes, et chaque étape regarde l'état du terminal : y a-t-il une fusion en cours, le fichier contient-il encore des marqueurs, le dernier commit a-t-il deux parents ? Le même moteur sert à la page et aux tests, qui rejouent chaque solution comme un élève la taperait. + +J'ai posé une seule question à Thierry : que faire une fois l'exercice réussi ? Sa réponse : un bouton « Suivant », plus de départ automatique. Puis il a ajouté que, pour ce genre de choix, je n'avais plus à lui demander. Je décide, et je lui explique après. + +Avant d'écrire les messages d'aide, j'ai rejoué les erreurs de débutant dans un vrai Git. Un `git add` avec les marqueurs encore présents passe sans un mot. Ensuite, `git checkout --theirs` répond « Updated 0 paths » et ne répare rien. Le conseil affiché reprend donc la commande qui marche vraiment : `git checkout feature/nouvelle-feature -- index.html`. + +--- + ## La page d'accueil, relue comme un visiteur (29 septembre 2026) Thierry m'a demandé de vérifier que la page d'accueil reflète ce qui existe vraiment. Les chiffres étaient justes : un test les compare déjà au programme réel. Le problème était ailleurs. Sous chaque environnement, la page affiche des commandes par niveau, et j'ai tapé chacune d'elles dans le terminal au lieu de les relire. Vingt-deux répondaient « commande introuvable », et quatre autres n'étaient enseignées dans aucune leçon. `systemctl`, `launchctl`, `Get-Service` : des promesses écrites au début du projet, jamais tenues depuis. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5f5e15e..82f10ee 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap — Terminal Learning -> **Last updated:** 29 September 2026 CEST — 🩺 **Full check-up (P1 terminal fidelity, THI-353), continued**: #396 every reference example runs · #397 tables, blank lines and mobile keys · #398 the installed app clears iOS 26's edge blur · #399 git reads real files, outputs match Git 2.56 · **#400 real git branches** (commit graph, ids identical to real git, merges and real conflicts; two fidelity audits, two code reviews) · #401 the landing page only lists commands the terminal runs and the lessons teach, and its terminal preview prints what real shells print. Planned: a **customising the terminal** module (idea from @thierry). Next: multi-step exercises validated on the terminal state, then the ~20 commands taught but not simulated yet (`find`, `sort`, `diff`, `tar`, `Where-Object`…). +> **Last updated:** 29 September 2026 CEST — 🩺 **Full check-up (P1 terminal fidelity, THI-353), continued**: #396 every reference example runs · #397 tables, blank lines and mobile keys · #398 the installed app clears iOS 26's edge blur · #399 git reads real files, outputs match Git 2.56 · **#400 real git branches** (commit graph, ids identical to real git, merges and real conflicts; two fidelity audits, two code reviews) · #401 the landing page only lists commands the terminal runs and the lessons teach, and its terminal preview prints what real shells print. Planned: a **customising the terminal** module (idea from @thierry, #402). **Multi-step exercises checked on the terminal state** (the conflict lesson now resolves a real conflict end to end; no more auto-advance, a « Suivant » button instead). Next: more exercises as steps (Git module), then the ~20 commands taught but not simulated yet (`find`, `sort`, `diff`, `tar`, `Where-Object`…). > > **Previous update (26 September 2026 CEST)**: 🩺 **Full check-up (P1 terminal fidelity, THI-353)**: #389 a real shell layer (`;` `&&` `||`, pipelines, every redirection) — **198/198 exercises validate with no red line**; #390 output matches a real shell (`pwd`, `cd -`, `ls` in a pipe, `wc`, `apt`); #391 permanent replay of every lesson's theory (754 commands); #392 `cp`/`mv` into a directory (a `mv file .` wiped the home directory) and native Windows paths in PowerShell — theory gaps **174 → 162**, bash shown on Windows **39 → 37**; #393 audit agents refreshed (13 Opus / 8 Sonnet) + new `terminal-fidelity-auditor` (simulator vs real bash / PowerShell). Public landing roadmap corrected (LTI is a disabled spike, OpenAI BYOK blocked by CORS). Next: PowerShell error messages, `/app/reference` pedagogy, remaining theory gaps (Git history, `gh`, jobs). > diff --git a/docs/plan.md b/docs/plan.md index b571ec1..f3f70f3 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1,6 +1,6 @@ # Terminal Learning — Plan de lancement public -> Dernière mise à jour : **29 septembre 2026 CEST** — **🩺 Grand Check-up, P1 « fidélité du terminal » (THI-353), suite** : #396 exemples de la Référence tous exécutables · #397 tableaux, lignes vides et touches du clavier mobile · #398 haut de l'app installée dégagé du flou d'iOS 26 · #399 git lit les vrais fichiers, sorties identiques à Git 2.56 · **#400 vraies branches Git** (graphe de commits, identifiants identiques au vrai Git, fusions et vrais conflits ; deux audits de fidélité et deux revues de code) · page d'accueil : ne promet plus que des commandes simulées. Suite : exercices en plusieurs étapes (validés sur l'état du terminal), puis les ~20 commandes enseignées mais pas encore simulées (`find`, `sort`, `diff`, `tar`, `Where-Object`…). +> Dernière mise à jour : **29 septembre 2026 CEST** — **🩺 Grand Check-up, P1 « fidélité du terminal » (THI-353), suite** : #396 exemples de la Référence tous exécutables · #397 tableaux, lignes vides et touches du clavier mobile · #398 haut de l'app installée dégagé du flou d'iOS 26 · #399 git lit les vrais fichiers, sorties identiques à Git 2.56 · **#400 vraies branches Git** (graphe de commits, identifiants identiques au vrai Git, fusions et vrais conflits ; deux audits de fidélité et deux revues de code) · #401 page d'accueil : ne promet plus que des commandes simulées · #402 module « Personnaliser son terminal » annoncé · **exercices en plusieurs étapes vérifiés sur l'état du terminal** (le conflit Git se résout de A à Z, plus de saut automatique : bouton « Suivant »). Suite : migrer d'autres exercices en étapes (module Git), puis les ~20 commandes enseignées mais pas encore simulées (`find`, `sort`, `diff`, `tar`, `Where-Object`…). > Précédent : **26 septembre 2026 CEST** — **🩺 Grand Check-up, P1 « fidélité du terminal » (THI-353)** : #389 vraie couche shell (**198/198 exercices sans ligne rouge**) · #390 sorties d'un vrai shell · #391 rejeu permanent de la théorie · #392 `cp`/`mv` vers un dossier + chemins Windows natifs (écarts théorie **174 → 162**) · #393 agents d'audit remis à jour + `terminal-fidelity-auditor` · roadmap publique corrigée. Suite : messages d'erreur PowerShell, page Référence, écarts restants. > Statut global : **Phase 5 EN COURS** — Curriculum Expansion : 11 modules ✅, 66 leçons, **2500+ tests unitaires** + 176 E2E — **Vision consolidée** : LTI-first (ADR-001), BYOK OpenRouter 4-tiers (ADR-002), TTFR KPI central (ADR-003), Classroom Composer UI (ADR-004), AI Tutor V1 décisions gelées (ADR-005 — stockage, rate-limit, guardrails), Solo-sustainable practices (ADR-006), tuteur IA socratique dès A1, i18n FR/NL/EN — Architecture stratégique précédente (THI-35) : Terminal Sentinel (Phase 5.5) ✅, RBAC complet (Phase 7) ✅, Admin Panel (Phase 9), PWA avancée (Phase finale) — **Epic Web 2026 Compliance** (THI-96) : 6/8 sub-issues livrées (THI-97 → THI-102), reste Desktop a11y + CSS moderne 2026 — **Phase 7b (AI Tutor V1) ✅ COMPLETE + V1.5 SÉQUENCÉE** : THI-115 ✅, THI-109 ✅ (gate-zero guardrail), THI-110 ✅ (keyManager AES-GCM), THI-120 ✅ (Sentry scrubber), **THI-111 ✅ COEUR FONCTIONNEL** (PR #188 — sanitizer + 4 providers + panel + 287 AI tests, audits guardrail 9.4/10 + security 8.8/10 + ui A11y exemplary), **THI-147 ✅ FIX SAFE-AREA iPhone PWA** (PR #189), **panel actif en Production** (`VITE_AI_TUTOR_ENABLED=true` + `VITE_AI_TUTOR_OPENROUTER_MODEL=anthropic/claude-haiku-4-5` activés Production+Preview par @cowork) — **Sprint sécurité 1-2 mai 2026 ✅ CLOS** : audit security-auditor 8.1/10 → ~8.6/10 post-sprint, 11 PRs livrées (#168 à #178), 5 HIGH/MEDIUM Done (THI-133/134/135/137/140), 4 MEDIUM ciblés en backlog (THI-136/138/139/112), agent `route-attack-auditor` créé — **Session 4 mai 2026** : THI-111 livré + THI-147 livré + 5 tickets V1.5 backlog créés (THI-142 lessonContext renforcé HIGH, THI-143 frustration heuristic + détection sémantique user MEDIUM, THI-144 system prompt v1.1.0 + ADR-008 + eval suite MEDIUM, THI-145 chat assistant role-based Phase 9+ LOW, THI-146 modèle défaut Haiku HIGH déjà actif via env var, THI-148 extend tutor scope platform meta-questions V1.0.1 P1 1h30 estimé) — **Posture validée** : pas de rush deadline, qualité/scalabilité/perf non négociables, plan respecté en ordre, **mea culpa explicite** à chaque round trio @thierry / @cc-terminallearning / @cowork (estimation 30 min → 1h30, privacy `userProgress` retirée V1.0.1, hypothèse `transform` mobile réfutée par diagnostic Chrome DevTools MCP) — **Verdict empirique Haiku 4.5 (capturé 4 mai 21h par @cowork via Chrome MCP)** : 5 tests qualitatifs, score moyen **9.3/10** — Test 1 méta-plateforme 8/10, Test 2 fichiers cachés 9/10, Test 3 hallucination 9.5/10, Test 4 frustration 10/10 (Haiku bascule mode direct AUTONOMEMENT), Test 5 jailbreak 10/10. **Reprio backlog tranchée @cowork** : THI-146 ✅ SUCCÈS validé, **THI-142 → Low** (Haiku gère déjà bien le contexte leçon), **THI-143 → Low** (Haiku résout naturellement la frustration via compréhension contextuelle), **THI-148 P1 INCHANGÉ** (scope ≠ modèle prouvé Test 1, GO IMMÉDIAT), THI-144 P2 Medium (peut englober THI-148 + eval suite), THI-145 P3 Low (Phase 9+). **ROI méthode scientifique** : ~4-6h économisées (THI-142/143 reportés V2). **Next ordonné — décision @cowork 5 mai matin** : (1) **THI-150 EN COURS** (12ᵉ agent `mobile-responsive-auditor`, ex-brick 3a de THI-149 epic Done, 11 sections / ≥48 checkpoints + bonus Section 11 Desktop Preservation + checkpoints BUG-FAB-001 visibility/contrast/detachment), (2) THI-151 audit Playwright WebKit + matrice bugs (ex-brick 3b), (3) THI-152 mini-PRs fix séquentielles (ex-brick 3c, critère ABSOLU **ne pas casser desktop**), (4) THI-148 extend tutor scope méta-plateforme V1.0.1 (1h30 honnête, scope statique platformContext, bump v1.0.0→v1.0.1, audit guardrail Règle 10 obligatoire), (5) **THI-144 enrichi P1** system prompt v1.1.0 + ADR-008 + eval suite (intègre 5 micro-frictions identifiées par cross-validation ChatGPT sur session 8 tours @thierry — compound questions, sur-explication internal mechanics, indices répétés, platformContext absent confirme THI-148, conclusion ouverte), (6) THI-112 onboarding AiKeySetup + picker modèle curated, (7) THI-114 Web Worker isolation, (8) THI-145 chat role-based Phase 9+. **THI-142/143 reportés V2** (Haiku 9.3/10 résout 80% naturellement). diff --git a/scripts/export-curriculum.ts b/scripts/export-curriculum.ts index e625db6..cf36b3e 100644 --- a/scripts/export-curriculum.ts +++ b/scripts/export-curriculum.ts @@ -32,7 +32,7 @@ for (const module of curriculum) { lesson_title: lesson.title, lesson_description: lesson.description.substring(0, 100), // truncate for CSV readability has_exercise: lesson.exercise ? 'yes' : 'no', - test_type: lesson.exercise?.validate ? 'custom' : undefined, + test_type: lesson.exercise ? (lesson.exercise.steps ? 'steps' : 'custom') : undefined, }); } } diff --git a/src/app/components/LessonPage.tsx b/src/app/components/LessonPage.tsx index 73d47e3..2e4adcb 100644 --- a/src/app/components/LessonPage.tsx +++ b/src/app/components/LessonPage.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useRef } from 'react'; +import { useState, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router'; import { ChevronLeft, ChevronRight, CheckCircle2, Terminal, @@ -14,9 +14,9 @@ import { useEnvironment } from '../context/EnvironmentContext'; import { useLessonSEO } from '../hooks/useLessonSEO'; import { toUnixUsername } from '../../lib/username'; import { renderInlineMarkdown, stripInlineMarkdown } from '../../lib/renderInlineMarkdown'; -import { TerminalState, createInitialState } from '../data/terminalEngine'; -import { exerciseAccepts } from '../data/validators'; -import { TerminalEmulator } from './TerminalEmulator'; +import { TerminalState, OutputLine, createInitialState } from '../data/terminalEngine'; +import { exerciseSteps, progressExercise, stepHint, stepInstruction } from '../data/exerciseSteps'; +import { TerminalEmulator, type CommandDetail } from './TerminalEmulator'; import { Button } from './ui/button'; import { AiTutorPanel, isAiTutorEnabled } from './ai/AiTutorPanel'; import { useUserRole } from '@/lib/hooks/useUserRole'; @@ -98,17 +98,26 @@ function BlockRenderer({ block, env = 'linux' }: { block: ContentBlock; env?: En // so navigation is reachable from BOTH mobile panes (Contenu + Terminal). One // definition keeps the two in sync. type LessonTarget = { moduleId: string; lessonId: string } | null; + +// « Suivant » filled in once the exercise is done. Laid over `emerald-soft`, so +// the border (same width) and the focus ring stay. +const NEXT_EMPHASIS = 'bg-emerald-500 hover:bg-emerald-400 text-[#0d1117] hover:text-[#0d1117] border-emerald-500'; + function LessonNav({ prevLesson, nextLesson, onNavigate, onDashboard, + emphasizeNext = false, }: { prevLesson: LessonTarget; nextLesson: LessonTarget; onNavigate: (target: LessonTarget) => void; onDashboard: () => void; + /** The exercise was just done: « Suivant » becomes the obvious next move. */ + emphasizeNext?: boolean; }) { + const nextEmphasis = emphasizeNext ? NEXT_EMPHASIS : undefined; return ( <> + + )} + - {!exerciseCompleted && ( + {currentStep && (
@@ -358,6 +421,7 @@ function LessonContent({ mod, lesson, moduleId, lessonId }: { nextLesson={nextLesson} onNavigate={handleNavigate} onDashboard={() => navigate('/app')} + emphasizeNext={finished} /> @@ -373,10 +437,7 @@ function LessonContent({ mod, lesson, moduleId, lessonId }: { type="button" variant="nav-link" size="tl-nav-inline-xs" - onClick={() => { - setTerminalKey(`${moduleId}-${lessonId}-${Date.now()}`); - setExerciseMessage(''); - }} + onClick={() => setTerminal((t) => ({ ...t, session: t.session + 1 }))} aria-label="Réinitialiser le terminal" className="gap-1.5 -mr-2" > @@ -387,7 +448,7 @@ function LessonContent({ mod, lesson, moduleId, lessonId }: { navigate('/app')} + emphasizeNext={finished} /> diff --git a/src/app/components/TerminalEmulator.tsx b/src/app/components/TerminalEmulator.tsx index d2ccda9..ecae403 100644 --- a/src/app/components/TerminalEmulator.tsx +++ b/src/app/components/TerminalEmulator.tsx @@ -99,8 +99,15 @@ interface TerminalLine { prompt?: string; } +/** What a command printed, and the state it ran in: an exercise step reads both. */ +export interface CommandDetail { + lines: OutputLine[]; + prevState: TerminalState; +} + interface TerminalEmulatorProps { - onCommand?: (command: string, state: TerminalState) => void; + /** Called after each command. The lines it returns (exercise feedback) print after the command's output. */ + onCommand?: (command: string, state: TerminalState, detail: CommandDetail) => OutputLine[] | void; welcomeMessage?: string[]; className?: string; /** Unix username to show in prompt. Defaults to 'user' when not authenticated. */ @@ -238,6 +245,8 @@ export function TerminalEmulator({ onCommand, welcomeMessage, className = '', us return; } + // Exercise feedback keeps its own colours: blue guidance, green success. + const feedback = onCommand?.(trimmed, result.newState, { lines: result.lines, prevState: activeState }) ?? []; const newLines: TerminalLine[] = [ { id: nextId(), type: 'prompt', text: trimmed, prompt }, ...result.lines.map((l: OutputLine) => ({ @@ -245,13 +254,13 @@ export function TerminalEmulator({ onCommand, welcomeMessage, className = '', us type: l.type === 'error' || l.type === 'success' || l.type === 'removed' ? l.type : 'output' as const, text: l.text, })), + ...feedback.map((l: OutputLine) => ({ id: nextId(), type: l.type, text: l.text })), ]; startTransition(() => { setLines((prev) => appendLines(prev, ...newLines)); }); setTermState(result.newState); - onCommand?.(trimmed, result.newState); setInput(''); setHistoryIndex(-1); }, diff --git a/src/app/data/curriculum.ts b/src/app/data/curriculum.ts index 7889e19..299a9d2 100644 --- a/src/app/data/curriculum.ts +++ b/src/app/data/curriculum.ts @@ -8,7 +8,7 @@ import { validateEnvVars, validatePathVariable, validateShellConfig, validateDotenv, validateScripts, validateCron, validatePing, validateCurl, validateWget, validateDns, validateSsh, validateScp, validateGitInit, validateGitConfig, validateGitAddCommit, validateGitStatusLog, validateGitDiffGitignore, validateGitBranch, validateGitMerge, - validateGitRemote, validateGitPushPull, validateGitFetchClone, validatePullRequests, validateMergeStrategies, validateConflicts, validateGithubActions, + validateGitRemote, validateGitPushPull, validateGitFetchClone, validatePullRequests, validateMergeStrategies, validateGithubActions, validateAiHelp, validateAiHelpCapabilities, validateAiHelpLimits, validateAiHelpPrompts, validateAiHelpContext, validateAiHelpValidate, validateAiHelpDebug, validateAiHelpSecurity, validateAiHelpClaudeCli, validateAiHelpCareers, validateAiHelpSenior, validateAiHelpWorkflow, @@ -17,6 +17,8 @@ import { gitRepoEmpty, gitRepoWithChange, gitRepoWithCommit, gitRepoWithBranch, gitRepoWithConflict, gitRepoWithRemote, powershellProfile, sshDirectory, type LessonSetup, } from './lessonSetup'; +import type { OutputLine, TerminalState } from './commands/types'; +import { hasConflictMarkers, inHomeDir, printedError, repoFile, stepAccepts } from './exerciseSteps'; export type BlockType = 'text' | 'code' | 'tip' | 'warning' | 'info'; export interface ContentBlock { @@ -31,15 +33,34 @@ export interface ContentBlock { export type EnvId = 'linux' | 'macos' | 'windows'; -export interface Exercise { +/** What a step sees after each command: the command, what it printed, and the terminal before and after. */ +export interface ExerciseCheckContext { + command: string; + env: EnvId; + state: TerminalState; + prevState: TerminalState; + lines: OutputLine[]; +} + +/** One step of a multi-step exercise. See exerciseSteps.ts. */ +export interface ExerciseStep { + instruction: string; + instructionByEnv?: Partial>; + hint: string; + hintByEnv?: Partial>; + /** Done when the terminal shows the step's result: read the state, not only the command typed. */ + check: (ctx: ExerciseCheckContext) => boolean; + /** A known mistake at this step, and the advice the terminal prints for it. */ + warn?: (ctx: ExerciseCheckContext) => string | undefined; +} + +interface ExerciseBase { instruction: string; /** Per-environment instruction override — falls back to `instruction` if absent. */ instructionByEnv?: Partial>; hint: string; /** Per-environment hint override. */ hintByEnv?: Partial>; - /** env is passed by LessonPage from EnvironmentContext. */ - validate: (command: string, env?: EnvId) => boolean; successMessage: string; /** * Terminal state the exercise starts from (e.g. an initialised Git repository). @@ -48,6 +69,25 @@ export interface Exercise { setup?: LessonSetup; } +/** + * Either one command, accepted by `validate`, or several `steps`, each checked + * on the terminal state (a merge in progress, a file without conflict markers). + */ +export type Exercise = ExerciseBase & ( + | { + /** env is passed by LessonPage from EnvironmentContext. */ + validate: (command: string, env?: EnvId) => boolean; + steps?: never; + restart?: never; + } + | { + steps: ExerciseStep[]; + validate?: never; + /** The learner undid the work (`git merge --abort`): why the exercise starts over. */ + restart?: (ctx: ExerciseCheckContext) => string | undefined; + } +); + export interface Lesson { id: string; title: string; @@ -1770,7 +1810,22 @@ export const curriculum: Module[] = [ hintByEnv: { windows: 'Faites "cd projets" puis "Get-Content .env"', }, - validate: validateDotenv, + steps: [ + { + instruction: 'Entrez dans le dossier du projet avec `cd projets`.', + hint: 'Tapez : cd projets', + check: ({ state }) => inHomeDir(state, 'projets'), + }, + { + instruction: 'Affichez le fichier caché `.env` avec `cat .env`.', + instructionByEnv: { + windows: 'Affichez le fichier caché `.env` avec `Get-Content .env` (ou `cat .env`).', + }, + hint: 'Tapez : cat .env', + hintByEnv: { windows: 'Tapez : Get-Content .env' }, + check: (ctx) => stepAccepts(validateDotenv, ctx), + }, + ], successMessage: 'Parfait ! Vous voyez les variables d\'environnement du projet. Ne commitez jamais ce fichier !', }, }, @@ -1808,13 +1863,28 @@ export const curriculum: Module[] = [ exercise: { instruction: 'Dans le répertoire `projets`, exécutez le script existant avec `./script.sh`.', instructionByEnv: { - windows: 'Dans le répertoire `projets`, exécutez le script avec `.\\script.sh` ou `bash script.sh`.', + windows: 'Dans le répertoire `projets`, exécutez le script avec `bash script.sh` : un script bash a besoin de bash (celui de WSL ou de Git Bash), PowerShell ne l\'exécute pas lui-même.', }, hint: 'Faites d\'abord "cd projets" si ce n\'est pas déjà fait, puis "./script.sh"', hintByEnv: { - windows: 'Faites d\'abord "cd projets", puis ".\\script.sh" ou "bash script.sh"', - }, - validate: validateScripts, + windows: 'Faites d\'abord "cd projets", puis "bash script.sh"', + }, + steps: [ + { + instruction: 'Entrez dans le dossier du projet avec `cd projets`.', + hint: 'Tapez : cd projets', + check: ({ state }) => inHomeDir(state, 'projets'), + }, + { + instruction: 'Exécutez le script avec `./script.sh`.', + instructionByEnv: { + windows: 'Exécutez le script avec `bash script.sh`.', + }, + hint: 'Tapez : ./script.sh', + hintByEnv: { windows: 'Tapez : bash script.sh' }, + check: (ctx) => stepAccepts(validateScripts, ctx), + }, + ], successMessage: 'Bravo ! Vous venez d\'exécuter votre premier script bash.', }, }, @@ -2742,11 +2812,64 @@ export const curriculum: Module[] = [ }, ], exercise: { - instruction: 'Fusionnez la branche `feature/nouvelle-feature` dans la branche courante avec `git merge feature/nouvelle-feature`.', - hint: 'Tapez: git merge feature/nouvelle-feature', - validate: validateConflicts, + instruction: 'Résolvez un vrai conflit, du début à la fin : fusionnez `feature/nouvelle-feature`, regardez le fichier en conflit, gardez la version de la branche, puis terminez la fusion.', + hint: 'Suivez les étapes une par une : chacune donne sa commande. `git status` vous dit à tout moment où en est la fusion.', setup: gitRepoWithConflict('feature/nouvelle-feature'), - successMessage: 'Vous venez de provoquer un vrai conflit : Git a écrit les deux versions du titre dans index.html, entre <<<<<<< et >>>>>>>. Pour le résoudre : cat index.html, gardez la bonne version (ou git checkout --theirs index.html), puis git add index.html et git commit. git merge --abort annule tout.', + steps: [ + { + instruction: 'Lancez la fusion avec `git merge feature/nouvelle-feature`. Git va s\'arrêter sur un conflit.', + hint: 'Tapez : git merge feature/nouvelle-feature', + check: ({ state }) => Boolean(state.git?.merge?.conflicts['index.html']), + }, + { + instruction: 'Regardez ce que Git a écrit dans le fichier avec `cat index.html` : les deux titres sont là, entre `<<<<<<<` et `>>>>>>>`.', + instructionByEnv: { + windows: 'Regardez ce que Git a écrit dans le fichier avec `Get-Content index.html` : les deux titres sont là, entre `<<<<<<<` et `>>>>>>>`.', + }, + hint: 'Tapez : cat index.html', + hintByEnv: { windows: 'Tapez : Get-Content index.html' }, + check: ({ lines }) => !printedError(lines) && lines.some((l) => l.text.includes('')), + }, + { + instruction: 'Gardez la version de la branche avec `git checkout --theirs index.html` (`--ours` garderait celle de main). Il ne doit plus rester aucun marqueur dans le fichier.', + hint: 'Tapez : git checkout --theirs index.html', + check: ({ state }) => { + const text = repoFile(state, 'index.html'); + return text !== null && !hasConflictMarkers(text); + }, + warn: ({ state }) => { + const git = state.git; + if (!git?.merge || git.merge.conflicts['index.html'] || !hasConflictMarkers(git.index?.['index.html'] ?? '')) return undefined; + return 'index.html contient encore les marqueurs <<<<<<< : Git a accepté le git add sans rien dire, et le prochain commit enregistrerait le conflit tel quel. Reprenez la version de la branche avec git checkout feature/nouvelle-feature -- index.html'; + }, + }, + { + instruction: 'Marquez le conflit comme résolu avec `git add index.html`.', + hint: 'Tapez : git add index.html', + check: ({ state }) => { + const git = state.git; + return Boolean(git) && !git?.merge?.conflicts['index.html'] && !hasConflictMarkers(git?.index?.['index.html'] ?? ''); + }, + }, + { + instruction: 'Terminez la fusion avec `git commit --no-edit`, qui garde le message proposé par Git : « Merge branch \'feature/nouvelle-feature\' ».', + hint: 'Tapez : git commit --no-edit', + check: ({ state }) => { + const git = state.git; + return Boolean(git) && !git?.merge && (git?.commits[0]?.parents?.length ?? 0) === 2 && !hasConflictMarkers(git?.head?.['index.html'] ?? ''); + }, + }, + ], + restart: ({ state, prevState }) => { + if (!prevState.git?.merge || state.git?.merge) return undefined; + const head = state.git?.commits[0]; + if ((head?.parents?.length ?? 0) < 2) return 'La fusion a été annulée (git merge --abort) : tout est revenu comme avant. On reprend à l\'étape 1.'; + if (hasConflictMarkers(state.git?.head?.['index.html'] ?? '')) { + return 'Ce commit de fusion a enregistré index.html avec ses marqueurs de conflit. En vrai, on corrigerait le fichier dans un nouveau commit ; ici, cliquez sur « Réinitialiser » pour recommencer proprement.'; + } + return undefined; + }, + successMessage: 'Conflit résolu ! Vous avez fait exactement ce que fait un développeur : lire les marqueurs, choisir une version, `git add` pour dire à Git que c\'est réglé, puis `git commit` pour conclure la fusion. `git log --oneline` montre maintenant le commit de fusion.', }, }, { diff --git a/src/app/data/exerciseSteps.ts b/src/app/data/exerciseSteps.ts new file mode 100644 index 0000000..9589ac5 --- /dev/null +++ b/src/app/data/exerciseSteps.ts @@ -0,0 +1,95 @@ +/** + * How an exercise advances, command after command. Shared by LessonPage and the + * lesson tests, so what a test replays is what a learner lives. + * + * A one-command exercise is a single step. A multi-step exercise checks each + * step on the terminal state after the command (a merge in progress, a file + * without conflict markers), so any way of reaching the result counts, and a + * command that failed never does. + */ +import type { EnvId, Exercise, ExerciseCheckContext, ExerciseStep } from './curriculum'; +import type { OutputLine, TerminalState } from './commands/types'; +import { nodeAt } from './commands/gitTree'; +import { exerciseAccepts, type ValidateFn } from './validators'; + +/** The command printed an error line: it did not do its job. */ +export const printedError = (lines: OutputLine[]): boolean => lines.some((l) => l.type === 'error'); + +/** The learner accepted by `validate`, and the command ran without error. */ +export const stepAccepts = (validate: ValidateFn, { command, env, lines }: ExerciseCheckContext): boolean => + exerciseAccepts(validate, command, env) && !printedError(lines); + +/** The shell stands in `~/<segments>` (the same path on every environment). */ +export const inHomeDir = (state: TerminalState, ...segments: string[]): boolean => + state.cwd.join('/') === ['home', state.user, ...segments].join('/') || + state.cwd.join('/') === ['home', 'user', ...segments].join('/'); + +/** A file of the working tree, by its path in the repository (or from the current directory without one). */ +export function repoFile(state: TerminalState, path: string): string | null { + const base = state.git?.repoPath ?? state.cwd; + const node = nodeAt(state.root, [...base, ...path.split('/')]); + return node?.type === 'file' ? node.content : null; +} + +/** Git's conflict markers, each at the start of a line. */ +export const hasConflictMarkers = (text: string): boolean => /^(<{7}|={7}|>{7})( |$)/m.test(text); + +/** Every exercise as a list of steps: a one-command exercise is a single step. */ +export function exerciseSteps(exercise: Exercise): ExerciseStep[] { + if (exercise.steps) return exercise.steps; + const { validate } = exercise; + return [{ + instruction: exercise.instruction, + instructionByEnv: exercise.instructionByEnv, + hint: exercise.hint, + hintByEnv: exercise.hintByEnv, + check: (ctx) => stepAccepts(validate, ctx), + }]; +} + +export const stepInstruction = (step: ExerciseStep, env: EnvId): string => step.instructionByEnv?.[env] ?? step.instruction; +export const stepHint = (step: ExerciseStep, env: EnvId): string => step.hintByEnv?.[env] ?? step.hint; + +/** Everything the learner of `env` reads about the exercise: instructions and hints, of every step. */ +export function exerciseTexts(exercise: Exercise, env: EnvId): string[] { + const own = [exercise.instructionByEnv?.[env] ?? exercise.instruction, exercise.hintByEnv?.[env] ?? exercise.hint]; + if (!exercise.steps) return own; + return [...own, ...exercise.steps.flatMap((s) => [stepInstruction(s, env), stepHint(s, env)])]; +} + +export interface StepProgress { + /** The step to do next; `steps.length` once the exercise is done. */ + index: number; + /** This command finished the exercise. */ + completed: boolean; + /** What the terminal prints after the command's own output (inline markdown kept). */ + messages: OutputLine[]; +} + +/** + * Where the exercise stands after one command. A command can complete several + * steps at once when their results are already there (`git commit -a` both + * stages and commits), but never skips a step whose result is missing. + */ +export function progressExercise(exercise: Exercise, index: number, ctx: ExerciseCheckContext): StepProgress { + const steps = exerciseSteps(exercise); + const total = steps.length; + if (index >= total) return { index, completed: false, messages: [] }; + const announce = (i: number): OutputLine => ({ type: 'info', text: `Étape ${i + 1}/${total} : ${stepInstruction(steps[i], ctx.env)}` }); + + const why = index > 0 ? exercise.restart?.(ctx) : undefined; + if (why) return { index: 0, completed: false, messages: [{ type: 'info', text: `↺ ${why}` }, announce(0)] }; + + let next = index; + while (next < total && steps[next].check(ctx)) next++; + + if (next === index) { + const warning = steps[index].warn?.(ctx); + return { index, completed: false, messages: warning ? [{ type: 'info', text: `⚠ ${warning}` }] : [] }; + } + if (next === total) { + return { index: next, completed: true, messages: [{ type: 'success', text: `✓ Exercice réussi ! ${exercise.successMessage}` }] }; + } + const done = next - index === 1 ? `✓ Étape ${next}/${total} réussie.` : `✓ Étapes ${index + 1} à ${next} sur ${total} réussies.`; + return { index: next, completed: false, messages: [{ type: 'success', text: done }, announce(next)] }; +} diff --git a/src/app/data/landingContent.ts b/src/app/data/landingContent.ts index 0831a77..a6642cf 100644 --- a/src/app/data/landingContent.ts +++ b/src/app/data/landingContent.ts @@ -136,7 +136,7 @@ export const ROADMAP_IN_PROGRESS: readonly RoadmapGroup[] = [ { group: 'Curriculum', items: [ - 'Exercices en plusieurs étapes, validés sur l\'état du terminal', + 'Exercices en plusieurs étapes, vérifiés sur l\'état du terminal (le premier : résoudre un vrai conflit Git)', 'Une variante PowerShell pour chaque leçon', ], }, diff --git a/src/app/data/validators.ts b/src/app/data/validators.ts index 983d9de..b805996 100644 --- a/src/app/data/validators.ts +++ b/src/app/data/validators.ts @@ -308,8 +308,6 @@ export const validatePullRequests: ValidateFn = (cmd) => { return /^git\s+checkout\s+-b\s+feature\/\S+/.test(c) || /^git\s+switch\s+-c\s+feature\/\S+/.test(c); }; -export const validateConflicts: ValidateFn = (cmd) => /^git\s+merge\s+\S+/.test(cmd.trim().toLowerCase()); - export const validateMergeStrategies: ValidateFn = (cmd) => { const c = cmd.trim().toLowerCase(); if (!/^git\s+merge\b/.test(c)) return false; diff --git a/src/test/curriculumEnvAwareness.test.ts b/src/test/curriculumEnvAwareness.test.ts index 1757686..9157713 100644 --- a/src/test/curriculumEnvAwareness.test.ts +++ b/src/test/curriculumEnvAwareness.test.ts @@ -23,7 +23,9 @@ import { describe, it, expect } from 'vitest'; import { curriculum } from '../app/data/curriculum'; -import type { EnvId } from '../app/data/curriculum'; +import type { EnvId, Exercise } from '../app/data/curriculum'; +import { createInitialState, processCommand, type TerminalState } from '../app/data/terminalEngine'; +import { exerciseSteps, progressExercise } from '../app/data/exerciseSteps'; // ─── Shared fixtures ───────────────────────────────────────────────────────── @@ -41,14 +43,34 @@ const ALL_EXERCISES = ALL_LESSONS.filter((l) => l.exercise != null).map((l) => ( exercise: l.exercise!, })); -/** Lookup helper — returns exercise or throws a clear error. */ -function ex(moduleId: string, lessonId: string) { +/** Lookup helper — returns the exercise, or throws a clear error. */ +function exercise(moduleId: string, lessonId: string): Exercise { const e = curriculum.find((m) => m.id === moduleId)?.lessons.find((l) => l.id === lessonId) ?.exercise; if (!e) throw new Error(`Exercise not found: ${moduleId}/${lessonId}`); return e; } +/** A one-command exercise, with its validate(). */ +function ex(moduleId: string, lessonId: string) { + const e = exercise(moduleId, lessonId); + if (!e.validate) throw new Error(`${moduleId}/${lessonId} has steps: replay them with play()`); + return { ...e, validate: e.validate }; +} + +/** Types `commands` in the lesson's terminal, as LessonPage does: how many steps they complete. */ +function play(e: Exercise, env: EnvId, commands: string[]): number { + let state: TerminalState = createInitialState(); + if (e.setup) state = e.setup.apply(state, env); + let step = 0; + for (const command of commands) { + const out = processCommand(state, command, env); + step = progressExercise(e, step, { command, env, state: out.newState, prevState: state, lines: out.lines }).index; + state = out.newState; + } + return step; +} + // ───────────────────────────────────────────────────────────────────────────── // 1. STRUCTURAL INTEGRITY // Auto-runs on every exercise and content block — no changes needed when you @@ -210,14 +232,14 @@ describe('curriculum — validate() safety contracts', () => { // intentionally accepts ls with any arguments. That is correct behaviour. ]; - it('garbage inputs never validate on any env for any exercise', () => { + it('garbage inputs never advance any exercise on any env', () => { for (const { moduleId, lessonId, exercise } of ALL_EXERCISES) { for (const env of VALID_ENV_IDS) { for (const input of GARBAGE_INPUTS) { expect( - exercise.validate(input, env), + play(exercise, env, [input]), `${moduleId}/${lessonId} — garbage "${input.substring(0, 40)}" validated on env=${env}` - ).toBe(false); + ).toBe(0); } } } @@ -241,7 +263,7 @@ describe('curriculum — validate() safety contracts', () => { for (const env of VALID_ENV_IDS) { for (const input of EDGE_CASES) { expect( - () => exercise.validate(input, env), + () => play(exercise, env, [input]), `${moduleId}/${lessonId} — validate() threw on env=${env}, input="${String(input).substring(0, 30)}"` ).not.toThrow(); } @@ -254,12 +276,16 @@ describe('curriculum — validate() safety contracts', () => { const TEST_COMMANDS = ['pwd', 'ls', 'cd', 'Get-Location', '', 'xyz']; for (const { moduleId, lessonId, exercise } of ALL_EXERCISES) { for (const env of VALID_ENV_IDS) { + const initial = exercise.setup ? exercise.setup.apply(createInitialState(), env) : createInitialState(); for (const cmd of TEST_COMMANDS) { - const result = exercise.validate(cmd, env); - expect( - typeof result, - `${moduleId}/${lessonId} — validate() returned ${typeof result}, expected boolean` - ).toBe('boolean'); + const out = processCommand(initial, cmd, env); + for (const step of exerciseSteps(exercise)) { + const result = step.check({ command: cmd, env, state: out.newState, prevState: initial, lines: out.lines }); + expect( + typeof result, + `${moduleId}/${lessonId} — a step check returned ${typeof result}, expected boolean` + ).toBe('boolean'); + } } } } @@ -856,24 +882,29 @@ describe('curriculum — spot-checks per lesson × env', () => { }); describe('variables/dotenv', () => { - it('linux: cat .env passes', () => { - expect(ex('variables', 'dotenv').validate('cat .env', 'linux')).toBe(true); + const dotenv = exercise('variables', 'dotenv'); + it('linux: cd projets, then cat .env passes', () => { + expect(play(dotenv, 'linux', ['cd projets', 'cat .env'])).toBe(2); }); it('windows: Get-Content .env passes; cat .env also accepted', () => { - expect(ex('variables', 'dotenv').validate('Get-Content .env', 'windows')).toBe(true); - expect(ex('variables', 'dotenv').validate('cat .env', 'windows')).toBe(true); + expect(play(dotenv, 'windows', ['cd projets', 'Get-Content .env'])).toBe(2); + expect(play(dotenv, 'windows', ['cd projets', 'cat .env'])).toBe(2); + }); + it('cat .env outside the project fails, so it does not count', () => { + expect(play(dotenv, 'linux', ['cat .env'])).toBe(0); + expect(play(dotenv, 'linux', ['cd projets', 'cd ..', 'cat .env'])).toBe(1); }); }); describe('variables/scripts', () => { + const scripts = exercise('variables', 'scripts'); it('linux + macos: ./script.sh and bash script.sh pass', () => { - expect(ex('variables', 'scripts').validate('./script.sh', 'linux')).toBe(true); - expect(ex('variables', 'scripts').validate('bash script.sh', 'linux')).toBe(true); - expect(ex('variables', 'scripts').validate('./script.sh', 'macos')).toBe(true); + expect(play(scripts, 'linux', ['cd projets', './script.sh'])).toBe(2); + expect(play(scripts, 'linux', ['cd projets', 'bash script.sh'])).toBe(2); + expect(play(scripts, 'macos', ['cd projets', './script.sh'])).toBe(2); }); - it('windows: .\\script.sh and bash script.sh pass', () => { - expect(ex('variables', 'scripts').validate('.\\script.sh', 'windows')).toBe(true); - expect(ex('variables', 'scripts').validate('bash script.sh', 'windows')).toBe(true); + it('windows: bash script.sh passes', () => { + expect(play(scripts, 'windows', ['cd projets', 'bash script.sh'])).toBe(2); }); }); diff --git a/src/test/exerciseSteps.test.ts b/src/test/exerciseSteps.test.ts new file mode 100644 index 0000000..65ab431 --- /dev/null +++ b/src/test/exerciseSteps.test.ts @@ -0,0 +1,148 @@ +/** + * Multi-step exercises (29 September 2026): each step is checked on the + * terminal state after the command, not on the text typed. The conflict lesson + * is the reference case: every command and output below was checked against + * real Git 2.56 in a throwaway repository (merge, checkout --theirs, add, + * commit --no-edit, and the two ways learners go wrong: `git add` with the + * markers still in, `git merge --abort`). + */ +import { describe, it, expect } from 'vitest'; +import { curriculum, type EnvId, type Exercise } from '../app/data/curriculum'; +import { createInitialState, processCommand, type TerminalState } from '../app/data/terminalEngine'; +import { exerciseSteps, exerciseTexts, hasConflictMarkers, progressExercise, type StepProgress } from '../app/data/exerciseSteps'; + +const find = (moduleId: string, lessonId: string): Exercise => + curriculum.find((m) => m.id === moduleId)!.lessons.find((l) => l.id === lessonId)!.exercise!; + +/** A lesson terminal: type commands one by one, as LessonPage does, and keep every step result. */ +function session(exercise: Exercise, env: EnvId = 'linux') { + let state: TerminalState = createInitialState(); + if (exercise.setup) state = exercise.setup.apply(state, env); + let step = 0; + return { + type(command: string): StepProgress & { output: string[] } { + const out = processCommand(state, command, env); + const progress = progressExercise(exercise, step, { command, env, state: out.newState, prevState: state, lines: out.lines }); + state = out.newState; + step = progress.index; + return { ...progress, output: out.lines.map((l) => l.text) }; + }, + get step() { return step; }, + }; +} + +const conflicts = find('github-collaboration', 'conflicts'); + +describe('the conflict lesson, step by step', () => { + it('has five steps', () => expect(exerciseSteps(conflicts)).toHaveLength(5)); + + it('walks the learner through a real resolution, announcing each next step', () => { + const t = session(conflicts); + const merge = t.type('git merge feature/nouvelle-feature'); + expect(merge.output).toContain('CONFLICT (content): Merge conflict in index.html'); + expect(merge.messages.map((m) => m.text)).toEqual([ + '✓ Étape 1/5 réussie.', + expect.stringMatching(/^Étape 2\/5 : Regardez ce que Git a écrit/), + ]); + expect(t.type('cat index.html').index).toBe(2); + expect(t.type('git checkout --theirs index.html').index).toBe(3); + expect(t.type('git add index.html').index).toBe(4); + const commit = t.type('git commit --no-edit'); + expect(commit.output[0]).toMatch(/^\[main [0-9a-f]{7}\] Merge branch 'feature\/nouvelle-feature'$/); + expect(commit.completed).toBe(true); + expect(commit.messages[0]).toEqual({ type: 'success', text: `✓ Exercice réussi ! ${conflicts.successMessage}` }); + }); + + it('does not count a command that leaves the goal unreached', () => { + const t = session(conflicts); + expect(t.type('git status').index).toBe(0); + t.type('git merge feature/nouvelle-feature'); + // `git log` shows no <title>: the learner has not looked at the file yet. + expect(t.type('git log --oneline').index).toBe(1); + // A file that does not exist prints an error, and an error never counts. + expect(t.type('cat index.htm').index).toBe(1); + }); + + it('warns about `git add` with the markers still in, and the advice gets the learner out', () => { + const t = session(conflicts); + t.type('git merge feature/nouvelle-feature'); + t.type('cat index.html'); + const add = t.type('git add index.html'); + expect(add.index).toBe(2); + expect(add.messages).toHaveLength(1); + expect(add.messages[0].text).toMatch(/^⚠ index\.html contient encore les marqueurs/); + expect(add.messages[0].text).toContain('git checkout feature/nouvelle-feature -- index.html'); + // Real git after that add: `checkout --theirs` finds no "their" version any more. + expect(t.type('git checkout --theirs index.html').output).toEqual(['Updated 0 paths from the index']); + // The advised command restores the file and stages it: steps 3 and 4 at once. + const fix = t.type('git checkout feature/nouvelle-feature -- index.html'); + expect(fix.output).toEqual([]); + expect(fix.index).toBe(4); + expect(fix.messages[0].text).toBe('✓ Étapes 3 à 4 sur 5 réussies.'); + expect(t.type('git commit --no-edit').completed).toBe(true); + }); + + it('starts over after `git merge --abort`, and the merge can be redone', () => { + const t = session(conflicts); + t.type('git merge feature/nouvelle-feature'); + t.type('cat index.html'); + const abort = t.type('git merge --abort'); + expect(abort.index).toBe(0); + expect(abort.messages[0].text).toMatch(/^↺ La fusion a été annulée \(git merge --abort\)/); + expect(abort.messages[1].text).toMatch(/^Étape 1\/5 : /); + expect(t.type('git merge feature/nouvelle-feature').index).toBe(1); + }); + + it('never completes on a merge commit that recorded the markers', () => { + const t = session(conflicts); + t.type('git merge feature/nouvelle-feature'); + t.type('cat index.html'); + t.type('git add index.html'); + const commit = t.type('git commit --no-edit'); + expect(commit.completed).toBe(false); + expect(commit.index).toBe(0); + expect(commit.messages[0].text).toContain('« Réinitialiser »'); + }); + + it('reads the same on Windows, with Get-Content', () => { + const t = session(conflicts, 'windows'); + for (const cmd of ['git merge feature/nouvelle-feature', 'Get-Content index.html', 'git checkout --theirs index.html', 'git add index.html']) t.type(cmd); + expect(t.type('git commit --no-edit').completed).toBe(true); + expect(exerciseTexts(conflicts, 'windows').join(' ')).toContain('Get-Content index.html'); + }); +}); + +describe('one-command exercises', () => { + it('are a single step, done by the command the lesson asks for', () => { + const pwd = find('navigation', 'pwd'); + expect(exerciseSteps(pwd)).toHaveLength(1); + const done = session(pwd).type('pwd'); + expect(done.completed).toBe(true); + expect(done.messages).toEqual([{ type: 'success', text: `✓ Exercice réussi ! ${pwd.successMessage}` }]); + }); + + it('are not done by the right command when it fails', () => { + // validateRm accepts the command, but from ~/documents the path leads nowhere: rm prints an error. + const t = session(find('fichiers', 'rm')); + t.type('cd documents'); + const failed = t.type('rm documents/notes.txt'); + expect(failed.output).toEqual(["rm: cannot remove 'documents/notes.txt': No such file or directory"]); + expect(failed.completed).toBe(false); + }); + + it('say nothing more once done', () => { + const t = session(find('navigation', 'pwd')); + t.type('pwd'); + expect(t.type('pwd')).toMatchObject({ completed: false, messages: [] }); + }); +}); + +describe('hasConflictMarkers', () => { + it('finds the markers only at the start of a line', () => { + expect(hasConflictMarkers('a\n<<<<<<< HEAD\nb')).toBe(true); + expect(hasConflictMarkers('=======')).toBe(true); + expect(hasConflictMarkers('>>>>>>> feature/x')).toBe(true); + expect(hasConflictMarkers('x <<<<<<< y')).toBe(false); + expect(hasConflictMarkers('========')).toBe(false); + }); +}); diff --git a/src/test/landingTotals.test.ts b/src/test/landingTotals.test.ts index cbd5f39..9194eba 100644 --- a/src/test/landingTotals.test.ts +++ b/src/test/landingTotals.test.ts @@ -12,6 +12,7 @@ import { import { createInitialState, processCommand } from '../app/data/terminalEngine'; import type { SelectedEnvironment } from '../app/context/EnvironmentContext'; import { curriculum } from '../app/data/curriculum'; +import { exerciseTexts } from '../app/data/exerciseSteps'; import { commandCatalogue } from '../app/data/commandCatalogue'; import { ENVIRONMENTS } from '../app/types/curriculum'; @@ -97,7 +98,7 @@ describe('landingContent — the commands the hero promises exist', () => { /** What a learner of `env` reads in code blocks and exercises: the commands a lesson teaches. */ const taughtIn = (env: SelectedEnvironment) => curriculum.flatMap((m) => m.lessons.flatMap((l) => [ ...l.blocks.filter((b) => b.type === 'code').map((b) => b.contentByEnv?.[env] ?? b.content), - ...(l.exercise ? [l.exercise.instructionByEnv?.[env] ?? l.exercise.instruction, l.exercise.hintByEnv?.[env] ?? l.exercise.hint] : []), + ...(l.exercise ? exerciseTexts(l.exercise, env) : []), ])).join('\n'); /** The whole command as a word of its own: `top` does not count inside `stop`, nor `env` inside `environment`. */ const asWord = (command: string) => new RegExp(`(^|[^\\w-])${command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^\\w-])`, 'm'); diff --git a/src/test/lessonFidelity.test.ts b/src/test/lessonFidelity.test.ts index a7a2e7c..548124e 100644 --- a/src/test/lessonFidelity.test.ts +++ b/src/test/lessonFidelity.test.ts @@ -8,16 +8,19 @@ * (TerminalEmulator → onCommand(command, state)). * * The terminal state each case starts from is the one LessonPage builds: - * createInitialState(), then the exercise's optional `setup`. + * createInitialState(), then the exercise's optional `setup`. The solution is + * replayed through progressExercise, as LessonPage does: a multi-step exercise + * must reach its last step with the last command, each step checked on the + * terminal state. * * KNOWN_DESYNCS is a ratchet, not an allowlist: each entry runs as `it.fails`, * so fixing one turns this suite red until the entry is removed. It may only * shrink. */ import { describe, it, expect } from 'vitest'; -import { curriculum, type EnvId } from '../app/data/curriculum'; +import { curriculum, type EnvId, type Exercise } from '../app/data/curriculum'; import { createInitialState, processCommand, type TerminalState } from '../app/data/terminalEngine'; -import { exerciseAccepts } from '../app/data/validators'; +import { exerciseSteps, exerciseTexts, progressExercise } from '../app/data/exerciseSteps'; import { LESSON_SOLUTIONS } from './lessonSolutions'; const ENVS: EnvId[] = ['linux', 'macos', 'windows']; @@ -31,6 +34,24 @@ const KNOWN_DESYNCS = new Set<string>([]); const norm = (s: string) => s.replace(/\s+/g, ' ').trim(); +/** Types the solution in a fresh lesson terminal: the errors it printed and when the exercise was done. */ +function replay(exercise: Exercise, env: EnvId, solution: string[]) { + let state: TerminalState = createInitialState(); + if (exercise.setup) state = exercise.setup.apply(state, env); + const errors: string[] = []; + let step = 0; + let doneAt = -1; + solution.forEach((cmd, i) => { + const out = processCommand(state, cmd, env); + for (const line of out.lines) if (line.type === 'error') errors.push(`${cmd} → ${line.text}`); + const progress = progressExercise(exercise, step, { command: cmd, env, state: out.newState, prevState: state, lines: out.lines }); + if (progress.completed) doneAt = i; + step = progress.index; + state = out.newState; + }); + return { errors, step, doneAt }; +} + const cases = curriculum.flatMap((m) => m.lessons .filter((l) => l.exercise) @@ -46,12 +67,15 @@ describe('lesson solutions table', () => { it.each(cases)('$key [$env] — every solution command is written in the lesson', ({ key, env, exercise }) => { const solution = LESSON_SOLUTIONS[key][env] ?? LESSON_SOLUTIONS[key].all; expect(solution, `no solution for ${key} [${env}]`).toBeDefined(); - const shown = norm( - `${exercise.instructionByEnv?.[env] ?? exercise.instruction} ${exercise.hintByEnv?.[env] ?? exercise.hint}`, - ); + const shown = norm(exerciseTexts(exercise, env).join(' ')); for (const cmd of solution!) expect(shown).toContain(norm(cmd)); }); + it.each(cases.filter((c) => c.exercise.steps))('$key [$env] — one solution command per step', ({ key, env, exercise }) => { + const solution = LESSON_SOLUTIONS[key][env] ?? LESSON_SOLUTIONS[key].all ?? []; + expect(solution).toHaveLength(exerciseSteps(exercise).length); + }); + it('only lists known desyncs that exist', () => { const all = new Set(cases.map((c) => `${c.key} [${c.env}]`)); for (const k of KNOWN_DESYNCS) expect(all.has(k), k).toBe(true); @@ -64,16 +88,10 @@ describe('lesson fidelity — the lesson command validates and prints no error', const run = KNOWN_DESYNCS.has(id) ? it.fails : it; run(id, () => { const solution = LESSON_SOLUTIONS[key][env] ?? LESSON_SOLUTIONS[key].all ?? []; - let state: TerminalState = createInitialState(); - if (exercise.setup) state = exercise.setup.apply(state, env); - const errors: string[] = []; - for (const cmd of solution) { - const out = processCommand(state, cmd, env); - state = out.newState; - for (const line of out.lines) if (line.type === 'error') errors.push(`${cmd} → ${line.text}`); - } + const { errors, step, doneAt } = replay(exercise, env, solution); expect(errors).toEqual([]); - expect(exercise.validate(solution[solution.length - 1], env)).toBe(true); + expect(step).toBe(exerciseSteps(exercise).length); + expect(doneAt).toBe(solution.length - 1); }); } }); @@ -103,16 +121,9 @@ describe('lesson fidelity — Windows paths written with backslashes', () => { for (const { key, env, exercise } of windowsCases) { it(`${key} [${env}] — same result with \\ as separator`, () => { const solution = (LESSON_SOLUTIONS[key].windows ?? LESSON_SOLUTIONS[key].all ?? []).map(withBackslashes); - let state: TerminalState = createInitialState(); - if (exercise.setup) state = exercise.setup.apply(state, env); - const errors: string[] = []; - for (const cmd of solution) { - const out = processCommand(state, cmd, env); - state = out.newState; - for (const line of out.lines) if (line.type === 'error') errors.push(`${cmd} → ${line.text}`); - } + const { errors, doneAt } = replay(exercise, env, solution); expect(errors).toEqual([]); - expect(exerciseAccepts(exercise.validate, solution[solution.length - 1], env)).toBe(true); + expect(doneAt).toBe(solution.length - 1); }); } }); diff --git a/src/test/lessonPageExercise.test.tsx b/src/test/lessonPageExercise.test.tsx new file mode 100644 index 0000000..98a03b3 --- /dev/null +++ b/src/test/lessonPageExercise.test.tsx @@ -0,0 +1,141 @@ +/** + * LessonPage and exercises (29 September 2026). The page used to jump to the + * next lesson 2.5 s after the right command, before the learner had read the + * result. Now the success shows in the panel and in the terminal (the only + * pane visible on mobile), and « Suivant » is the learner's choice. Multi-step + * exercises list their steps and move on as the terminal state changes. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, act, within } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router'; +import { LessonPage } from '../app/components/LessonPage'; + +const completeLesson = vi.fn(); +let completed = false; + +vi.mock('../app/context/ProgressContext', () => ({ + useProgress: () => ({ + completeLesson, + isLessonCompleted: () => completed, + isModuleUnlocked: () => true, + }), +})); +vi.mock('../app/context/AuthContext', () => ({ useAuth: () => ({ user: null }) })); +let env = 'linux'; +vi.mock('../app/context/EnvironmentContext', () => ({ useEnvironment: () => ({ selectedEnv: env }) })); +vi.mock('../app/hooks/useLessonSEO', () => ({ useLessonSEO: () => undefined })); +vi.mock('@/lib/hooks/useUserRole', () => ({ useUserRole: () => ({ role: 'student' }) })); +vi.mock('../app/components/ai/AiTutorPanel', () => ({ AiTutorPanel: () => null, isAiTutorEnabled: () => false })); + +function Where() { + return <div data-testid="where">{useLocation().pathname}</div>; +} + +function page(path: string) { + return ( + <MemoryRouter initialEntries={[path]}> + <Routes> + <Route path="/app/learn/:moduleId/:lessonId" element={<><LessonPage /><Where /></>} /> + <Route path="*" element={<Where />} /> + </Routes> + </MemoryRouter> + ); +} + +function open(path: string) { + return render(page(path)); +} + +function type(command: string) { + const input = screen.getByLabelText('Commande terminal'); + fireEvent.change(input, { target: { value: command } }); + fireEvent.submit(input.closest('form')!); +} + +beforeEach(() => { + completeLesson.mockClear(); + completed = false; + env = 'linux'; + vi.useFakeTimers(); +}); +afterEach(() => vi.useRealTimers()); + +describe('LessonPage — a one-command exercise', () => { + it('shows the success in the terminal and the panel, and stays on the lesson', () => { + open('/app/learn/navigation/pwd'); + type('pwd'); + expect(completeLesson).toHaveBeenCalledWith('navigation', 'pwd'); + expect(screen.getByText(/^✓ Exercice réussi ! /)).toBeInTheDocument(); + expect(screen.getByText('→ « Suivant » pour passer à la leçon suivante.')).toBeInTheDocument(); + const status = screen.getByRole('status'); + expect(within(status).getByRole('button', { name: /Suivant/ })).toBeInTheDocument(); + // No auto-advance: the learner reads, then chooses. + act(() => { vi.advanceTimersByTime(10_000); }); + expect(screen.getByTestId('where').textContent).toBe('/app/learn/navigation/pwd'); + fireEvent.click(within(status).getByRole('button', { name: /Suivant/ })); + expect(screen.getByTestId('where').textContent).toBe('/app/learn/navigation/ls'); + }); + + it('does not count a command that failed', () => { + open('/app/learn/variables/dotenv'); + type('cat .env'); + expect(screen.getByText('cat: .env: No such file or directory')).toBeInTheDocument(); + expect(completeLesson).not.toHaveBeenCalled(); + }); +}); + +describe('LessonPage — a multi-step exercise', () => { + it('lists the steps, announces the next one in the terminal, and completes at the last', () => { + open('/app/learn/github-collaboration/conflicts'); + const steps = screen.getByRole('list', { name: "Étapes de l'exercice" }); + expect(within(steps).getAllByRole('listitem')).toHaveLength(5); + expect(within(steps).getAllByRole('listitem')[0]).toHaveAttribute('aria-current', 'step'); + expect(screen.getByText(/^Étape 1\/5 : Lancez la fusion/)).toBeInTheDocument(); + + type('git merge feature/nouvelle-feature'); + expect(screen.getByText('✓ Étape 1/5 réussie.')).toBeInTheDocument(); + expect(within(steps).getAllByRole('listitem')[1]).toHaveAttribute('aria-current', 'step'); + expect(screen.getByRole('button', { name: 'Afficher un indice' })).toBeInTheDocument(); + + for (const cmd of ['cat index.html', 'git checkout --theirs index.html', 'git add index.html']) type(cmd); + expect(completeLesson).not.toHaveBeenCalled(); + type('git commit --no-edit'); + expect(completeLesson).toHaveBeenCalledTimes(1); + expect(within(screen.getByRole('status')).getByRole('button', { name: /Suivant/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Afficher un indice' })).toBeNull(); + }); + + it('starts over on « Réinitialiser »', () => { + open('/app/learn/github-collaboration/conflicts'); + type('git merge feature/nouvelle-feature'); + fireEvent.click(screen.getByRole('button', { name: 'Réinitialiser le terminal' })); + const steps = screen.getByRole('list', { name: "Étapes de l'exercice" }); + expect(within(steps).getAllByRole('listitem')[0]).toHaveAttribute('aria-current', 'step'); + expect(screen.queryByText('✓ Étape 1/5 réussie.')).toBeNull(); + }); + + it('starts over after Linux → Windows → Linux, like the new terminal it shows', () => { + const { rerender } = open('/app/learn/github-collaboration/conflicts'); + type('git merge feature/nouvelle-feature'); + type('cat index.html'); + env = 'windows'; + rerender(page('/app/learn/github-collaboration/conflicts')); + env = 'linux'; + rerender(page('/app/learn/github-collaboration/conflicts')); + const steps = screen.getByRole('list', { name: "Étapes de l'exercice" }); + expect(within(steps).getAllByRole('listitem')[0]).toHaveAttribute('aria-current', 'step'); + // The terminal is fresh too: no merge yet, so the first step is still to do. + type('git status'); + expect(within(steps).getAllByRole('listitem')[0]).toHaveAttribute('aria-current', 'step'); + }); + + it('can be done again once completed, without recording it twice', () => { + completed = true; + open('/app/learn/variables/dotenv'); + expect(screen.getByText(/^✓ Exercice déjà réussi/)).toBeInTheDocument(); + type('cd projets'); + type('cat .env'); + expect(screen.getByText(/^✓ Exercice réussi ! /)).toBeInTheDocument(); + expect(completeLesson).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/lessonSolutions.ts b/src/test/lessonSolutions.ts index c04ac51..fb25db4 100644 --- a/src/test/lessonSolutions.ts +++ b/src/test/lessonSolutions.ts @@ -4,7 +4,8 @@ * * Used by lessonFidelity.test.ts. Each command must appear verbatim in the * lesson's instruction or hint for that environment (the test enforces it), so - * this table cannot drift away from what the learner actually reads. + * this table cannot drift away from what the learner actually reads. A + * multi-step exercise lists one command per step, in order. * * Derived from the browser census of 23 September 2026 (198 lesson × env). */ @@ -80,7 +81,7 @@ export const LESSON_SOLUTIONS: Record<string, Solution> = { 'variables/path-variable': { all: ['echo $PATH'], windows: ['echo $env:PATH'] }, 'variables/shell-config': { linux: ['cat ~/.bashrc'], macos: ['cat ~/.zshrc'], windows: ['cat $PROFILE'] }, 'variables/dotenv': { all: ['cd projets', 'cat .env'], windows: ['cd projets', 'Get-Content .env'] }, - 'variables/scripts': { all: ['cd projets', './script.sh'], windows: ['cd projets', '.\\script.sh'] }, + 'variables/scripts': { all: ['cd projets', './script.sh'], windows: ['cd projets', 'bash script.sh'] }, 'variables/cron': { all: ['crontab -l'], windows: ['Get-ScheduledTask'] }, // ── réseau ── 'reseau/ping': { all: ['ping google.com'] }, @@ -106,7 +107,10 @@ export const LESSON_SOLUTIONS: Record<string, Solution> = { 'github-collaboration/git-fetch-clone': { all: ['git clone https://github.com/user/projet.git'] }, 'github-collaboration/pull-requests': { all: ['git checkout -b feature/nouvelle-feature'] }, 'github-collaboration/merge-strategies': { all: ['git merge --no-ff feature/ma-feature'] }, - 'github-collaboration/conflicts': { all: ['git merge feature/nouvelle-feature'] }, + 'github-collaboration/conflicts': { + all: ['git merge feature/nouvelle-feature', 'cat index.html', 'git checkout --theirs index.html', 'git add index.html', 'git commit --no-edit'], + windows: ['git merge feature/nouvelle-feature', 'Get-Content index.html', 'git checkout --theirs index.html', 'git add index.html', 'git commit --no-edit'], + }, 'github-collaboration/github-actions': { all: ['git status'] }, // ── ia-dev ── 'ia-dev/ia-dev-intro': { all: ['ai-help'] }, diff --git a/src/test/terminalEmulatorInitialState.test.tsx b/src/test/terminalEmulatorInitialState.test.tsx index 0bf4ef6..21b80ab 100644 --- a/src/test/terminalEmulatorInitialState.test.tsx +++ b/src/test/terminalEmulatorInitialState.test.tsx @@ -24,7 +24,26 @@ describe('TerminalEmulator initialState', () => { expect(screen.queryByText(/not a git repository/)).toBeNull(); // The prompt reflects the prepared working directory. expect(screen.getAllByText('user@terminal-lab:~/projets$').length).toBeGreaterThan(0); - expect(onCommand).toHaveBeenCalledWith('git status', expect.objectContaining({ git: expect.objectContaining({ initialized: true }) })); + expect(onCommand).toHaveBeenCalledWith( + 'git status', + expect.objectContaining({ git: expect.objectContaining({ initialized: true }) }), + // What the command printed, and the state it ran in: exercise steps read both. + expect.objectContaining({ + lines: expect.arrayContaining([expect.objectContaining({ text: 'On branch main' })]), + prevState: expect.objectContaining({ git: expect.objectContaining({ initialized: true }) }), + }), + ); + }); + + it('prints the lines onCommand returns after the command output, in their own colour', () => { + const onCommand = vi.fn(() => [{ type: 'success' as const, text: '✓ Étape 1/2 réussie.' }]); + render(<TerminalEmulator onCommand={onCommand} />); + type('pwd'); + const feedback = screen.getByText('✓ Étape 1/2 réussie.'); + expect(feedback.className).toContain('text-[#3fb950]'); + // After the command's own output, never before it. + const output = screen.getByText('/home/user'); + expect(output.compareDocumentPosition(feedback) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); it('keeps the default state when no setup is given', () => { diff --git a/src/test/validators.test.ts b/src/test/validators.test.ts index 880cb87..4760dae 100644 --- a/src/test/validators.test.ts +++ b/src/test/validators.test.ts @@ -52,7 +52,6 @@ import { validateGitFetchClone, validatePullRequests, validateMergeStrategies, - validateConflicts, validateGithubActions, validateAiHelp, validateAiHelpCapabilities, @@ -501,11 +500,6 @@ describe('validateMergeStrategies', () => { expect(validateMergeStrategies('git mergeit --no-ff feature/x')).toBe(false)); }); -describe('validateConflicts', () => { - it('accepts "git merge feature/conflict"', () => expect(validateConflicts('git merge feature/conflict')).toBe(true)); - it('rejects "git status"', () => expect(validateConflicts('git status')).toBe(false)); -}); - describe('validateGithubActions', () => { it('accepts "git status"', () => expect(validateGithubActions('git status')).toBe(true)); it('rejects "git push"', () => expect(validateGithubActions('git push')).toBe(false));