From f70e85fc89a3ce9adbb8dfffe2f8cef291e087c8 Mon Sep 17 00:00:00 2001
From: "Thierry V." <46031203+thierryvm@users.noreply.github.com>
Date: Thu, 24 Sep 2026 00:10:24 +0200
Subject: [PATCH] feat(terminal): start each lesson from the state it teaches
(THI-353)
Every lesson mounted a fresh default terminal, so the Git lessons asked for
`git status`, `git merge feature/x` or `git push -u origin main` in a
terminal that had no repository, no branch and no remote. The learner typed
exactly what the lesson said, got "fatal: not a git repository" in red, and
the exercise still validated: the validator only reads the command string.
The browser census of 23 September found 46 of 198 lesson x environment
combinations in that state; 33 of them were Git lessons.
- Exercise gains an optional `setup` (src/app/data/lessonSetup.ts): a pure
function of the initial state plus a one-line note shown in the terminal
welcome message. Git lessons start in ~/projets with the repository, branch
or remote they need; the security lesson gets a simulated ~/.ssh with the
700/600/644 permissions it teaches.
- TerminalEmulator takes an `initialState` builder, read once per mount
(lesson change or "Reinitialiser").
- Terminal output keeps its spacing (git branch indentation, git status
tabs); the welcome message, instruction, success message and hint no longer
show raw markdown backticks and asterisks.
- The Windows hint of the scripts lesson now says to `cd projets` first.
Permanent guard: lessonFidelity.test.ts replays, for every lesson x
environment, the command the lesson tells the learner to type, and requires
it to validate AND print no error. The solutions table must match the
lesson's instruction or hint word for word, so it cannot drift. The 9 cases
still broken in the engine (2>, ./script.sh, $PROFILE, three PowerShell
cmdlets) run as it.fails in a ratchet that can only shrink: fixing one turns
the suite red until its entry is removed.
Replayed in the engine with the full lesson commands, 44 combinations
failed before this change; 35 are fixed here, 9 remain in the ratchet (the
census counted 46 because it also typed fragments of the hints). Bundle: +0.06 kB gzip (LessonPage), +0.7 kB (curriculum).
Refs THI-353
Co-Authored-By: Claude Opus 5.5
---
src/app/components/LessonPage.tsx | 22 +++-
src/app/components/TerminalEmulator.tsx | 21 ++-
src/app/data/curriculum.ts | 23 +++-
src/app/data/lessonSetup.ts | 113 ++++++++++++++++
src/lib/renderInlineMarkdown.tsx | 13 ++
src/test/lessonFidelity.test.ts | 88 +++++++++++++
src/test/lessonSetup.test.ts | 53 ++++++++
src/test/lessonSolutions.ts | 124 ++++++++++++++++++
src/test/renderInlineMarkdown.test.tsx | 18 ++-
.../terminalEmulatorInitialState.test.tsx | 50 +++++++
10 files changed, 510 insertions(+), 15 deletions(-)
create mode 100644 src/app/data/lessonSetup.ts
create mode 100644 src/test/lessonFidelity.test.ts
create mode 100644 src/test/lessonSetup.test.ts
create mode 100644 src/test/lessonSolutions.ts
create mode 100644 src/test/terminalEmulatorInitialState.test.tsx
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');
+ });
+});