diff --git a/src/i18n/en.js b/src/i18n/en.js index 0692aaa..3a7cf05 100644 --- a/src/i18n/en.js +++ b/src/i18n/en.js @@ -81,6 +81,12 @@ export const EN = { 'cible atteinte': 'target reached', 'sans erreur ni fuite': 'no error or leak', 'Exécuter': 'Run', 'Pas-à-pas': 'Step', 'Réinitialiser': 'Reset', 'Suivant →': 'Next →', 'Passer': 'Skip', '// pile d\'appels': '// call stack', + // Réordonnancement du programme (boutons + aria-labels accessibles). + 'monter': 'move up', 'descendre': 'move down', 'retirer': 'remove', + 'monter l\'instruction': 'move instruction up', 'descendre l\'instruction': 'move instruction down', + 'n°': 'no.', + // Notes de statut des cartes de région (carte RAM). + 'résolu': 'solved', 'tu es ici': 'you are here', 'verrouillé': 'locked', '// explorateur d\'octets (little-endian)': '// byte explorer (little-endian)', 'GLIF surveille la mémoire': 'GLIF watches the memory', 'GLIF valide !': 'GLIF approves!', 'GLIF réfléchit…': 'GLIF is thinking…', diff --git a/src/ui/bytesView.js b/src/ui/bytesView.js index 062d117..64195f9 100644 --- a/src/ui/bytesView.js +++ b/src/ui/bytesView.js @@ -11,7 +11,7 @@ export function renderBytes(container, snapshot, names = []) { const wanted = snapshot.filter((c) => names.includes(c.name)); if (!wanted.length) return; - container.appendChild(el('div', { class: 'heap-label', text: '// explorateur d\'octets (little-endian)' })); + container.appendChild(el('div', { class: 'heap-label', text: t('// explorateur d\'octets (little-endian)') })); const strip = el('div', { style: 'display:flex;flex-direction:column;gap:6px' }); for (const cell of wanted) { const e = explain(cell.value); diff --git a/src/ui/callStackView.js b/src/ui/callStackView.js index 4fd2e85..ea50ebd 100644 --- a/src/ui/callStackView.js +++ b/src/ui/callStackView.js @@ -53,7 +53,7 @@ export function renderCallStack(container, frames = []) { } if (model.frames.length) { - container.appendChild(el('div', { class: 'heap-label', text: '// pile d\'appels' })); + container.appendChild(el('div', { class: 'heap-label', text: t('// pile d\'appels') })); const strip = el('div', { style: 'display:flex;flex-direction:column;gap:6px' }); for (const f of model.frames) { const head = el('div', { style: 'display:flex;align-items:center;gap:8px;margin-bottom:3px' }, [ diff --git a/src/ui/components/memory.js b/src/ui/components/memory.js index 790c69b..b804e46 100644 --- a/src/ui/components/memory.js +++ b/src/ui/components/memory.js @@ -15,7 +15,7 @@ export function locker({ name, address = null, value = null, kind = null, state const cls = 'mf-locker' + (state !== 'normal' ? ' mf-locker--' + state : ''); const children = [el('div', { class: 'mf-locker__name', text: name })]; if (address != null) - children.push(el('div', { class: 'mf-locker__addr', text: 'n° ' + address + (kind ? ' · ' + kind : '') })); + children.push(el('div', { class: 'mf-locker__addr', text: t('n°') + ' ' + address + (kind ? ' · ' + kind : '') })); children.push(el('div', { class: 'mf-locker__val', text: value == null ? '—' : String(value) })); children.push(el('div', { class: 'mf-locker__state', text: t(stateLabel || STATE_LABELS[state] || '') })); return el('div', { class: cls }, children); diff --git a/src/ui/components/navigation.js b/src/ui/components/navigation.js index 8c53431..5850a81 100644 --- a/src/ui/components/navigation.js +++ b/src/ui/components/navigation.js @@ -1,8 +1,11 @@ // Composants « navigation » du design-system MemoForge (vanilla). // Miroir de components/navigation du prototype : RegionCard. import { el } from '../dom.js'; +import { t } from '../../game/i18n.js'; const GLYPHS = { solved: '★★★', current: '◊', locked: '🔒' }; +// Clés source (français) ; t() les traduit au rendu (pas à l'import : la langue n'est +// appliquée qu'après initLang()). const DEFAULT_NOTE = { solved: 'résolu', current: 'tu es ici', locked: 'verrouillé' }; /** @@ -16,6 +19,6 @@ export function regionCard({ id = null, title, status = 'locked', note = null } titleChildren.push(document.createTextNode(title)); return el('div', { class: 'mf-region mf-region--' + status }, [ el('div', { class: 'mf-region__title' }, titleChildren), - el('div', { class: 'mf-region__note', text: (GLYPHS[status] || '') + ' ' + (note || DEFAULT_NOTE[status] || '') }) + el('div', { class: 'mf-region__note', text: (GLYPHS[status] || '') + ' ' + t(note || DEFAULT_NOTE[status] || '') }) ]); } diff --git a/src/ui/libftView.js b/src/ui/libftView.js index 7f02165..9469be7 100644 --- a/src/ui/libftView.js +++ b/src/ui/libftView.js @@ -1,4 +1,5 @@ import { el, clear } from './dom.js'; +import { t } from '../game/i18n.js'; // « Ta libft » (E6-8) : l'inventaire des ft_ forgées par le joueur, affiché comme des // jetons. C'est la matérialisation de la progression — ta boîte à outils qui grandit. @@ -8,7 +9,7 @@ export function renderLibft(container, names = []) { clear(container); if (!names.length) return; - container.appendChild(el('h2', { text: 'ta libft' })); + container.appendChild(el('h2', { text: t('ta libft') })); const strip = el('div', { class: 'libft-strip', style: 'display:flex;flex-wrap:wrap;gap:6px' }); for (const name of names) strip.appendChild(el('span', { diff --git a/src/ui/programView.js b/src/ui/programView.js index f9521d3..c01e59a 100644 --- a/src/ui/programView.js +++ b/src/ui/programView.js @@ -21,14 +21,14 @@ export function renderProgram(container, program, slots, activeIndex, onRemove, brick.style.flex = '1'; const up = el('button', { - style: MOVE_STYLE, title: 'monter', 'aria-label': 'monter l\'instruction', + style: MOVE_STYLE, title: t('monter'), 'aria-label': t('monter l\'instruction'), disabled: i === 0 ? 'true' : null, onclick: () => onMove && onMove(i, i - 1) }, ['▲']); const down = el('button', { - style: MOVE_STYLE, title: 'descendre', 'aria-label': 'descendre l\'instruction', + style: MOVE_STYLE, title: t('descendre'), 'aria-label': t('descendre l\'instruction'), disabled: i === program.length - 1 ? 'true' : null, onclick: () => onMove && onMove(i, i + 1) }, ['▼']); - const remove = el('button', { class: 'slot-remove', title: 'retirer', onclick: () => onRemove(i) }, ['×']); + const remove = el('button', { class: 'slot-remove', title: t('retirer'), onclick: () => onRemove(i) }, ['×']); const rowEl = el('div', { class: 'mf-slot', style: 'display:flex;gap:6px;align-items:stretch', draggable: 'true' }, [brick, up, down, remove]); rowEl.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/plain', String(i)); e.dataTransfer.effectAllowed = 'move'; }); diff --git a/tests/game/i18n-guard.test.mjs b/tests/game/i18n-guard.test.mjs new file mode 100644 index 0000000..62bcd43 --- /dev/null +++ b/tests/game/i18n-guard.test.mjs @@ -0,0 +1,148 @@ +// Garde i18n (E10-1) — aucune chaîne visible par le joueur n'est en dur. Le français est la +// source, l'anglais une surcharge : toute chaîne user-facing DOIT passer par t() pour être +// traduisible. Cette garde scanne les vues et échoue si un littéral de « prose » rendu à +// l'écran (valeur d'une propriété non structurelle) n'est pas routé par t(). +// C'est le filet qui empêche le retour des oublis de traduction (cf. #160, DEFAULT_NOTE). +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, relative } from 'node:path'; +import { EN } from '../../src/i18n/en.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +// Registre des chaînes traduisibles : les clés du pack EN (le français EST la clé). Un texte +// user-facing est « en règle » s'il est enregistré ici — qu'il soit passé à t() directement +// ou stocké comme clé-source dans une table (STATE_LABELS, DEFAULT_NOTE) puis t()'d au rendu. +const REGISTERED = new Set(Object.keys(EN.ui || {})); + +// Un littéral de chaîne (gère les échappements \' et \"). +const STRING_RE = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g; +// « Prose » = une suite d'au moins 3 lettres (accents inclus). Distingue un texte humain +// d'un glyphe/symbole (« ⟳ », « ← », « n° »). +const PROSE_RE = /[A-Za-zÀ-ÖØ-öø-ÿ]{3,}/; + +// Clés STRUCTURELLES : leur valeur est du code (CSS, classe, id, enum), jamais du texte +// affiché. Un littéral en valeur de ces clés est légitime et ignoré. +const STRUCTURAL = new Set([ + 'class', 'style', 'id', 'href', 'src', 'type', 'role', 'tag', 'name', 'for', 'rel', + 'd', 'viewBox', 'xmlns', 'lang', 'dir', 'key', 'tone', 'state', 'status', 'kind', + 'variant', 'glyph', 'code', 'effectAllowed', 'dropEffect', 'draggable', 'disabled', + 'width', 'height', 'align', 'data', 'value', 'group', + // Propriétés CSS (objets de style : Object.assign(el.style, {...})). + 'position', 'overflow', 'pointerEvents', 'inset', 'zIndex', 'top', 'left', 'right', + 'bottom', 'margin', 'padding', 'color', 'background', 'border', 'display', 'flex', + 'font', 'fontFamily', 'opacity', 'transform', 'transition', 'cursor', 'gap' +]); +// Clés-chaîne (entre guillemets) qui rendent bien du texte user-facing : on les surveille. +const QUOTED_TEXT_KEYS = new Set(['aria-label', 'placeholder', 'alt', 'title', 'aria-description']); + +// Chaînes techniques identiques dans les deux langues (sortie d'outil, nom propre) : +// volontairement non traduites. Toute addition ici doit être un vrai invariant de locale. +const ALLOW = new Set([ + '// valgrind --leak-check=full', + '==memoforge== ' +]); +// Fichiers exemptés : la page de style est un outil de dev (non expédié au joueur). +const SKIP = new Set(['styleguide.js']); + +function collectJs(dir) { + const out = []; + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) out.push(...collectJs(p)); + else if (name.endsWith('.js') && !SKIP.has(name)) out.push(p); + } + return out; +} + +// Sources user-facing : toutes les vues + le contrôleur de jeu (qui compose des libellés). +const FILES = [...collectJs(join(ROOT, 'src', 'ui')), join(ROOT, 'src', 'game', 'game.js')]; + +// La clé qui précède un littéral en position de valeur (« word: » ou « 'word': »), ou null +// si le littéral n'est pas une valeur de propriété (arg, élément de tableau, branche ?:). +function keyBefore(line, litIndex) { + let j = litIndex - 1; + while (j >= 0 && line[j] === ' ') j--; + if (j < 0 || line[j] !== ':') return null; // pas en position de valeur + const head = line.slice(0, j); // tout ce qui précède le « : » + const m = head.match(/(['"]?)([A-Za-z][\w-]*)\1\s*$/); + if (!m) return null; + // Une clé entre guillemets n'est un vrai attribut de texte que si elle est déclarée ; + // sinon c'est la branche gauche d'un ternaire (« ? 'a' : 'b' ») → on ignore. + if (m[1] && !QUOTED_TEXT_KEYS.has(m[2])) return null; + return m[2]; +} + +function violationsIn(file) { + const src = readFileSync(file, 'utf8'); + const found = []; + src.split('\n').forEach((line, idx) => { + const trimmed = line.trim(); + if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return; + let s; + STRING_RE.lastIndex = 0; + while ((s = STRING_RE.exec(line))) { + const inner = s[0].slice(1, -1); + if (!PROSE_RE.test(inner) || ALLOW.has(inner)) continue; + const key = keyBefore(line, s.index); + if (key === null || STRUCTURAL.has(key)) continue; // pas un texte affiché + if (REGISTERED.has(inner)) continue; // enregistré → traduisible + found.push({ line: idx + 1, key, literal: s[0] }); + } + }); + return found; +} + +// Second volet : toute clé passée à t('…') littéralement DOIT exister dans le pack EN. +// Sinon t() retombe sur le français en mode anglais — une fuite silencieuse (faute de +// frappe, clé désynchronisée). Les appels dynamiques t(variable) ne sont pas vérifiables. +const T_CALL_RE = /\bt\(\s*('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")\s*\)/g; + +function unregisteredKeysIn(file) { + const src = readFileSync(file, 'utf8'); + const bad = []; + src.split('\n').forEach((line, idx) => { + let m; + T_CALL_RE.lastIndex = 0; + while ((m = T_CALL_RE.exec(line))) { + // Décode le littéral source (\n, \', …) pour comparer à la clé réelle du pack. + let key; + try { key = JSON.parse('"' + m[1].slice(1, -1).replace(/\\'/g, "'").replace(/"/g, '\\"') + '"'); } + catch { key = m[1].slice(1, -1); } + if (!PROSE_RE.test(key) || ALLOW.has(key)) continue; // glyphe/technique + if (!REGISTERED.has(key)) bad.push({ line: idx + 1, key: m[1] }); + } + }); + return bad; +} + +describe('garde i18n — chaque clé t(…) est enregistrée dans le pack EN', () => { + for (const file of FILES) { + const rel = relative(ROOT, file).replace(/\\/g, '/'); + test(`${rel} — aucune clé t() orpheline`, () => { + const bad = unregisteredKeysIn(file); + assert.equal( + bad.length, 0, + `clé(s) t() absente(s) du pack EN dans ${rel} :\n` + + bad.map((x) => ` L${x.line}: t(${x.key})`).join('\n') + + '\n→ ajoute la traduction dans src/i18n/en.js (sinon l\'anglais affiche le français).' + ); + }); + } +}); + +describe('garde i18n — aucune chaîne user-facing en dur', () => { + for (const file of FILES) { + const rel = relative(ROOT, file).replace(/\\/g, '/'); + test(`${rel} — tout texte affiché passe par t()`, () => { + const v = violationsIn(file); + assert.equal( + v.length, 0, + `chaîne(s) non traduisible(s) dans ${rel} :\n` + + v.map((x) => ` L${x.line} (${x.key}): ${x.literal}`).join('\n') + + "\n→ enveloppe le texte dans t(...) et ajoute la clé dans src/i18n/en.js." + ); + }); + } +}); diff --git a/tests/visual/baseline/win32/en-carte.png b/tests/visual/baseline/win32/en-carte.png new file mode 100644 index 0000000..e085034 Binary files /dev/null and b/tests/visual/baseline/win32/en-carte.png differ diff --git a/tests/visual/run.mjs b/tests/visual/run.mjs index eb21935..70227af 100644 --- a/tests/visual/run.mjs +++ b/tests/visual/run.mjs @@ -184,6 +184,20 @@ const SCREENS = [ || 'rendu EN incomplet : ' + JSON.stringify((t && t.textContent) + ' | ' + tag); } }, + { + // Carte en anglais (E10-1 / #162) : garde contre la fuite « tu es ici / verrouillé » + // qui restait en français sur la carte EN. Les notes de statut DOIVENT être traduites. + name: 'en-carte', + url: '/', + lang: 'en', + verify: () => { + const cards = document.querySelectorAll('.mf-region').length; + const txt = document.body.innerText; + return cards >= 9 && /you are here/i.test(txt) && !/tu es ici|verrouill/i.test(txt) + && txt.includes('Recursion') + || `carte EN : notes de statut non traduites ou carte incomplète (${cards} salles)`; + } + }, { name: 'styleguide', url: '/styleguide.html',