From 5a375a507d317bff939500f9d8630b2d861afa77 Mon Sep 17 00:00:00 2001 From: "Thierry V." <46031203+thierryvm@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:35:02 +0200 Subject: [PATCH] fix(lessons): theory shows what the terminal prints, checked by a permanent replay (THI-353) - Replay every terminal session shown in a lesson code block through the engine, from the lesson's starting state, in each environment (src/test/lessonTheory.test.ts). Known gaps are a shrink-only ratchet (lessonTheoryGaps.ts, 174 entries), plus a ceiling on lessons that show bash to Windows learners (39). - Fix 13 lesson examples whose output did not match (ls -l, ls -a, grep -n, wc, chmod, chown, apt, stderr, Windows ping) and give the PATH and git init lessons a single block per environment. - ls -l size comes from the content bytes, like wc -c. - $env:X = "..." expands $env:Y inside double quotes (PowerShell). - git init prints C:/Users/user/... on Windows, like Git for Windows. - CHANGELOG and STORY: catch up #389 and #390, add this change. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 27 ++++ STORY.md | 10 ++ src/app/data/commands/git.ts | 13 +- src/app/data/curriculum.ts | 53 ++++---- src/app/data/terminalEngine.ts | 10 +- src/test/lessonTheory.test.ts | 94 ++++++++++++++ src/test/lessonTheoryGaps.ts | 224 ++++++++++++++++++++++++++++++++ src/test/terminalEngine.test.ts | 22 ++++ 8 files changed, 423 insertions(+), 30 deletions(-) create mode 100644 src/test/lessonTheory.test.ts create mode 100644 src/test/lessonTheoryGaps.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dc710a..dbfbf6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ --- +## 🐚 24 septembre 2026 — Un vrai shell, et une théorie qui dit ce que le terminal répond (THI-353) + +*PR #389 · #390 · #391 · 198/198 exercices sans ligne rouge (ordinateur et mobile) · nouveau test permanent `lessonTheory` : 754 commandes de théorie rejouées* + +La livraison précédente avait réparé les exercices. Il restait deux écarts : le terminal ne comprenait pas la syntaxe du shell, et la théorie montrait parfois une sortie que le terminal n'affiche pas. + +- **Une vraie couche shell (#389).** `;`, `&&` et `||` enchaînent les commandes. Les pipes acceptent autant d'étapes qu'on veut. Les redirections `>`, `>>`, `2>`, `2>&1`, `&>` et `<` fonctionnent pour toutes les commandes, et `/dev/null` ou `$null` avalent vraiment la sortie. `tee` écrit réellement son fichier. `grep` sans résultat fait échouer la commande qui suit un `&&`, comme dans un vrai shell. Résultat : **plus aucun exercice ne se valide avec une ligne rouge**, sur les 198 combinaisons leçon × environnement, vérifié dans un navigateur, sur ordinateur et en mobile. +- **Le terminal affiche ce qu'un vrai shell affiche (#390).** + - `pwd` donne le chemin complet, et `cd -` revient au dossier précédent. + - `export` comprend `$PATH`, et `wc` compte les vrais octets, accents compris. + - `ls` écrit un nom par ligne quand sa sortie part dans un pipe. + - `apt` refuse de s'exécuter sans `sudo`, et `sudo` demande le mot de passe une fois par session. `sudo whoami` répond `root`. + - `rm -rf /` est refusé, comme par GNU `rm`. +- **La théorie corrigée, et surveillée (#391).** + - Treize exemples montraient une sortie que le terminal n'affiche pas : un `grep -n` qui annonçait la ligne 5 au lieu de la 6, un `wc` aux mauvais comptes, un `ls -l` sans heure, un `ping` Windows au mauvais TTL. + - Deux leçons montraient à l'élève Windows une commande bash à côté de sa version PowerShell. Chaque élève ne voit plus que la sienne. + - Sous Windows, `git init` affiche désormais `C:/Users/user/…`, comme Git pour Windows. + - Un test rejoue maintenant chaque session de terminal montrée dans une leçon, depuis l'état de départ de la leçon, dans les trois environnements, et compare avec ce qu'affiche le terminal. 390 commandes correspondent déjà. + +Ce qui reste, et c'est dit : + +- **174 écarts restent listés dans le test**, et cette liste ne peut que rétrécir. La plupart viennent des leçons Git et GitHub avancées : l'état de départ de ces leçons n'a pas encore l'historique (commits, branches, conflits) que leur théorie montre. Le reste concerne des outils pas encore simulés : `gh`, les tâches en arrière-plan. +- **39 leçons montrent encore du bash à un élève Windows**, faute de variante PowerShell. Ce nombre est suivi par le même test et ne peut que baisser. +- **Un bug grave découvert en chemin, corrigé dans la livraison suivante** : `mv fichier .` et `cp fichier dossier` écrasaient le dossier de destination au lieu d'y placer le fichier. + +--- + ## 🖥️ 24 septembre 2026 — Le terminal dit enfin ce que la leçon enseigne (THI-353) *PR #385 · #386 · #387 · test permanent `lessonFidelity` · 2558 tests · exercices « validés malgré une erreur » : 44 → 3* diff --git a/STORY.md b/STORY.md index 330d480..6b09068 100644 --- a/STORY.md +++ b/STORY.md @@ -21,6 +21,16 @@ Ce projet a été construit avec l'aide de Claude — l'IA d'Anthropic, des mod --- +## Des tests qui se comparaient à eux-mêmes (24 septembre 2026) + +Après les exercices, Thierry m'a demandé de vérifier la théorie : « tester visuellement chaque exercice sur base de la théorie ». J'ai écrit un script qui rejoue chaque session de terminal affichée dans une leçon et compare avec ce que notre terminal répond. Les premiers écarts étaient attendus : un `grep -n` qui annonçait la ligne 5 d'un fichier dont le titre est à la ligne 6, un `wc` aux comptes faux. + +Le plus instructif est venu de `ls`. Envoyé dans un pipe ou dans un fichier, un vrai `ls` écrit un nom par ligne ; le nôtre les mettait tous sur une seule. Nos tests passaient pourtant, et pour une raison gênante : ils calculaient la valeur attendue en appelant le moteur lui-même. Le test demandait au terminal « combien de lignes vas-tu écrire ? », puis vérifiait que le terminal écrivait bien ce nombre. Il ne pouvait pas échouer. Un attendu doit venir de l'extérieur de ce qu'on teste : un vrai shell, une documentation, ou le texte même de la leçon. C'est pour cela que le nouveau garde-fou compare le terminal au texte des leçons, et jamais le terminal à lui-même. + +Ce même rejeu a trouvé le bug le plus grave de la journée, là où personne ne regardait. La leçon `mv` montre `mv documents/notes.txt .`, c'est-à-dire « ramène le fichier ici ». Dans notre simulateur, cette commande remplaçait le dossier personnel entier par le fichier : l'élève perdait toute son arborescence au deuxième module. `cp fichier dossier` faisait la même chose au dossier visé. Aucun exercice ne tapait cette commande, et aucun test ne l'essayait. Seule la théorie la montrait, et c'est en la rejouant qu'on l'a vue. + +--- + ## Le bug était dans la police, et 44 exercices qui mentaient poliment (23-24 septembre 2026) Le Grand Check-up de l'été avait posé un diagnostic que je n'aimais pas : pour décider qu'un exercice était réussi, Terminal Learning lisait la commande tapée — et jamais ce que le terminal répondait. J'ai rejoué la solution de chaque exercice, dans chaque environnement, et compté. Sur 198 cas, 44 se validaient alors que l'écran affichait une erreur en rouge. Un élève tapait `git status` comme on le lui demandait, lisait `fatal: not a git repository`, et recevait des félicitations juste en dessous. Un mensonge poli, mais un mensonge : l'outil disait « bravo » à quelqu'un qui venait de voir que ça ne marchait pas. diff --git a/src/app/data/commands/git.ts b/src/app/data/commands/git.ts index 9350242..464169c 100644 --- a/src/app/data/commands/git.ts +++ b/src/app/data/commands/git.ts @@ -6,6 +6,13 @@ function displayPath(cwd: string[]): string { return '/' + cwd.join('/'); } +/** Git for Windows writes `C:/Users/user/…` (drive letter, forward slashes). */ +function gitPath(cwd: string[], env: TerminalEnv): string { + if (env !== 'windows') return displayPath(cwd); + const home = cwd[0] === 'home' && cwd[1] === 'user'; + return (home ? ['C:', 'Users', 'user', ...cwd.slice(2)] : ['C:', ...cwd]).join('/'); +} + function getNode(root: DirectoryNode, path: string[]): FSNode | null { let current: FSNode = root; for (const seg of path) { @@ -21,7 +28,7 @@ function makeHash(): string { return Array.from({ length: 7 }, () => '0123456789abcdef'[Math.floor(Math.random() * 16)]).join(''); } -export function handleGit(newState: TerminalState, args: string[], _env: TerminalEnv): CommandOutput { +export function handleGit(newState: TerminalState, args: string[], env: TerminalEnv): CommandOutput { const sub = args[0]?.toLowerCase() ?? ''; const requireRepo = (): OutputLine | null => { @@ -36,7 +43,7 @@ export function handleGit(newState: TerminalState, args: string[], _env: Termina case 'init': { if (newState.git?.initialized) { return { - lines: [{ text: `Reinitialized existing Git repository in ${displayPath(newState.cwd)}/.git/`, type: 'info' }], + lines: [{ text: `Reinitialized existing Git repository in ${gitPath(newState.cwd, env)}/.git/`, type: 'info' }], newState, }; } @@ -53,7 +60,7 @@ export function handleGit(newState: TerminalState, args: string[], _env: Termina }; return { lines: [ - { text: `Initialized empty Git repository in ${displayPath(newState.cwd)}/.git/`, type: 'success' }, + { text: `Initialized empty Git repository in ${gitPath(newState.cwd, env)}/.git/`, type: 'success' }, { text: "Hint: Use 'git add ' to stage files, 'git commit -m' to record changes.", type: 'info' }, ], newState, diff --git a/src/app/data/curriculum.ts b/src/app/data/curriculum.ts index 742fe14..cfd0922 100644 --- a/src/app/data/curriculum.ts +++ b/src/app/data/curriculum.ts @@ -239,7 +239,7 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '$ ls -l\ntotal 3\ndrwxr-xr-x 2 user user 4096 Mar 30 documents\ndrwxr-xr-x 2 user user 4096 Mar 30 downloads\ndrwxr-xr-x 2 user user 4096 Mar 30 projets', + content: '$ ls -l\ntotal 3\ndrwxr-xr-x 2 user user 4096 Mar 30 10:00 documents\ndrwxr-xr-x 2 user user 4096 Mar 30 10:00 downloads\ndrwxr-xr-x 2 user user 4096 Mar 30 10:00 projets', label: 'ls -l (format long)', }, { @@ -249,7 +249,7 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '$ ls -a\n. .. .bashrc .profile documents downloads projets', + content: '$ ls -a\n. .. .bashrc .profile .zshrc documents downloads projets', label: 'ls -a (fichiers cachés)', }, { @@ -730,7 +730,7 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '$ grep -n "Section" documents/rapport.md\n5:## Section 1', + content: '$ grep -n "Section" documents/rapport.md\n6:## Section 1', label: 'grep -n (avec numéros de lignes)', }, { @@ -769,12 +769,12 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '$ wc documents/notes.txt\n 6 20 145 documents/notes.txt', + content: '$ wc documents/notes.txt\n 6 22 143 documents/notes.txt', label: 'wc complet (lignes mots octets)', }, { type: 'code', - content: '$ wc -l documents/notes.txt\n6 documents/notes.txt\n\n$ wc -w documents/notes.txt\n20 documents/notes.txt\n\n$ wc -c documents/notes.txt\n145 documents/notes.txt', + content: '$ wc -l documents/notes.txt\n6 documents/notes.txt\n\n$ wc -w documents/notes.txt\n22 documents/notes.txt\n\n$ wc -c documents/notes.txt\n143 documents/notes.txt', label: 'Options -l (lignes) -w (mots) -c (octets)', }, { @@ -820,7 +820,7 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '$ ls -l\n-rw-r--r-- 1 user user 145 notes.txt\ndrwxr-xr-x 2 user user 4096 documents', + content: '$ ls -l\ntotal 3\ndrwxr-xr-x 2 user user 4096 Mar 30 10:00 documents\ndrwxr-xr-x 2 user user 4096 Mar 30 10:00 downloads\ndrwxr-xr-x 2 user user 4096 Mar 30 10:00 projets\n\n$ ls -l documents/notes.txt\n-rw-r--r-- 1 user user 143 Mar 30 10:00 documents/notes.txt', label: 'Affichage des permissions', }, { @@ -868,7 +868,7 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '$ chmod 755 projets/script.sh\n$ ls -l projets/script.sh\n-rwxr-xr-x 1 user user 56 script.sh', + content: '$ chmod 755 projets/script.sh\n$ ls -l projets/script.sh\n-rwxr-xr-x 1 user user 68 Mar 30 10:00 projets/script.sh', label: 'Notation octale (Linux/macOS)', contentByEnv: { windows: 'PS> icacls documents\\notes.txt\nnotes.txt BUILTIN\\Administrators:(I)(F)\n NT AUTHORITY\\SYSTEM:(I)(F)\n user:(I)(M)\n\nPS> Set-ExecutionPolicy RemoteSigned\n# Autorise les scripts locaux non signés', @@ -936,7 +936,7 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '$ ls -l documents/notes.txt\n-rw-r--r-- 1 user user 145 notes.txt\n# ↑ ↑\n# proprio groupe\n\n$ sudo chown alice notes.txt\n$ sudo chown alice:devs notes.txt\n$ sudo chown :devs notes.txt # changer seulement le groupe', + content: '$ ls -l documents/notes.txt\n-rw-r--r-- 1 user user 143 Mar 30 10:00 documents/notes.txt\n# ↑ ↑\n# proprio groupe\n\n$ sudo chown alice notes.txt\n$ sudo chown alice:devs notes.txt\n$ sudo chown :devs notes.txt # changer seulement le groupe', label: 'chown — changer propriétaire (Linux/macOS)', contentByEnv: { windows: 'PS> Get-Acl documents\\notes.txt | Select-Object Owner\nOwner\n-----\nDESKTOP-ABC\\user\n\nPS> takeown /f documents\\notes.txt\n# Prendre possession du fichier\n\nPS> icacls documents\\notes.txt /setowner "BUILTIN\\Administrators"', @@ -1000,7 +1000,7 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '$ sudo apt update\n[sudo] password for user: ****\nHit:1 http://archive.ubuntu.com/ubuntu jammy InRelease\n...\n\n$ sudo -i # ouvrir un shell root\n$ sudo -l # lister les commandes autorisées\n$ sudo !! # relancer la dernière commande en sudo', + content: '$ sudo apt update\n[sudo] password for user: ****\nHit:1 http://archive.ubuntu.com/ubuntu noble InRelease\n...\n\n$ sudo -i # ouvrir un shell root\n$ sudo -l # lister les commandes autorisées\n$ sudo !! # relancer la dernière commande en sudo', label: 'sudo — exemples courants (Linux)', contentByEnv: { macos: '$ sudo brew services restart nginx\n[sudo] password for user: ****\n...\n\n$ sudo -i # ouvrir un shell root\n$ sudo dscacheutil -flushcache # vider le cache DNS\n$ sudo -l # lister les commandes autorisées', @@ -1456,7 +1456,7 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '$ ls fichier-inexistant\nls: cannot access \'fichier-inexistant\': No such file or directory\n\n$ ls fichier-inexistant 2> erreurs.log\n# L\'erreur va dans le fichier, rien ne s\'affiche\n\n$ ls documents/ 2> erreurs.log\ndocuments/notes.txt documents/rapport.md\n# stdout s\'affiche, stderr va dans le fichier', + content: '$ ls fichier-inexistant\nls: cannot access \'fichier-inexistant\': No such file or directory\n\n$ ls fichier-inexistant 2> erreurs.log\n# L\'erreur va dans le fichier, rien ne s\'affiche\n\n$ ls documents/ 2> erreurs.log\nnotes.txt rapport.md\n# stdout s\'affiche, stderr va dans le fichier', label: '2> — rediriger stderr seul', contentByEnv: { windows: 'PS> Get-Item fichier-inexistant\nGet-Item : Cannot find path...\n\nPS> Get-Item fichier-inexistant 2> erreurs.txt\n# L\'erreur va dans le fichier\n\nPS> Get-Item documents 2> erreurs.txt\ndirectory: C:\\Users\\user\\documents', @@ -1626,22 +1626,27 @@ export const curriculum: Module[] = [ { type: 'code', content: '$ echo $PATH\n/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', - label: 'Afficher le PATH (Linux/macOS)', - }, - { - type: 'code', - content: 'PS> echo $env:PATH\nC:\\Windows\\System32;C:\\Windows;C:\\Program Files\\Git\\bin', - label: 'Afficher le PATH (Windows)', + label: 'Afficher le PATH', + contentByEnv: { + windows: 'PS> echo $env:PATH\nC:\\Windows\\System32;C:\\Windows;C:\\Program Files\\Git\\bin', + }, }, { type: 'code', - content: '$ export PATH=$PATH:/opt/myapp/bin\n$ echo $PATH\n/usr/local/bin:/usr/bin:/bin:/opt/myapp/bin', + content: '$ export PATH=$PATH:/opt/myapp/bin\n$ echo $PATH\n/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/myapp/bin', label: 'Ajouter un répertoire au PATH', + contentByEnv: { + windows: 'PS> $env:PATH = "$env:PATH;C:\\outils"\nPS> $env:PATH\nC:\\Windows\\System32;C:\\Windows;C:\\Program Files\\Git\\bin;C:\\outils', + }, }, { type: 'warning', content: 'Modifier le PATH avec `export` dans le terminal est temporaire (jusqu\'à la fermeture). Pour le rendre permanent, ajoutez-le à votre fichier de config shell (`.bashrc`, `.zshrc`).', + contentByEnv: { + windows: + 'Modifier `$env:PATH` dans PowerShell est temporaire (jusqu\'à la fermeture de la fenêtre). Pour le rendre permanent, ajoutez la même ligne à votre profil PowerShell (le fichier `$PROFILE`).', + }, }, ], exercise: { @@ -1866,7 +1871,7 @@ export const curriculum: Module[] = [ windows: 'Exemple (Windows)', }, contentByEnv: { - windows: 'PS> ping google.com\nPinging google.com [142.250.74.46] with 32 bytes of data:\nReply from 142.250.74.46: bytes=32 time=12ms TTL=54\nReply from 142.250.74.46: bytes=32 time=11ms TTL=54\n\nPing statistics for 142.250.74.46:\n Packets: Sent = 4, Received = 4, Lost = 0 (0% loss)', + windows: 'PS> ping google.com\nPinging google.com [142.250.74.46] with 32 bytes of data:\nReply from 142.250.74.46: bytes=32 time=12ms TTL=117\nReply from 142.250.74.46: bytes=32 time=11ms TTL=117\n\nPing statistics for 142.250.74.46:\n Packets: Sent = 4, Received = 4, Lost = 0 (0% loss)', }, }, { @@ -2180,13 +2185,11 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '# Créer un nouveau dépôt dans le répertoire courant\n$ git init\nInitialized empty Git repository in /home/user/mon-projet/.git/\n\n# Créer un dépôt avec un nom de répertoire\n$ git init mon-projet\nInitialized empty Git repository in /home/user/mon-projet/.git/', - label: 'git init (Linux/macOS)', - }, - { - type: 'code', - content: '# PowerShell — même commande, multiplateforme\nPS> git init\nInitialized empty Git repository in C:\\Users\\user\\mon-projet\\.git\\\n\nPS> git init mon-projet', - label: 'git init (Windows PowerShell)', + content: '# Créer un dépôt dans un nouveau dossier\n$ git init mon-projet\nInitialized empty Git repository in /home/user/mon-projet/.git/\n$ cd mon-projet\n\n# Relancer git init dans un dépôt existant ne casse rien\n$ git init\nReinitialized existing Git repository in /home/user/mon-projet/.git/', + label: 'git init', + contentByEnv: { + windows: '# Même commande sous PowerShell (Git écrit les chemins avec des /)\nPS> git init mon-projet\nInitialized empty Git repository in C:/Users/user/mon-projet/.git/\nPS> cd mon-projet\n\nPS> git init\nReinitialized existing Git repository in C:/Users/user/mon-projet/.git/', + }, }, { type: 'info', diff --git a/src/app/data/terminalEngine.ts b/src/app/data/terminalEngine.ts index 6cd5281..3e8a07e 100644 --- a/src/app/data/terminalEngine.ts +++ b/src/app/data/terminalEngine.ts @@ -295,7 +295,8 @@ function formatLongEntry(name: string, node: FSNode): string { const perm = node.permissions; const owner = node.owner; const group = node.group; - const size = node.type === 'file' ? String(node.size).padStart(6) : ' 4096'; + // Bytes on disk, computed from the content (the stored `size` is not kept up to date on edits). + const size = node.type === 'file' ? String(textCounts(node.content).bytes).padStart(6) : ' 4096'; const date = 'Mar 30 10:00'; const links = node.type === 'directory' ? ' 2' : ' 1'; return `${perm}${links} ${owner} ${group} ${size} ${date} ${name}`; @@ -1851,7 +1852,12 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com const psEnvSet = trimmed.match(/^\$env:([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/); if (psEnvSet) { const [, varName, rawValue] = psEnvSet; - const varValue = rawValue.replace(/^["']|["']$/g, ''); + // Double quotes expand `$env:X` (`"$env:PATH;C:\outils"`); single quotes keep it literal. + const shown = varsForEnv(newState.envVars, env); + const unquoted = rawValue.replace(/^["']|["']$/g, ''); + const varValue = rawValue.startsWith("'") + ? unquoted + : unquoted.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/g, (_, ref: string) => shown[ref] ?? ''); return { lines: [{ text: `$env:${varName} défini à "${varValue}"`, type: 'success' }], newState: { ...newState, envVars: { ...newState.envVars, [varName]: varValue } }, diff --git a/src/test/lessonTheory.test.ts b/src/test/lessonTheory.test.ts new file mode 100644 index 0000000..1c39e20 --- /dev/null +++ b/src/test/lessonTheory.test.ts @@ -0,0 +1,94 @@ +/** + * Theory ↔ terminal (THI-353). Every terminal session a lesson shows in a code + * block is replayed in the engine, from the lesson's own starting state + * (Exercise.setup), in the environment the learner picked. The output the + * lesson displays must be what the terminal prints. + * + * Gaps that remain are listed in lessonTheoryGaps.ts — a ratchet: a new gap + * fails this suite, and a listed gap that gets fixed fails it too until its + * entry is removed, so the list can only shrink. + */ +import { describe, it, expect } from 'vitest'; +import { curriculum, type EnvId } from '../app/data/curriculum'; +import { createInitialState, processCommand, type TerminalState } from '../app/data/terminalEngine'; +import { KNOWN_THEORY_GAPS, BASH_SHOWN_ON_WINDOWS_MAX } from './lessonTheoryGaps'; + +const ENVS: EnvId[] = ['linux', 'macos', 'windows']; +/** `user@host:~$ cmd`, `$ cmd`, `% cmd`, `PS> cmd`, `PS C:\…> cmd`. */ +const PROMPT = /^(?:[\w.-]+@[\w.-]+:[^$]*\$|\$|%|PS(?:\s[^>]*)?>)\s+(.+)$/; +/** The lesson shows an error on purpose (and so should the terminal). */ +const SHOWS_ERROR = /error|erreur|introuvable|not found|No such|cannot|denied|refus|fatal/i; +const norm = (s: string) => s.replace(/\s+/g, ' ').trim(); + +interface Row { key: string; ok: boolean; bashShownOnWindows: boolean } + +function replayTheory(): Row[] { + const rows: Row[] = []; + for (const m of curriculum) { + for (const l of m.lessons) { + l.blocks.forEach((b, bi) => { + if (b.type !== 'code') return; + for (const env of ENVS) { + const text = b.contentByEnv?.[env] ?? b.content; + const lines = text.split('\n'); + if (!lines.some((x) => PROMPT.test(x))) continue; // a file, not a terminal session + const bashShownOnWindows = env === 'windows' && !b.contentByEnv?.windows && /^\$\s/m.test(b.content); + let state: TerminalState = l.exercise?.setup ? l.exercise.setup.apply(createInitialState()) : createInitialState(); + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(PROMPT); + if (!match) continue; + const cmd = match[1].replace(/\s+#.*$/, '').trim(); + const expected: string[] = []; + for (let j = i + 1; j < lines.length && !PROMPT.test(lines[j]); j++) { + const t = lines[j].trim(); + if (t && !t.startsWith('#') && t !== '...' && t !== '…') expected.push(t); + } + const r = processCommand(state, cmd, env); + state = r.newState; + const errors = r.lines.filter((x) => x.type === 'error').map((x) => x.text); + const printed = r.lines + .filter((x) => x.type !== 'error') + .flatMap((x) => x.text.split('\n')) + .map((s) => s.trim()) + .filter(Boolean); + let ok: boolean; + if (errors.length && !expected.some((e) => SHOWS_ERROR.test(e))) ok = false; + else if (!expected.length) ok = true; + else { + const actual = printed.concat(errors).map(norm); + const wanted = expected.map(norm); + ok = actual.join('\n') === wanted.join('\n') + || wanted.every((e) => actual.some((a) => a.includes(e) || (e.includes(a) && a.length > 3))); + } + rows.push({ key: `${m.id}/${l.id} b${bi} [${env}] ${cmd}`, ok, bashShownOnWindows }); + } + } + }); + } + } + return rows; +} + +const rows = replayTheory(); +const failing = new Set(rows.filter((r) => !r.ok && !r.bashShownOnWindows).map((r) => r.key)); + +describe('lesson theory ↔ terminal', () => { + it('replays a meaningful number of theory commands', () => { + expect(rows.length).toBeGreaterThan(700); + }); + + it('prints what each lesson shows — no new gap', () => { + const unexpected = [...failing].filter((k) => !KNOWN_THEORY_GAPS.has(k)); + expect(unexpected, 'Fix the engine or the lesson; do not add to lessonTheoryGaps.ts unless the fix is planned').toEqual([]); + }); + + it('the list of known gaps only shrinks', () => { + const fixed = [...KNOWN_THEORY_GAPS].filter((k) => !failing.has(k)); + expect(fixed, 'These now match: remove them from KNOWN_THEORY_GAPS').toEqual([]); + }); + + it('bash examples shown to Windows learners never increase', () => { + const lessons = new Set(rows.filter((r) => r.bashShownOnWindows).map((r) => r.key.split(' ')[0])); + expect(lessons.size, `Now ${lessons.size}: lower BASH_SHOWN_ON_WINDOWS_MAX to it`).toBe(BASH_SHOWN_ON_WINDOWS_MAX); + }); +}); diff --git a/src/test/lessonTheoryGaps.ts b/src/test/lessonTheoryGaps.ts new file mode 100644 index 0000000..d7dba99 --- /dev/null +++ b/src/test/lessonTheoryGaps.ts @@ -0,0 +1,224 @@ +/** + * Known gaps between what a lesson shows and what the terminal prints (THI-353). + * Generated from the replay in lessonTheory.test.ts; the list may only shrink. + * + * Remaining causes, by family (each has its own planned fix): + * - Git history: theory shows commits, branches, merges and conflicts the lesson's + * starting state does not contain (richer Git setups). + * - gh (GitHub CLI), background jobs (`&`, `jobs`, `fg`, `Start-Job`), `code`, + * `ssh-copy-id`, `git check-ignore`, `git mergetool`, `git remote rename`: not simulated yet. + * - PowerShell objects: property selection (`Select-Object Owner`), Get-Process columns. + * - Illustrative output in the lesson (placeholders like "[liste des fichiers]", + * abbreviated help, an interactive prompt, a clock time): not meant to match. + * + * Key: `/ b [] `. + */ +export const KNOWN_THEORY_GAPS = new Set([ + // navigation/orientation + "navigation/orientation b2 [linux] help", + "navigation/orientation b2 [macos] help", + "navigation/orientation b2 [windows] help", + // fichiers/mv + "fichiers/mv b1 [linux] ls", + "fichiers/mv b1 [macos] ls", + "fichiers/mv b2 [linux] mv notes.txt mes-notes.txt", + "fichiers/mv b2 [linux] ls", + "fichiers/mv b2 [macos] mv notes.txt mes-notes.txt", + "fichiers/mv b2 [macos] ls", + // fichiers/rm + "fichiers/rm b1 [linux] rm fichier.txt", + "fichiers/rm b1 [macos] rm fichier.txt", + "fichiers/rm b2 [linux] rm -r dossier", + "fichiers/rm b2 [macos] rm -r dossier", + // lecture/cat + "lecture/cat b2 [linux] cat fichier1.txt fichier2.txt", + "lecture/cat b2 [macos] cat fichier1.txt fichier2.txt", + // permissions/chmod + "permissions/chmod b2 [linux] chmod +x script.sh", + "permissions/chmod b2 [linux] chmod u+x script.sh", + "permissions/chmod b2 [linux] chmod o-r fichier.txt", + "permissions/chmod b2 [linux] chmod 644 fichier.txt", + "permissions/chmod b2 [macos] chmod +x script.sh", + "permissions/chmod b2 [macos] chmod u+x script.sh", + "permissions/chmod b2 [macos] chmod o-r fichier.txt", + "permissions/chmod b2 [macos] chmod 644 fichier.txt", + // permissions/chown + "permissions/chown b1 [windows] Get-Acl documents\\notes.txt | Select-Object Owner", + // permissions/sudo + "permissions/sudo b1 [macos] sudo dscacheutil -flushcache", + // processus/kill + "processus/kill b1 [linux] ps", + "processus/kill b1 [linux] kill 9999", + "processus/kill b1 [macos] ps", + "processus/kill b1 [macos] kill 9999", + "processus/kill b1 [windows] Get-Process", + // processus/top + "processus/top b1 [linux] top", + "processus/top b1 [macos] top", + "processus/top b1 [windows] Get-Process | Sort-Object CPU -Descending | Select-Object -First 10 Name,CPU,WorkingSet", + // processus/background + "processus/background b1 [linux] long-task &", + "processus/background b1 [linux] jobs", + "processus/background b1 [linux] fg %1", + "processus/background b1 [linux] bg %1", + "processus/background b1 [macos] long-task &", + "processus/background b1 [macos] jobs", + "processus/background b1 [macos] fg %1", + "processus/background b1 [macos] bg %1", + "processus/background b1 [windows] $job = Start-Job -ScriptBlock { Start-Sleep 30; \"Terminé!\" }", + "processus/background b1 [windows] Get-Job", + "processus/background b1 [windows] Remove-Job -Id 1", + "processus/background b2 [linux] npm run dev &", + "processus/background b2 [linux] # Vous pouvez continuer à travailler ici", + "processus/background b2 [linux] jobs", + "processus/background b2 [macos] npm run dev &", + "processus/background b2 [macos] # Vous pouvez continuer à travailler ici", + "processus/background b2 [macos] jobs", + "processus/background b2 [windows] $devServer = Start-Job -ScriptBlock { npm run dev }", + "processus/background b2 [windows] # Continuez à travailler dans ce terminal", + // redirection/redirection-sortie + "redirection/redirection-sortie b4 [linux] cat liste-fichiers.txt", + "redirection/redirection-sortie b4 [macos] cat liste-fichiers.txt", + // redirection/pipes + "redirection/pipes b3 [linux] ls -la | grep \"^d\"", + "redirection/pipes b3 [macos] ls -la | grep \"^d\"", + // redirection/stderr + "redirection/stderr b1 [windows] Get-Item fichier-inexistant", + // redirection/tee + "redirection/tee b1 [linux] ls -la | tee liste.txt", + "redirection/tee b1 [macos] ls -la | tee liste.txt", + "redirection/tee b1 [windows] Get-ChildItem | Tee-Object -FilePath liste.txt", + // variables/scripts + "variables/scripts b2 [linux] ./mon-script.sh", + "variables/scripts b2 [macos] ./mon-script.sh", + // reseau/curl + "reseau/curl b1 [linux] curl -X POST https://api.example.com/data \\", + "reseau/curl b1 [macos] curl -X POST https://api.example.com/data \\", + // reseau/ssh + "reseau/ssh b2 [linux] ssh-keygen -t ed25519 -C \"mon@email.com\"", + "reseau/ssh b2 [linux] ssh-copy-id user@serveur.example.com", + "reseau/ssh b2 [macos] ssh-keygen -t ed25519 -C \"mon@email.com\"", + "reseau/ssh b2 [macos] ssh-copy-id user@serveur.example.com", + // git/git-config + "git/git-config b1 [linux] git config --list", + "git/git-config b1 [macos] git config --list", + // git/git-add-commit + "git/git-add-commit b1 [linux] git status", + "git/git-add-commit b1 [macos] git status", + "git/git-add-commit b2 [linux] git commit -m \"feat: ajouter la page d'accueil\"", + "git/git-add-commit b2 [macos] git commit -m \"feat: ajouter la page d'accueil\"", + // git/git-status-log + "git/git-status-log b1 [linux] git status", + "git/git-status-log b1 [macos] git status", + "git/git-status-log b2 [linux] git log", + "git/git-status-log b2 [linux] git log --oneline", + "git/git-status-log b2 [macos] git log", + "git/git-status-log b2 [macos] git log --oneline", + // git/git-diff-gitignore + "git/git-diff-gitignore b1 [linux] git diff", + "git/git-diff-gitignore b1 [macos] git diff", + "git/git-diff-gitignore b3 [linux] git check-ignore -v .env", + "git/git-diff-gitignore b3 [macos] git check-ignore -v .env", + // git/git-branch + "git/git-branch b1 [linux] git branch", + "git/git-branch b1 [linux] git checkout -b feature/panier", + "git/git-branch b1 [linux] git switch -c feature/panier", + "git/git-branch b1 [macos] git branch", + "git/git-branch b1 [macos] git checkout -b feature/panier", + "git/git-branch b1 [macos] git switch -c feature/panier", + "git/git-branch b2 [linux] git branch -d feature/login", + "git/git-branch b2 [linux] git branch -D feature/experimental", + "git/git-branch b2 [linux] git branch -a", + "git/git-branch b2 [macos] git branch -d feature/login", + "git/git-branch b2 [macos] git branch -D feature/experimental", + "git/git-branch b2 [macos] git branch -a", + // git/git-merge + "git/git-merge b1 [linux] git merge feature/panier", + "git/git-merge b1 [macos] git merge feature/panier", + "git/git-merge b2 [linux] git merge feature/login", + "git/git-merge b2 [macos] git merge feature/login", + // github-collaboration/git-remote + "github-collaboration/git-remote b1 [linux] git remote rename origin upstream", + "github-collaboration/git-remote b1 [macos] git remote rename origin upstream", + "github-collaboration/git-remote b2 [linux] git remote set-url origin git@github.com:user/repo.git", + "github-collaboration/git-remote b2 [macos] git remote set-url origin git@github.com:user/repo.git", + // github-collaboration/git-push-pull + "github-collaboration/git-push-pull b1 [linux] git push -u origin main", + "github-collaboration/git-push-pull b1 [macos] git push -u origin main", + "github-collaboration/git-push-pull b2 [linux] git pull", + "github-collaboration/git-push-pull b2 [macos] git pull", + // github-collaboration/git-fetch-clone + "github-collaboration/git-fetch-clone b1 [linux] git clone https://github.com/org/projet.git", + "github-collaboration/git-fetch-clone b1 [macos] git clone https://github.com/org/projet.git", + "github-collaboration/git-fetch-clone b2 [linux] git fetch origin", + "github-collaboration/git-fetch-clone b2 [linux] git fetch && git log HEAD..origin/main --oneline", + "github-collaboration/git-fetch-clone b2 [linux] git fetch --all", + "github-collaboration/git-fetch-clone b2 [linux] git diff main origin/main", + "github-collaboration/git-fetch-clone b2 [macos] git fetch origin", + "github-collaboration/git-fetch-clone b2 [macos] git fetch && git log HEAD..origin/main --oneline", + "github-collaboration/git-fetch-clone b2 [macos] git fetch --all", + "github-collaboration/git-fetch-clone b2 [macos] git diff main origin/main", + "github-collaboration/git-fetch-clone b3 [linux] git merge upstream/main", + "github-collaboration/git-fetch-clone b3 [macos] git merge upstream/main", + // github-collaboration/pull-requests + "github-collaboration/pull-requests b1 [linux] git push -u origin feature/THI-28-git-modules", + "github-collaboration/pull-requests b1 [linux] gh pr create --title \"feat(curriculum): add git module\" --body \"...\"", + "github-collaboration/pull-requests b1 [macos] git push -u origin feature/THI-28-git-modules", + "github-collaboration/pull-requests b1 [macos] gh pr create --title \"feat(curriculum): add git module\" --body \"...\"", + "github-collaboration/pull-requests b2 [linux] gh pr list", + "github-collaboration/pull-requests b2 [linux] gh pr view 42", + "github-collaboration/pull-requests b2 [linux] gh pr checkout 42", + "github-collaboration/pull-requests b2 [linux] gh pr review 42 --approve", + "github-collaboration/pull-requests b2 [linux] gh pr merge 42 --squash", + "github-collaboration/pull-requests b2 [macos] gh pr list", + "github-collaboration/pull-requests b2 [macos] gh pr view 42", + "github-collaboration/pull-requests b2 [macos] gh pr checkout 42", + "github-collaboration/pull-requests b2 [macos] gh pr review 42 --approve", + "github-collaboration/pull-requests b2 [macos] gh pr merge 42 --squash", + // github-collaboration/merge-strategies + "github-collaboration/merge-strategies b1 [linux] git merge --no-ff feature/panier", + "github-collaboration/merge-strategies b1 [linux] git log --oneline --graph", + "github-collaboration/merge-strategies b1 [macos] git merge --no-ff feature/panier", + "github-collaboration/merge-strategies b1 [macos] git log --oneline --graph", + "github-collaboration/merge-strategies b2 [linux] git merge --squash feature/panier", + "github-collaboration/merge-strategies b2 [linux] git log --oneline", + "github-collaboration/merge-strategies b2 [linux] gh pr merge 42 --squash --delete-branch", + "github-collaboration/merge-strategies b2 [macos] git merge --squash feature/panier", + "github-collaboration/merge-strategies b2 [macos] git log --oneline", + "github-collaboration/merge-strategies b2 [macos] gh pr merge 42 --squash --delete-branch", + "github-collaboration/merge-strategies b3 [linux] git checkout feature/panier", + "github-collaboration/merge-strategies b3 [linux] git merge feature/panier", + "github-collaboration/merge-strategies b3 [linux] git log --oneline", + "github-collaboration/merge-strategies b3 [linux] gh pr merge 42 --rebase --delete-branch", + "github-collaboration/merge-strategies b3 [macos] git checkout feature/panier", + "github-collaboration/merge-strategies b3 [macos] git merge feature/panier", + "github-collaboration/merge-strategies b3 [macos] git log --oneline", + "github-collaboration/merge-strategies b3 [macos] gh pr merge 42 --rebase --delete-branch", + // github-collaboration/conflicts + "github-collaboration/conflicts b1 [linux] git merge feature/login", + "github-collaboration/conflicts b1 [linux] git status", + "github-collaboration/conflicts b1 [macos] git merge feature/login", + "github-collaboration/conflicts b1 [macos] git status", + "github-collaboration/conflicts b2 [linux] code index.html", + "github-collaboration/conflicts b2 [macos] code index.html", + "github-collaboration/conflicts b3 [linux] git mergetool", + "github-collaboration/conflicts b3 [linux] git merge --abort", + "github-collaboration/conflicts b3 [linux] git pull --rebase origin main", + "github-collaboration/conflicts b3 [macos] git mergetool", + "github-collaboration/conflicts b3 [macos] git merge --abort", + "github-collaboration/conflicts b3 [macos] git pull --rebase origin main", + // github-collaboration/github-actions + "github-collaboration/github-actions b3 [linux] gh workflow list", + "github-collaboration/github-actions b3 [linux] gh run list", + "github-collaboration/github-actions b3 [linux] gh run view 12345", + "github-collaboration/github-actions b3 [linux] gh workflow run ci.yml", + "github-collaboration/github-actions b3 [linux] gh run download 12345", + "github-collaboration/github-actions b3 [macos] gh workflow list", + "github-collaboration/github-actions b3 [macos] gh run list", + "github-collaboration/github-actions b3 [macos] gh run view 12345", + "github-collaboration/github-actions b3 [macos] gh workflow run ci.yml", + "github-collaboration/github-actions b3 [macos] gh run download 12345", +]); + +/** Lessons whose code blocks show bash to a Windows learner (no Windows variant). */ +export const BASH_SHOWN_ON_WINDOWS_MAX = 39; diff --git a/src/test/terminalEngine.test.ts b/src/test/terminalEngine.test.ts index c90db85..f304665 100644 --- a/src/test/terminalEngine.test.ts +++ b/src/test/terminalEngine.test.ts @@ -2538,6 +2538,18 @@ describe('theory ↔ terminal: engine fidelity', () => { expect(out(t, '$env:PATH', 'windows')).toBe('C:\\outils'); }); + it('Git for Windows prints C:/ paths with forward slashes', () => { + const r = processCommand(createInitialState(), 'git init mon-projet', 'windows'); + expect(r.lines[0].text).toBe('Initialized empty Git repository in C:/Users/user/mon-projet/.git/'); + const again = processCommand(build('windows', 'git init mon-projet', 'cd mon-projet'), 'git init', 'windows'); + expect(again.lines[0].text).toBe('Reinitialized existing Git repository in C:/Users/user/mon-projet/.git/'); + }); + + it('PowerShell $env:X = "..." expands $env: variables in its value', () => { + const s = build('windows', '$env:PATH = "$env:PATH;C:\\outils"'); + expect(out(s, '$env:PATH', 'windows')).toBe('C:\\Windows\\System32;C:\\Windows;C:\\Program Files\\Git\\bin;C:\\outils'); + }); + it('git init creates the directory and the repository there', () => { const r = processCommand(createInitialState(), 'git init mon-projet', 'linux'); expect(r.lines[0].text).toBe('Initialized empty Git repository in /home/user/mon-projet/.git/'); @@ -2602,6 +2614,16 @@ describe('theory ↔ terminal: engine fidelity', () => { expect(out(s, 'Get-Locaton', 'windows')).toMatch(/^Get-Locaton: /); }); + it('ls -l shows the size in bytes, the same number as wc -c, and follows edits', () => { + const s = createInitialState(); + expect(out(s, 'ls -l documents/notes.txt')).toContain(' 143 '); + // Real: printf '#!/bin/bash\necho "Bonjour le monde !"\necho "Ce script fonctionne !"\n' | wc -c -> 68 + expect(out(s, 'ls -l projets/script.sh')).toContain(' 68 '); + const e = build('linux', 'echo abc > f.txt'); + expect(out(e, 'ls -l f.txt')).toContain(' 4 '); + expect(out(processCommand(e, 'echo abcdef > f.txt', 'linux').newState, 'ls -l f.txt')).toContain(' 7 '); + }); + it('sudo asks for the password the first time only, like its credential cache', () => { const first = processCommand(createInitialState(), 'sudo whoami', 'linux'); expect(first.lines[0]).toEqual({ text: '[sudo] password for user: ****', type: 'info' });