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');
+ });
+});