Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/i18n/en.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/ui/bytesView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/ui/callStackView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }, [
Expand Down
2 changes: 1 addition & 1 deletion src/ui/components/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion src/ui/components/navigation.js
Original file line number Diff line number Diff line change
@@ -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é' };

/**
Expand All @@ -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] || '') })
]);
}
3 changes: 2 additions & 1 deletion src/ui/libftView.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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', {
Expand Down
6 changes: 3 additions & 3 deletions src/ui/programView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'; });
Expand Down
148 changes: 148 additions & 0 deletions tests/game/i18n-guard.test.mjs
Original file line number Diff line number Diff line change
@@ -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."
);
});
}
});
Binary file added tests/visual/baseline/win32/en-carte.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions tests/visual/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading