diff --git a/src/app/components/LessonPage.tsx b/src/app/components/LessonPage.tsx index 7a26bb8..8c7544a 100644 --- a/src/app/components/LessonPage.tsx +++ b/src/app/components/LessonPage.tsx @@ -13,8 +13,8 @@ import { useAuth } from '../context/AuthContext'; import { useEnvironment } from '../context/EnvironmentContext'; import { useLessonSEO } from '../hooks/useLessonSEO'; import { toUnixUsername } from '../../lib/username'; -import { renderInlineMarkdown } from '../../lib/renderInlineMarkdown'; -import { TerminalState } from '../data/terminalEngine'; +import { renderInlineMarkdown, stripInlineMarkdown } from '../../lib/renderInlineMarkdown'; +import { TerminalState, createInitialState } from '../data/terminalEngine'; import { TerminalEmulator } from './TerminalEmulator'; import { Button } from './ui/button'; import { AiTutorPanel } from './ai/AiTutorPanel'; @@ -212,11 +212,18 @@ function LessonContent({ mod, lesson, moduleId, lessonId }: { const effectiveInstruction = lesson.exercise?.instructionByEnv?.[selectedEnv] ?? lesson.exercise?.instruction ?? ''; + const setup = lesson.exercise?.setup; + const setupNote = setup ? [stripInlineMarkdown(setup.note)] : []; const welcomeMessage = lesson.exercise ? exerciseCompleted - ? [`📚 ${lesson.title}`, ``, `✓ Exercice déjà complété — « Suivant » pour continuer, ou pratique librement ci-dessous.`, ``] - : [`📚 ${lesson.title}`, ``, `Exercice : ${effectiveInstruction}`, ``] + ? [`📚 ${lesson.title}`, ``, ...setupNote, `✓ Exercice déjà complété — « Suivant » pour continuer, ou pratique librement ci-dessous.`, ``] + : [`📚 ${lesson.title}`, ``, ...setupNote, `Exercice : ${stripInlineMarkdown(effectiveInstruction)}`, ``] : [`📚 ${lesson.title}`, ``, `Terminal libre — pratiquez les commandes ci-dessous.`, ``]; + // Read once per terminal mount (lesson change or « Réinitialiser » remount). + const buildInitialState = useCallback( + () => (setup ? setup.apply(createInitialState()) : createInitialState()), + [setup], + ); return (
@@ -297,10 +304,10 @@ function LessonContent({ mod, lesson, moduleId, lessonId }: {
{exerciseMessage ? ( -

{exerciseMessage}

+

{renderInlineMarkdown(exerciseMessage)}

) : (

- {lesson.exercise.instructionByEnv?.[selectedEnv] ?? lesson.exercise.instruction} + {renderInlineMarkdown(effectiveInstruction)}

)} @@ -331,7 +338,7 @@ function LessonContent({ mod, lesson, moduleId, lessonId }: { aria-label="Indice" className="mt-2 text-amber-400 text-xs font-mono bg-amber-500/5 border border-amber-500/20 rounded px-3 py-2" > - đź’ˇ {lesson.exercise.hintByEnv?.[selectedEnv] ?? lesson.exercise.hint} + đź’ˇ {renderInlineMarkdown(lesson.exercise.hintByEnv?.[selectedEnv] ?? lesson.exercise.hint)}

)} @@ -382,6 +389,7 @@ function LessonContent({ mod, lesson, moduleId, lessonId }: { className="flex-1 min-h-0" username={terminalUsername} environment={selectedEnv} + initialState={buildInitialState} /> diff --git a/src/app/components/TerminalEmulator.tsx b/src/app/components/TerminalEmulator.tsx index 83ce03d..fc692a7 100644 --- a/src/app/components/TerminalEmulator.tsx +++ b/src/app/components/TerminalEmulator.tsx @@ -104,8 +104,17 @@ interface TerminalEmulatorProps { username?: string; /** Active environment — controls prompt style and display. Defaults to 'linux'. */ environment?: SelectedEnvironment; + /** Builds the state the terminal starts from (lesson setup). Read once, on mount. */ + initialState?: () => TerminalState; } +/** + * Output keeps its spacing: `git branch` indents non-current branches, `git + * status` uses tabs, `ps` aligns columns. HTML collapses all of that by + * default. Long unbroken tokens (URLs, paths) still wrap instead of overflowing. + */ +const OUTPUT_TEXT = 'whitespace-pre-wrap break-words [word-break:break-word]'; + let lineCounter = 0; const nextId = () => ++lineCounter; @@ -137,8 +146,8 @@ function useCoarsePointer(): boolean { return coarse; } -export function TerminalEmulator({ onCommand, welcomeMessage, className = '', username, environment = 'linux' }: TerminalEmulatorProps) { - const [termState, setTermState] = useState(createInitialState); +export function TerminalEmulator({ onCommand, welcomeMessage, className = '', username, environment = 'linux', initialState }: TerminalEmulatorProps) { + const [termState, setTermState] = useState(initialState ?? createInitialState); const [lines, setLines] = useState(() => { const welcome = welcomeMessage ?? ENV_MOTD[environment]; return welcome.map((text) => ({ id: nextId(), type: 'info' as const, text })); @@ -346,13 +355,13 @@ export function TerminalEmulator({ onCommand, welcomeMessage, className = '', us {line.text} ) : line.type === 'error' ? ( -
{line.text}
+
{line.text}
) : line.type === 'success' ? ( -
{line.text}
+
{line.text}
) : line.type === 'info' ? ( -
{line.text}
+
{line.text}
) : ( -
{line.text}
+
{line.text}
)} ))} diff --git a/src/app/data/curriculum.ts b/src/app/data/curriculum.ts index a5bd021..6e74544 100644 --- a/src/app/data/curriculum.ts +++ b/src/app/data/curriculum.ts @@ -13,6 +13,10 @@ import { validateAiHelpContext, validateAiHelpValidate, validateAiHelpDebug, validateAiHelpSecurity, validateAiHelpClaudeCli, validateAiHelpCareers, validateAiHelpSenior, validateAiHelpWorkflow, } from './validators'; +import { + gitRepoEmpty, gitRepoWithCommit, gitRepoWithBranch, gitRepoWithRemote, sshDirectory, + type LessonSetup, +} from './lessonSetup'; export type BlockType = 'text' | 'code' | 'tip' | 'warning' | 'info'; export interface ContentBlock { @@ -37,6 +41,11 @@ export interface Exercise { /** 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). + * Absent = the default filesystem. See lessonSetup.ts. + */ + setup?: LessonSetup; } export interface Lesson { @@ -1092,6 +1101,7 @@ export const curriculum: Module[] = [ windows: 'Tapez: Get-Acl $HOME | Format-List', }, validate: validateSecurityPermissions, + setup: sshDirectory, successMessage: 'Excellent ! Vous intégrez maintenant la sécurité dans votre gestion de fichiers.', }, }, @@ -1775,7 +1785,7 @@ export const curriculum: Module[] = [ }, hint: 'Faites d\'abord "cd projets" si ce n\'est pas déjà fait, puis "./script.sh"', hintByEnv: { - windows: 'Tapez ".\\script.sh" ou "bash script.sh"', + windows: 'Faites d\'abord "cd projets", puis ".\\script.sh" ou "bash script.sh"', }, validate: validateScripts, successMessage: 'Bravo ! Vous venez d\'exécuter votre premier script bash.', @@ -2275,6 +2285,7 @@ export const curriculum: Module[] = [ instruction: 'Ajoutez tous les fichiers du répertoire courant à la zone de staging avec `git add .`.', hint: 'Tapez: git add .', validate: validateGitAddCommit, + setup: gitRepoEmpty, successMessage: 'Fichiers stagés ! Maintenant vous pouvez les committer avec git commit -m "message".', }, }, @@ -2312,6 +2323,7 @@ export const curriculum: Module[] = [ instruction: 'Affichez le statut de votre dépôt avec `git status`.', hint: 'Tapez: git status', validate: validateGitStatusLog, + setup: gitRepoWithCommit, successMessage: 'Vous savez lire l\'état de votre dépôt. git status sera votre commande la plus utilisée au quotidien.', }, }, @@ -2353,6 +2365,7 @@ export const curriculum: Module[] = [ instruction: 'Visualisez les différences actuelles dans votre dépôt avec `git diff`.', hint: 'Tapez: git diff', validate: validateGitDiffGitignore, + setup: gitRepoWithCommit, successMessage: 'Vous savez lire un diff Git. Les lignes en vert (+) sont les ajouts, en rouge (-) les suppressions.', }, }, @@ -2393,6 +2406,7 @@ export const curriculum: Module[] = [ instruction: 'Créez une nouvelle branche `feature/ma-feature` et basculez dessus avec `git checkout -b feature/ma-feature`.', hint: 'Tapez: git checkout -b feature/ma-feature', validate: validateGitBranch, + setup: gitRepoWithCommit, successMessage: 'Branche créée et activée ! Vous développez maintenant en isolation totale de main.', }, }, @@ -2430,6 +2444,7 @@ export const curriculum: Module[] = [ instruction: 'Fusionnez la branche `feature/ma-feature` dans la branche courante avec `git merge feature/ma-feature`.', hint: 'Tapez: git merge feature/ma-feature', validate: validateGitMerge, + setup: gitRepoWithBranch('feature/ma-feature'), successMessage: 'Fusion réussie ! Le travail de la branche est maintenant intégré. C\'est le coeur du workflow Git en entreprise.', }, }, @@ -2484,6 +2499,7 @@ export const curriculum: Module[] = [ instruction: 'Ajoutez un remote `origin` pointant vers `https://github.com/user/mon-projet.git` avec `git remote add origin https://github.com/user/mon-projet.git`.', hint: 'Tapez: git remote add origin https://github.com/user/mon-projet.git', validate: validateGitRemote, + setup: gitRepoWithCommit, successMessage: 'Remote ajouté ! Votre dépôt local est maintenant connecté à GitHub.', }, }, @@ -2528,6 +2544,7 @@ export const curriculum: Module[] = [ instruction: 'Envoyez vos commits vers GitHub avec `git push -u origin main`.', hint: 'Tapez: git push -u origin main', validate: validateGitPushPull, + setup: gitRepoWithRemote, successMessage: 'Push réussi ! Vos commits sont maintenant sur GitHub, visibles par toute votre équipe.', }, }, @@ -2605,6 +2622,7 @@ export const curriculum: Module[] = [ instruction: 'Simulez le début d\'un workflow PR : créez une branche `feature/nouvelle-feature` avec `git checkout -b feature/nouvelle-feature`.', hint: 'Tapez: git checkout -b feature/nouvelle-feature', validate: validatePullRequests, + setup: gitRepoWithCommit, successMessage: 'Branche feature créée ! Dans un vrai projet, vous développeriez ici puis ouvreriez une PR vers main.', }, }, @@ -2651,6 +2669,7 @@ export const curriculum: Module[] = [ instruction: 'Fusionnez la branche `feature/ma-feature` avec un **merge commit explicite** (option `--no-ff`) : `git merge --no-ff feature/ma-feature`.', hint: 'Tapez: git merge --no-ff feature/ma-feature', validate: validateMergeStrategies, + setup: gitRepoWithBranch('feature/ma-feature'), successMessage: 'Merge commit créé ! Votre branche reste identifiable dans l\'historique — utile pour retrouver le contexte d\'une feature 6 mois plus tard.', }, }, @@ -2692,6 +2711,7 @@ export const curriculum: Module[] = [ 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, + setup: gitRepoWithBranch('feature/nouvelle-feature'), successMessage: 'Fusion effectuée ! En cas de conflit réel, vous savez maintenant comment les identifier et les résoudre.', }, }, @@ -2737,6 +2757,7 @@ export const curriculum: Module[] = [ instruction: 'Vérifiez l\'état de votre dépôt git avant un push avec `git status`.', hint: 'Tapez: git status', validate: validateGithubActions, + setup: gitRepoWithRemote, successMessage: 'Parfait ! Avant chaque push, vérifiez toujours l\'état de votre dépôt. GitHub Actions fera ensuite tourner automatiquement vos tests et votre build.', }, }, diff --git a/src/app/data/lessonSetup.ts b/src/app/data/lessonSetup.ts new file mode 100644 index 0000000..b276820 --- /dev/null +++ b/src/app/data/lessonSetup.ts @@ -0,0 +1,113 @@ +/** + * Lesson setups — the terminal state a lesson starts from. + * + * Every lesson mounts a fresh terminal (`createInitialState`). That is right for + * most lessons, but wrong for the ones that teach a command which only makes + * sense in a prepared context: `git status` needs a repository, `git merge + * feature/x` needs a branch called `feature/x`, `ls -la ~/.ssh` needs a `.ssh` + * directory. Without a setup the learner typed exactly what the lesson asked, + * got `fatal: not a git repository` in red, and was still told "Exercice + * complété" — the validator only reads the command string (check-up W4b, + * 23 September 2026: 33 of the 46 desyncs were Git lessons). + * + * A setup is a pure function of the initial state, plus a one-line note shown + * in the terminal welcome message so the learner knows what is already there. + */ +import type { DirectoryNode, FSNode, GitCommit, GitState, TerminalState } from './commands/types'; + +export interface LessonSetup { + /** Pure: returns a new state, never mutates the one it receives. */ + apply: (state: TerminalState) => TerminalState; + /** Shown in the welcome message, e.g. "Dépôt Git prêt dans ~/projets". */ + note: string; +} + +// Fixed hashes and dates keep the prepared history deterministic (tests, and +// the same screen for every learner). +const INITIAL_COMMIT: GitCommit = { + hash: 'a3f8c12', + message: 'feat: premier commit du projet', + author: 'user', + date: '2026-01-15', +}; + +const PROJECT_DIR = ['home', 'user', 'projets']; + +function withGit(state: TerminalState, git: Partial): TerminalState { + return { + ...state, + cwd: PROJECT_DIR, + git: { + initialized: true, + branch: 'main', + branches: ['main'], + stagedFiles: [], + commits: [], + remotes: {}, + ...git, + }, + }; +} + +/** Returns a copy of `root` with `node` placed at `path` (parents must exist). */ +function withNode(root: DirectoryNode, path: string[], node: FSNode): DirectoryNode { + const [head, ...rest] = path; + if (rest.length === 0) return { ...root, children: { ...root.children, [head]: node } }; + const child = root.children[head]; + if (!child || child.type !== 'directory') return root; + return { ...root, children: { ...root.children, [head]: withNode(child, rest, node) } }; +} + +function file(content: string, permissions: string): FSNode { + return { type: 'file', content, permissions, owner: 'user', group: 'user', size: content.length }; +} + +/** A fresh repository: nothing committed yet, files ready to be staged. */ +export const gitRepoEmpty: LessonSetup = { + apply: (s) => withGit(s, {}), + note: 'Dépôt Git prêt dans ~/projets (initialisé, aucun commit).', +}; + +/** A repository with one commit on `main`. */ +export const gitRepoWithCommit: LessonSetup = { + apply: (s) => withGit(s, { commits: [INITIAL_COMMIT] }), + note: 'Dépôt Git prêt dans ~/projets (branche main, 1 commit).', +}; + +/** A repository whose `main` has one commit and an extra branch to merge. */ +export function gitRepoWithBranch(branch: string): LessonSetup { + return { + apply: (s) => withGit(s, { commits: [INITIAL_COMMIT], branches: ['main', branch] }), + note: `Dépôt Git prêt dans ~/projets (vous êtes sur main, la branche ${branch} existe).`, + }; +} + +/** A repository with one commit and an `origin` remote. */ +export const gitRepoWithRemote: LessonSetup = { + apply: (s) => + withGit(s, { + commits: [INITIAL_COMMIT], + remotes: { origin: 'https://github.com/user/mon-projet.git' }, + }), + note: 'Dépôt Git prêt dans ~/projets (1 commit, remote origin configuré).', +}; + +/** A `~/.ssh` directory with the permissions a correct setup has (700 / 600 / 644). */ +export const sshDirectory: LessonSetup = { + apply: (s) => ({ + ...s, + root: withNode(s.root, ['home', 'user', '.ssh'], { + type: 'directory', + permissions: 'drwx------', + owner: 'user', + group: 'user', + children: { + // Deliberately not shaped like a key: secret scanners flag the PEM header even on a fake. + id_ed25519: file('(clé privée simulée — une vraie clé ne se partage et ne s\'affiche jamais)', '-rw-------'), + 'id_ed25519.pub': file('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAISimulatedKeyOnly user@terminal-lab', '-rw-r--r--'), + known_hosts: file('github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAISimulatedHostKey', '-rw-r--r--'), + }, + }), + }), + note: 'Un dossier ~/.ssh (simulé) contient une paire de clés.', +}; diff --git a/src/lib/renderInlineMarkdown.tsx b/src/lib/renderInlineMarkdown.tsx index e1212d2..be05020 100644 --- a/src/lib/renderInlineMarkdown.tsx +++ b/src/lib/renderInlineMarkdown.tsx @@ -47,3 +47,16 @@ export function renderInlineMarkdown(text: string): ReactNode[] { return {part}; }); } + +/** + * Plain-text twin of `renderInlineMarkdown`, for surfaces that cannot render + * markup — the terminal welcome message showed the instruction's backticks and + * asterisks literally (check-up P1, 23 September 2026). Same token rules: only + * a complete `` `code` `` or `**bold**` span loses its markers; a lone + * backtick or asterisk stays verbatim. + */ +export function stripInlineMarkdown(text: string): string { + return text.replace(INLINE_TOKEN_RX, (part) => + part.startsWith('`') ? part.slice(1, -1) : part.slice(2, -2), + ); +} diff --git a/src/test/lessonFidelity.test.ts b/src/test/lessonFidelity.test.ts new file mode 100644 index 0000000..2e06a0b --- /dev/null +++ b/src/test/lessonFidelity.test.ts @@ -0,0 +1,88 @@ +/** + * Lesson fidelity — P1 of the check-up (23 September 2026). + * + * For every lesson × environment, typing what the lesson tells the learner to + * type must (1) complete the exercise AND (2) print no error. Before this test, + * 46 of 198 combinations validated while the terminal showed a red error line: + * the validator only reads the command string, never the engine output + * (TerminalEmulator → onCommand(command, state)). + * + * The terminal state each case starts from is the one LessonPage builds: + * createInitialState(), then the exercise's optional `setup`. + * + * 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 { createInitialState, processCommand, type TerminalState } from '../app/data/terminalEngine'; +import { LESSON_SOLUTIONS } from './lessonSolutions'; + +const ENVS: EnvId[] = ['linux', 'macos', 'windows']; + +/** Cases still broken in the engine — each one names the P1 cluster that fixes it. */ +const KNOWN_DESYNCS = new Set([ + // Redirection: `2>` does not capture stderr, the error is still printed. + 'redirection/stderr [linux]', + 'redirection/stderr [macos]', + 'redirection/stderr [windows]', // + Get-Item is not simulated + // Scripts: `./script.sh` and `.\script.sh` are not executed. + 'variables/scripts [linux]', + 'variables/scripts [macos]', + 'variables/scripts [windows]', + // $PROFILE is not expanded, the file does not exist. + 'variables/shell-config [windows]', + // PowerShell cmdlets / expressions not simulated. + 'lecture/wc [windows]', // (Get-Content …).Count + 'permissions/chmod [windows]', // Set-ExecutionPolicy +]); + +const norm = (s: string) => s.replace(/\s+/g, ' ').trim(); + +const cases = curriculum.flatMap((m) => + m.lessons + .filter((l) => l.exercise) + .flatMap((l) => ENVS.map((env) => ({ key: `${m.id}/${l.id}`, env, exercise: l.exercise! }))), +); + +describe('lesson solutions table', () => { + it('covers every lesson that has an exercise, and nothing else', () => { + const keys = [...new Set(cases.map((c) => c.key))].sort(); + expect(Object.keys(LESSON_SOLUTIONS).sort()).toEqual(keys); + }); + + 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}`, + ); + for (const cmd of solution!) expect(shown).toContain(norm(cmd)); + }); + + 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); + }); +}); + +describe('lesson fidelity — the lesson command validates and prints no error', () => { + for (const { key, env, exercise } of cases) { + const id = `${key} [${env}]`; + 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); + 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}`); + } + expect(errors).toEqual([]); + expect(exercise.validate(solution[solution.length - 1], env)).toBe(true); + }); + } +}); diff --git a/src/test/lessonSetup.test.ts b/src/test/lessonSetup.test.ts new file mode 100644 index 0000000..0d2aedc --- /dev/null +++ b/src/test/lessonSetup.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { createInitialState, processCommand } from '../app/data/terminalEngine'; +import { + gitRepoEmpty, + gitRepoWithBranch, + gitRepoWithCommit, + gitRepoWithRemote, + sshDirectory, +} from '../app/data/lessonSetup'; + +describe('lesson setups', () => { + it('never mutate the state they receive', () => { + for (const setup of [gitRepoEmpty, gitRepoWithCommit, gitRepoWithRemote, gitRepoWithBranch('feature/x'), sshDirectory]) { + const base = createInitialState(); + const snapshot = JSON.stringify(base); + setup.apply(base); + expect(JSON.stringify(base)).toBe(snapshot); + } + }); + + it('start Git lessons inside ~/projets, where the project files are', () => { + const s = gitRepoEmpty.apply(createInitialState()); + expect(s.cwd).toEqual(['home', 'user', 'projets']); + const out = processCommand(s, 'git add .', 'linux'); + expect(out.lines.map((l) => l.text)).toEqual(['staged: script.sh', 'staged: README.md']); + }); + + it('prepare the branch a merge lesson asks for, with main checked out', () => { + const s = gitRepoWithBranch('feature/ma-feature').apply(createInitialState()); + const out = processCommand(s, 'git branch', 'linux'); + expect(out.lines.map((l) => l.text)).toEqual(['* main', ' feature/ma-feature']); + }); + + it('give push lessons a commit and an origin remote', () => { + const s = gitRepoWithRemote.apply(createInitialState()); + const out = processCommand(s, 'git remote -v', 'linux'); + expect(out.lines[0].text).toBe('origin\thttps://github.com/user/mon-projet.git (fetch)'); + expect(processCommand(s, 'git log --oneline', 'linux').lines[0].text).toBe('a3f8c12 feat: premier commit du projet'); + }); + + it('create ~/.ssh with the permissions the lesson teaches (700 / 600 / 644)', () => { + const s = sshDirectory.apply(createInitialState()); + const out = processCommand(s, 'ls -la ~/.ssh', 'linux').lines.map((l) => l.text).join('\n'); + expect(out).toMatch(/-rw------- .*id_ed25519\b(?!\.pub)/); + expect(out).toMatch(/-rw-r--r-- .*id_ed25519\.pub/); + const home = processCommand(s, 'ls -la', 'linux').lines.map((l) => l.text).join('\n'); + expect(home).toMatch(/drwx------ .*\.ssh/); + }); + + it('leave the default state untouched for lessons without a setup', () => { + expect(processCommand(createInitialState(), 'ls -la ~/.ssh', 'linux').lines[0].type).toBe('error'); + }); +}); diff --git a/src/test/lessonSolutions.ts b/src/test/lessonSolutions.ts new file mode 100644 index 0000000..48088d3 --- /dev/null +++ b/src/test/lessonSolutions.ts @@ -0,0 +1,124 @@ +/** + * The command(s) a learner types to solve each lesson, per environment — the + * ones the lesson itself tells them to type (instruction or hint). + * + * 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. + * + * Derived from the browser census of 23 September 2026 (198 lesson × env). + */ +import type { EnvId } from '../app/data/curriculum'; + +export type Solution = Partial>; + +export const LESSON_SOLUTIONS: Record = { + // ── navigation ── + 'navigation/orientation': { all: ['help'] }, + 'navigation/pwd': { all: ['pwd'], windows: ['Get-Location'] }, + 'navigation/ls': { all: ['ls'], windows: ['Get-ChildItem'] }, + 'navigation/ls-la': { all: ['ls -la'], windows: ['Get-ChildItem -Force'] }, + 'navigation/command-anatomy': { all: ['man ls'] }, + 'navigation/cd': { all: ['cd documents'], windows: ['Set-Location documents'] }, + // ── fichiers ── + 'fichiers/mkdir': { all: ['mkdir test'], windows: ['New-Item -ItemType Directory -Name test'] }, + 'fichiers/touch': { all: ['touch memo.txt'], windows: ['New-Item -ItemType File -Name memo.txt'] }, + 'fichiers/cp': { + all: ['cp documents/notes.txt documents/notes-copy.txt'], + windows: ['Copy-Item documents/notes.txt documents/notes-copy.txt'], + }, + 'fichiers/mv': { + all: ['mv documents/rapport.md documents/rapport-final.md'], + windows: ['Move-Item documents/rapport.md documents/rapport-final.md'], + }, + 'fichiers/rm': { all: ['rm documents/notes.txt'], windows: ['Remove-Item documents/notes.txt'] }, + // ── lecture ── + 'lecture/cat': { all: ['cat documents/notes.txt'], windows: ['Get-Content documents/notes.txt'] }, + 'lecture/head-tail': { + all: ['head -n 3 documents/rapport.md'], + windows: ['Get-Content documents/rapport.md | Select-Object -First 3'], + }, + 'lecture/grep': { + all: ['grep important documents/notes.txt'], + windows: ['Select-String "important" documents/notes.txt'], + }, + 'lecture/wc': { all: ['wc -l documents/rapport.md'], windows: ['(Get-Content documents/rapport.md).Count'] }, + // ── permissions ── + 'permissions/comprendre-permissions': { all: ['ls -l'], windows: ['Get-Acl documents/notes.txt'] }, + 'permissions/chmod': { all: ['chmod +x projets/script.sh'], windows: ['Set-ExecutionPolicy RemoteSigned'] }, + 'permissions/chown': { all: ['ls -la'], windows: ['Get-Acl documents/notes.txt | Select-Object Owner'] }, + 'permissions/sudo': { all: ['whoami'] }, + 'permissions/security-permissions': { all: ['ls -la ~/.ssh'], windows: ['Get-Acl $HOME | Format-List'] }, + // ── processus ── + 'processus/ps': { all: ['ps'], windows: ['Get-Process'] }, + 'processus/kill': { + all: ['ps aux'], + windows: ['Get-Process | Sort-Object CPU -Descending | Select-Object -First 5'], + }, + 'processus/top': { + linux: ['ps aux --sort=-%mem | head -5'], + macos: ['ps aux | sort -k3rn | head -5'], + windows: ['Get-Process | Sort-Object WorkingSet -Descending | Select-Object -First 5'], + }, + 'processus/background': { all: ['jobs'], windows: ['Get-Job'] }, + // ── redirection ── + 'redirection/redirection-sortie': { + all: ['echo "Bonjour le monde!" > bonjour.txt'], + windows: ['Write-Output "Bonjour le monde!" > bonjour.txt'], + }, + 'redirection/pipes': { all: ['ls | wc -l'], windows: ['Get-ChildItem | Measure-Object'] }, + 'redirection/stderr': { + all: ['ls fichier-inexistant 2> erreurs.txt'], + windows: ['Get-Item fichier-inexistant 2> erreurs.txt'], + }, + 'redirection/tee': { + all: ['ls | tee ma-liste.txt'], + windows: ['Get-ChildItem | Tee-Object -FilePath ma-liste.txt'], + }, + // ── variables ── + 'variables/env-vars': { all: ['export GREETING=Hello'], windows: ['$env:GREETING = "Hello"'] }, + '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/cron': { all: ['crontab -l'] }, + // ── réseau ── + 'reseau/ping': { all: ['ping google.com'] }, + 'reseau/curl': { all: ['curl https://api.github.com'] }, + 'reseau/wget': { + all: ['wget https://example.com/fichier.zip'], + windows: ['Invoke-WebRequest -Uri https://example.com/fichier.zip -OutFile fichier.zip'], + }, + 'reseau/dns': { all: ['nslookup google.com'] }, + 'reseau/ssh': { all: ['ssh-keygen -t ed25519'] }, + 'reseau/scp': { all: ['scp fichier.txt user@serveur.example.com:/home/user/'] }, + // ── git ── + 'git/git-init': { all: ['git init'] }, + 'git/git-config': { all: ['git config --list'] }, + 'git/git-add-commit': { all: ['git add .'] }, + 'git/git-status-log': { all: ['git status'] }, + 'git/git-diff-gitignore': { all: ['git diff'] }, + 'git/git-branch': { all: ['git checkout -b feature/ma-feature'] }, + 'git/git-merge': { all: ['git merge feature/ma-feature'] }, + // ── github-collaboration ── + 'github-collaboration/git-remote': { all: ['git remote add origin https://github.com/user/mon-projet.git'] }, + 'github-collaboration/git-push-pull': { all: ['git push -u origin main'] }, + '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/github-actions': { all: ['git status'] }, + // ── ia-dev ── + 'ia-dev/ia-dev-intro': { all: ['ai-help'] }, + 'ia-dev/ia-dev-capacites': { all: ['ai-help capabilities'] }, + 'ia-dev/ia-dev-limites': { all: ['ai-help limits'] }, + 'ia-dev/ia-dev-prompts-basics': { all: ['ai-help prompts'] }, + 'ia-dev/ia-dev-prompts-avances': { all: ['ai-help context'] }, + 'ia-dev/ia-dev-valider': { all: ['ai-help validate'] }, + 'ia-dev/ia-dev-debug': { all: ['ai-help debug'] }, + 'ia-dev/ia-dev-securite': { all: ['ai-help security'] }, + 'ia-dev/ia-dev-claude-cli': { all: ['ai-help claude-cli'] }, + 'ia-dev/ia-dev-metiers': { all: ['ai-help careers'] }, + 'ia-dev/ia-dev-posture': { all: ['ai-help senior'] }, + 'ia-dev/ia-dev-workflow': { all: ['ai-help workflow'] }, +}; diff --git a/src/test/renderInlineMarkdown.test.tsx b/src/test/renderInlineMarkdown.test.tsx index a6e88f5..469bce2 100644 --- a/src/test/renderInlineMarkdown.test.tsx +++ b/src/test/renderInlineMarkdown.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { render } from '@testing-library/react'; -import { renderInlineMarkdown } from '@/lib/renderInlineMarkdown'; +import { renderInlineMarkdown, stripInlineMarkdown } from '@/lib/renderInlineMarkdown'; function renderInline(text: string) { return render(
{renderInlineMarkdown(text)}
); @@ -70,3 +70,19 @@ describe('renderInlineMarkdown', () => { expect(container.textContent).toBe('juste du texte normal'); }); }); + +describe('stripInlineMarkdown (terminal welcome message)', () => { + it('drops the markers of complete code and bold spans', () => { + expect(stripInlineMarkdown('Fusionnez `feature/x` avec un **merge commit** (`--no-ff`).')).toBe( + 'Fusionnez feature/x avec un merge commit (--no-ff).', + ); + }); + + it('keeps a lone backtick or asterisk verbatim', () => { + expect(stripInlineMarkdown('a ` b * c')).toBe('a ` b * c'); + }); + + it('leaves plain text unchanged', () => { + expect(stripInlineMarkdown('Tapez: git status')).toBe('Tapez: git status'); + }); +}); diff --git a/src/test/terminalEmulatorInitialState.test.tsx b/src/test/terminalEmulatorInitialState.test.tsx new file mode 100644 index 0000000..0bf4ef6 --- /dev/null +++ b/src/test/terminalEmulatorInitialState.test.tsx @@ -0,0 +1,50 @@ +/** + * P1 (23 September 2026) — the terminal starts from the lesson's setup state. + * Before, every lesson started from createInitialState(), so `git status` in a + * Git lesson printed "fatal: not a git repository" and still validated. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TerminalEmulator } from '../app/components/TerminalEmulator'; +import { createInitialState } from '../app/data/terminalEngine'; +import { gitRepoWithBranch, gitRepoWithCommit } from '../app/data/lessonSetup'; + +function type(command: string) { + const input = screen.getByLabelText('Commande terminal'); + fireEvent.change(input, { target: { value: command } }); + fireEvent.submit(input.closest('form')!); +} + +describe('TerminalEmulator initialState', () => { + it('starts from the setup state when one is given', () => { + const onCommand = vi.fn(); + render( gitRepoWithCommit.apply(createInitialState())} />); + type('git status'); + expect(screen.getByText('On branch main')).toBeInTheDocument(); + 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 }) })); + }); + + it('keeps the default state when no setup is given', () => { + render(); + type('git status'); + expect(screen.getByText(/not a git repository/)).toBeInTheDocument(); + }); + + it('builds the state once, not on every render', () => { + const build = vi.fn(() => createInitialState()); + render(); + type('ls'); + type('pwd'); + expect(build).toHaveBeenCalledTimes(1); + }); + + it('keeps the spacing of command output (git branch indents non-current branches)', () => { + render( gitRepoWithBranch('feature/x').apply(createInitialState())} />); + type('git branch'); + const line = screen.getByText((_, el) => el?.textContent === ' feature/x' && el.children.length === 0); + expect(line.className).toContain('whitespace-pre-wrap'); + }); +});