Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y

Config strictement stricte partout (ruff, mypy --strict, bandit, vulture,
import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas
casser le build" - l'existant a ete corrige pour la satisfaire plutot que
l'inverse. Hooks pre-commit locaux (language: system) bloquants.

- Typage mypy --strict propage a tout le moteur (db, screens, auth, core,
  ai, routes, puis publish/scripts/tests/app.py/build_css.py).
- Securite : fuite de handle fichier Windows corrigee dans l'export SCORM
  (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes
  (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe
  "</script>" dans le JSON embarque en <script>, 25 sites).
- Architecture : imports circulaires/F811 nettoyes, contrats
  import-linter respectes, code mort retire (vulture).
- Accessibilite : 69 champs de formulaire sans label correctement
  associe corriges (for/id ou aria-label) sur 11 templates.
- ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis
  appliquees (aucune desactivee sans verification individuelle).
- Tests : isolation du compte admin partage (nettoyage ponctuel +
  fixture de teardown automatique en filet de securite), suite complete
  verte (591 tests Python, 241 tests JS).
- SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport
  complet analyse point par point, faux positifs documentes.
- .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine
  faisait echouer ESLint (linebreak-style) via un bug connu de git
  (checkout "en place" qui ignore l'eol force sur un fichier deja
  present sur disque - contourne en supprimant puis recreant chaque
  fichier suivi).

djLint (H021, styles inline) volontairement saute pour ce commit -
backlog assume, deja documente, traite dans un lot separe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-15 16:06:15 +02:00
co-authored by Claude Sonnet 5
parent 7db4803b93
commit c57420c8c9
454 changed files with 16448 additions and 6967 deletions
+10 -4
View File
@@ -79,7 +79,7 @@ test('runSpriteAnimation — une animation non bouclée en cours résiste à une
});
test('resolveSpriteFrames — résout depuis gameData.personnage_animations par nom', () => {
global.gameData = { personnage_animations: { '5': { walk: ['/w0.png', '/w1.png'] } } };
global.gameData = { personnage_animations: { 5: { walk: ['/w0.png', '/w1.png'] } } };
const resolved = resolveSpriteFrames('5', { animation: 'walk', fps: 12, loop: true });
assert.deepEqual(resolved.frames, ['/w0.png', '/w1.png']);
assert.equal(resolved.fps, 12);
@@ -101,7 +101,7 @@ test('clampSceneObjectPosition — borne au bord droit/bas de la scène', () =>
withScreensData({ id: 1, scene_width: 200, scene_height: 100 }, () => {
const el = { style: { width: '40px', height: '40px' } };
assert.equal(clampSceneObjectPosition(el, 'left', 500), 160); // 200 - 40
assert.equal(clampSceneObjectPosition(el, 'top', 500), 60); // 100 - 40
assert.equal(clampSceneObjectPosition(el, 'top', 500), 60); // 100 - 40
});
});
@@ -118,7 +118,9 @@ test('clampSceneObjectPosition — borne au bord gauche/haut (jamais négatif)',
// grand que la scène doit laisser le personnage se déplacer sur TOUT le
// fond, pas seulement le petit cadre visible de la scène d'origine.
test('clampSceneObjectPosition — borne au MONDE (world_width/height) quand un fond XXL l\'a agrandi', () => {
withScreensData({ id: 1, scene_width: 200, scene_height: 100, world_width: 2000, world_height: 1000 }, () => {
withScreensData({
id: 1, scene_width: 200, scene_height: 100, world_width: 2000, world_height: 1000,
}, () => {
const el = { style: { width: '40px', height: '40px' } };
assert.equal(clampSceneObjectPosition(el, 'left', 500), 500); // pas encore au bord du MONDE
assert.equal(clampSceneObjectPosition(el, 'left', 5000), 1960); // 2000 - 40
@@ -134,7 +136,11 @@ test('clampSceneObjectPosition — sans world_width/height (aucun fond), retombe
test('applyObjectProperty — pos_x_relatif reste dans les limites de la scène après plusieurs pas', () => {
withScreensData({ id: 1, scene_width: 100, scene_height: 100 }, () => {
const el = { style: { left: '90px', top: '0px', width: '20px', height: '20px' } };
const el = {
style: {
left: '90px', top: '0px', width: '20px', height: '20px',
},
};
applyObjectProperty(el, 'pos_x_relatif', '5');
applyObjectProperty(el, 'pos_x_relatif', '5');
applyObjectProperty(el, 'pos_x_relatif', '5');
@@ -16,6 +16,7 @@ global.screensData = [];
global.heldKeys = new Set();
const actionsMod = require('../actions.js');
Object.assign(global, actionsMod);
require('../personnage-controller.js');
const { forgeDialogueBoxAdvance } = require('../dialogue-box-controller.js'); // pas mocké : la vraie logique métier
@@ -28,12 +29,12 @@ function fakeEl(style) {
const classes = new Set();
const children = [];
return {
style: style,
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
style,
classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), contains: (c) => classes.has(c) },
children,
appendChild(child) { children.push(child); },
querySelector(sel) {
if (sel === '.dialogueBoxWidget') return children.find(c => c._isDialogueBoxWidget);
if (sel === '.dialogueBoxWidget') return children.find((c) => c._isDialogueBoxWidget);
if (sel === '[data-dialogue-role="header"]') return this._header;
if (sel === '[data-dialogue-role="body"]') return this._body;
if (sel === '[data-dialogue-role="footer"]') return this._footer;
@@ -46,19 +47,26 @@ function fakeEl(style) {
test('collision "dialogue" -> boîte de dialogue -> fin de dialogue (mark_completed) marque le dialogue terminé', () => {
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur' };
const mrPinguin = {
id: 2, kind: 'personnage', name: 'Mr. Pinguin',
id: 2,
kind: 'personnage',
name: 'Mr. Pinguin',
collision_rules: [{
trigger: 'collision',
action: {
type: 'dialogue', mark_completed: true,
type: 'dialogue',
mark_completed: true,
dialogue: { id: 'd_intro', lines: [{ speaker: 'Mr. Pinguin', text: 'Bonjour !' }, { speaker: 'Charlie', text: 'Salut !' }] },
},
}],
};
const box = { id: 3, kind: 'dialogue_box' };
const playerEl = fakeEl({ left: '0px', top: '0px', width: '20px', height: '20px' });
const pinguinEl = fakeEl({ left: '5px', top: '5px', width: '20px', height: '20px' }); // chevauche playerEl
const playerEl = fakeEl({
left: '0px', top: '0px', width: '20px', height: '20px',
});
const pinguinEl = fakeEl({
left: '5px', top: '5px', width: '20px', height: '20px',
}); // chevauche playerEl
const boxWidget = fakeEl({});
boxWidget._isDialogueBoxWidget = true;
boxWidget._objectId = '3';
@@ -107,6 +115,6 @@ test('collision "dialogue" -> boîte de dialogue -> fin de dialogue (mark_comple
forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> mark_completed -> dialogue terminé, boîte masquée
assert.equal(boxWidget.classList.contains('is-active'), false);
assert.equal(window.forgeCompletedDialogueIds['d_intro'], true);
assert.equal(window.forgeCompletedDialogueIds.d_intro, true);
} finally { global.document = previousDoc; }
});
@@ -12,6 +12,7 @@ global.screensData = [];
global.heldKeys = new Set();
const actionsMod = require('../actions.js');
Object.assign(global, actionsMod);
require('../personnage-controller.js');
global.forgeApplyVariableActionOffline = global.forgeApplyVariableActionOffline || require('../offline/apply-actions.js').forgeApplyVariableActionOffline;
@@ -34,7 +35,7 @@ function fakeParentEl() {
appendChild(child) { children.push(child); },
querySelector(sel) {
const m = sel.match(/data-for-object-id="(\d+)"/);
return m ? children.find(c => c._forObjectId === m[1]) : undefined;
return m ? children.find((c) => c._forObjectId === m[1]) : undefined;
},
};
}
@@ -42,22 +43,28 @@ function fakeParentEl() {
function fakeEl(parentEl) {
const classes = new Set();
return {
style: { left: '0px', top: '0px', width: '20px', height: '20px' },
classList: { add: c => classes.add(c), remove: c => classes.delete(c), toggle: c => {}, contains: c => classes.has(c) },
style: {
left: '0px', top: '0px', width: '20px', height: '20px',
},
classList: {
add: (c) => classes.add(c), remove: (c) => classes.delete(c), toggle: (_c) => {}, contains: (c) => classes.has(c),
},
parentElement: parentEl,
};
}
function fakeBubbleEl() {
return {
className: 'collisionInteractBubble', style: {}, textContent: '',
className: 'collisionInteractBubble',
style: {},
textContent: '',
setAttribute(name, value) { if (name === 'data-for-object-id') this._forObjectId = String(value); },
};
}
function fakeDocument(elByObjectId) {
return {
querySelector: sel => { const m = sel.match(/"(\d+)"/); return m ? elByObjectId[m[1]] : undefined; },
querySelector: (sel) => { const m = sel.match(/"(\d+)"/); return m ? elByObjectId[m[1]] : undefined; },
createElement: () => fakeBubbleEl(),
};
}
@@ -65,7 +72,9 @@ function fakeDocument(elByObjectId) {
test.afterEach(() => { forgeStopCollisionRuleControllers(); });
function rect(left, top, width, height) {
return { left, top, width, height, right: left + width, bottom: top + height, shape: 'rectangle' };
return {
left, top, width, height, right: left + width, bottom: top + height, shape: 'rectangle',
};
}
test('forgeCollisionRuleTriggerActive — trigger "collision" : vrai si les boîtes se chevauchent, faux si loin', () => {
@@ -93,7 +102,7 @@ test('forgeRunCollisionRuleAction — "dialogue" délègue à forgeShowDialogueB
const previousShow = global.forgeShowDialogueBox;
global.forgeShowDialogueBox = (...args) => calls.push(args);
const lines = [{ speaker: 'PNJ', text: 'Bonjour !' }];
forgeRunCollisionRuleAction({ type: 'dialogue', dialogue: { id: 'd_1', lines: lines }, mark_completed: true }, fakeEl(), { id: 1, name: 'Crokine' });
forgeRunCollisionRuleAction({ type: 'dialogue', dialogue: { id: 'd_1', lines }, mark_completed: true }, fakeEl(), { id: 1, name: 'Crokine' });
global.forgeShowDialogueBox = previousShow;
assert.deepEqual(calls, [['d_1', 'Crokine', lines, true, undefined]]);
});
@@ -114,8 +123,12 @@ test('forgeRunCollisionRuleAction — "condition" vraie exécute la branche si_v
global.forgeShowDialogueBox = (...args) => calls.push(args);
const vraiLines = [{ speaker: 'PNJ', text: 'Vrai !' }];
const action = {
type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0',
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: vraiLines } }, si_faux: null,
type: 'condition',
variable: 'inventaire_cle',
operateur: 'superieur',
valeur: '0',
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: vraiLines } },
si_faux: null,
};
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
global.forgeShowDialogueBox = previousShow;
@@ -129,7 +142,10 @@ test('forgeRunCollisionRuleAction — "condition" fausse exécute la branche si_
global.forgeShowDialogueBox = (...args) => calls.push(args);
const fauxLines = [{ speaker: 'PNJ', text: 'Faux !' }];
const action = {
type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0',
type: 'condition',
variable: 'inventaire_cle',
operateur: 'superieur',
valeur: '0',
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: [] } },
si_faux: { type: 'dialogue', dialogue: { id: 'd_faux', lines: fauxLines } },
};
@@ -144,8 +160,12 @@ test('forgeRunCollisionRuleAction — "condition" avec une branche "Ne rien fair
const previousShow = global.forgeShowDialogueBox;
global.forgeShowDialogueBox = (...args) => calls.push(args);
const action = {
type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0',
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: [] } }, si_faux: null,
type: 'condition',
variable: 'inventaire_cle',
operateur: 'superieur',
valeur: '0',
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: [] } },
si_faux: null,
};
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
global.forgeShowDialogueBox = previousShow;
@@ -154,15 +174,22 @@ test('forgeRunCollisionRuleAction — "condition" avec une branche "Ne rien fair
test('forgeRunCollisionRuleAction — "variable" modifie gameData.variables via forgeApplyVariableActionOffline', () => {
global.gameData = { variables: { score: { type: 'nombre_entier', value: '2' } } };
forgeRunCollisionRuleAction({ type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '3' }, fakeEl(), { id: 1, name: 'Crokine' });
forgeRunCollisionRuleAction({
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '3',
}, fakeEl(), { id: 1, name: 'Crokine' });
assert.equal(gameData.variables.score.value, '5');
});
test('forgeRunCollisionRuleAction — "variable" chaînée (then) exécute IMMÉDIATEMENT la suivante (instantané, pas besoin d\'attendre)', () => {
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' }, vies: { type: 'nombre_entier', value: '3' } } };
const action = {
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
then: { type: 'variable', target_variable: 'vies', data_operation: 'decrementer', data_value: '1' },
type: 'variable',
target_variable: 'score',
data_operation: 'incrementer',
data_value: '1',
then: {
type: 'variable', target_variable: 'vies', data_operation: 'decrementer', data_value: '1',
},
};
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
assert.equal(gameData.variables.score.value, '1');
@@ -174,7 +201,9 @@ test('forgeRunCollisionRuleAction — "dialogue" transmet son "then" à forgeSho
const calls = [];
const previousShow = global.forgeShowDialogueBox;
global.forgeShowDialogueBox = (...args) => calls.push(args);
const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' };
const thenAction = {
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
};
forgeRunCollisionRuleAction({ type: 'dialogue', dialogue: { id: 'd_1', lines: [] }, then: thenAction }, fakeEl(), { id: 1, name: 'Crokine' });
global.forgeShowDialogueBox = previousShow;
assert.deepEqual(calls, [['d_1', 'Crokine', [], undefined, thenAction]]);
@@ -183,7 +212,7 @@ test('forgeRunCollisionRuleAction — "dialogue" transmet son "then" à forgeSho
test('forgeRunCollisionRuleAction — "surbrillance" bascule .forgeHighlight sur l\'objet CIBLÉ (object_id), pas forcément l\'objet du déclencheur', () => {
const targetEl = fakeEl();
const previousDoc = global.document;
global.document = { querySelector: sel => (sel.includes('"9"') ? targetEl : undefined) };
global.document = { querySelector: (sel) => (sel.includes('"9"') ? targetEl : undefined) };
try {
forgeRunCollisionRuleAction({ type: 'surbrillance', object_id: 9, valeur: 'on' }, fakeEl(), { id: 1, name: 'Interrupteur' });
assert.equal(targetEl.classList.contains('forgeHighlight'), true);
@@ -206,8 +235,12 @@ test('forgeRunCollisionRuleAction — "surbrillance" sans objet trouvé (référ
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
try {
forgeRunCollisionRuleAction({
type: 'surbrillance', object_id: 999, valeur: 'on',
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
type: 'surbrillance',
object_id: 999,
valeur: 'on',
then: {
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
},
}, fakeEl(), { id: 1, name: 'x' });
assert.equal(gameData.variables.score.value, '1');
} finally { global.document = previousDoc; }
@@ -223,8 +256,11 @@ test('forgeRunCollisionRuleAction — "son" joue l\'URL fournie via Audio, insta
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
try {
forgeRunCollisionRuleAction({
type: 'son', asset_url: '/assets/1/bip.mp3',
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
type: 'son',
asset_url: '/assets/1/bip.mp3',
then: {
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
},
}, fakeEl(), { id: 1, name: 'x' });
assert.deepEqual(played, ['/assets/1/bip.mp3']);
assert.equal(gameData.variables.score.value, '1');
@@ -238,8 +274,12 @@ test('forgeRunCollisionRuleAction — "video" attend forgeShowVideoOverlay avant
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
try {
forgeRunCollisionRuleAction({
type: 'video', asset_url: '/assets/1/intro.mp4', mode: 'plein_ecran',
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
type: 'video',
asset_url: '/assets/1/intro.mp4',
mode: 'plein_ecran',
then: {
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
},
}, fakeEl(), { id: 1, name: 'x' });
assert.equal(gameData.variables.score.value, '0'); // pas encore, la vidéo "joue"
capturedDone();
@@ -250,11 +290,14 @@ test('forgeRunCollisionRuleAction — "video" attend forgeShowVideoOverlay avant
test('forgeRunCollisionRuleAction — "attendre" suspend "then" jusqu\'à la fin du délai (data_value en secondes)', async () => {
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
forgeRunCollisionRuleAction({
type: 'attendre', data_value: '0',
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
type: 'attendre',
data_value: '0',
then: {
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
},
}, fakeEl(), { id: 1, name: 'x' });
assert.equal(gameData.variables.score.value, '0'); // pas encore, setTimeout pas encore résolu
await new Promise(function (resolve) { setTimeout(resolve, 10); });
await new Promise((resolve) => { setTimeout(resolve, 10); });
assert.equal(gameData.variables.score.value, '1');
});
@@ -272,16 +315,22 @@ test('forgeRunCollisionRuleAction — "indication" affiche une bulle temporisée
});
test('forgeBindClicSurvolRules — "clic" exécute l\'action au clic, sans joueur ni tick', () => {
const targetEl = { addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; }, removeEventListener() {} };
const targetEl = {
addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; },
removeEventListener() {},
};
const previousDoc = global.document;
global.document = { querySelector: () => targetEl };
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
try {
forgeBindClicSurvolRules({
elements: [{
id: 5, collision_rules: [{
id: 5,
collision_rules: [{
trigger: 'clic',
action: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
action: {
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
},
}],
}],
});
@@ -291,7 +340,10 @@ test('forgeBindClicSurvolRules — "clic" exécute l\'action au clic, sans joueu
});
test('forgeBindClicSurvolRules — "survol" se lie sur mouseenter, jamais sur click', () => {
const targetEl = { addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; }, removeEventListener() {} };
const targetEl = {
addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; },
removeEventListener() {},
};
const previousDoc = global.document;
global.document = { querySelector: () => targetEl };
try {
@@ -302,7 +354,10 @@ test('forgeBindClicSurvolRules — "survol" se lie sur mouseenter, jamais sur cl
});
test('forgeBindClicSurvolRules — un déclencheur "collision" n\'est jamais lié en écouteur DOM (reste géré par le tick)', () => {
const targetEl = { addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; }, removeEventListener() {} };
const targetEl = {
addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; },
removeEventListener() {},
};
const previousDoc = global.document;
global.document = { querySelector: () => targetEl };
try {
@@ -312,18 +367,24 @@ test('forgeBindClicSurvolRules — un déclencheur "collision" n\'est jamais li
});
test('tick — la collision entre le joueur et un objet ne redéclenche pas "dialogue" tant que le contact persiste', () => {
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } };
const npc = { id: 2, kind: 'personnage', name: 'Crokine', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue', dialogue: { id: 'd_7', lines: [] } } }] };
const player = {
id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' },
};
const npc = {
id: 2, kind: 'personnage', name: 'Crokine', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue', dialogue: { id: 'd_7', lines: [] } } }],
};
const playerEl = fakeEl();
const npcEl = fakeEl();
npcEl.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; // chevauche playerEl (0,0,20,20)
npcEl.style = {
left: '5px', top: '5px', width: '20px', height: '20px',
}; // chevauche playerEl (0,0,20,20)
const previousDoc = global.document;
global.document = fakeDocument({ 1: playerEl, 2: npcEl });
global.gameData = { personnage_animations: {} };
const previousShow = global.forgeShowDialogueBox;
const calls = [];
global.forgeShowDialogueBox = id => calls.push(id);
global.forgeShowDialogueBox = (id) => calls.push(id);
try {
const screenData = { elements: [player, npc] };
forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche
@@ -340,17 +401,24 @@ test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas
// déjà collision.width/height/offset_x/offset_y) doit être utilisé.
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur' };
const pnj = {
id: 3, kind: 'personnage',
id: 3,
kind: 'personnage',
// Sprite de 100×200 px, mais silhouette réelle 20×20 centrée en bas
// (offset_y positif = décalée vers le bas, voir collision_settings.py).
collision: { enabled: true, shape: 'rectangle', width: 20, height: 20, offset_x: 0, offset_y: 80 },
collision: {
enabled: true, shape: 'rectangle', width: 20, height: 20, offset_x: 0, offset_y: 80,
},
collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'dialogue' } } }],
};
const parent = fakeParentEl();
const playerEl = fakeEl(parent);
playerEl.style = { left: '45px', top: '175px', width: '20px', height: '20px' }; // chevauche la silhouette réelle du pnj (40,170)-(60,190)
playerEl.style = {
left: '45px', top: '175px', width: '20px', height: '20px',
}; // chevauche la silhouette réelle du pnj (40,170)-(60,190)
const pnjEl = fakeEl(parent);
pnjEl.style = { left: '0px', top: '0px', width: '100px', height: '200px' }; // grand cadre padé
pnjEl.style = {
left: '0px', top: '0px', width: '100px', height: '200px',
}; // grand cadre padé
const previousDoc = global.document;
global.gameData = { personnage_animations: {} };
@@ -365,16 +433,22 @@ test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas
});
test('tick — "interagir" affiche la bulle "Appuie sur [touche]" pendant la collision, puis la cache en sortant', () => {
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } };
const player = {
id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' },
};
const pnj = { id: 3, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'dialogue' } } }] };
const parentNear = fakeParentEl();
const parentFar = fakeParentEl();
const playerElNear = fakeEl(parentNear);
const pnjElNear = fakeEl(parentNear);
pnjElNear.style = { left: '5px', top: '5px', width: '20px', height: '20px' };
pnjElNear.style = {
left: '5px', top: '5px', width: '20px', height: '20px',
};
const playerElFar = fakeEl(parentFar);
const pnjElFar = fakeEl(parentFar);
pnjElFar.style = { left: '300px', top: '300px', width: '20px', height: '20px' };
pnjElFar.style = {
left: '300px', top: '300px', width: '20px', height: '20px',
};
const previousDoc = global.document;
global.gameData = { personnage_animations: {} };
+81 -27
View File
@@ -79,7 +79,7 @@ test('resolveVariablePath — JSON invalide ou chemin absent renvoie null', () =
});
test('readFieldValue — résout CLICKED_ROW_ID (-1) via window.lastClickedRowId', () => {
withGameData({ data: { '1': [{ id: 7, score: 42 }] } }, () => {
withGameData({ data: { 1: [{ id: 7, score: 42 }] } }, () => {
window.lastClickedRowId = 7;
assert.equal(readFieldValue(1, -1, 'score'), 42);
});
@@ -93,22 +93,28 @@ test('readVariableValue — lit gameData.variables par nom', () => {
});
test('evaluateConditionClause — source "objet" (comportement historique)', () => {
withGameData({ data: { '1': [{ id: 5, pv: '3' }] } }, () => {
const clause = { cond_definition_id: 1, cond_row_id: 5, cond_field: 'pv', cond_operator: 'inferieur', cond_value: '10' };
withGameData({ data: { 1: [{ id: 5, pv: '3' }] } }, () => {
const clause = {
cond_definition_id: 1, cond_row_id: 5, cond_field: 'pv', cond_operator: 'inferieur', cond_value: '10',
};
assert.equal(evaluateConditionClause(clause), true);
});
});
test('evaluateConditionClause — source "variable"', () => {
withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' } } }, () => {
const clause = { cond_source: 'variable', cond_variable: 'pv', cond_operator: 'inferieur', cond_value: '10' };
const clause = {
cond_source: 'variable', cond_variable: 'pv', cond_operator: 'inferieur', cond_value: '10',
};
assert.equal(evaluateConditionClause(clause), true);
});
});
test('evaluateConditionNode — clause unique (pas de cond_clauses)', () => {
withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' } } }, () => {
const node = { cond_source: 'variable', cond_variable: 'pv', cond_operator: 'egal', cond_value: '3' };
const node = {
cond_source: 'variable', cond_variable: 'pv', cond_operator: 'egal', cond_value: '3',
};
assert.equal(evaluateConditionNode(node), true);
});
});
@@ -118,8 +124,12 @@ test('evaluateConditionNode — clauses combinées ET (toutes vraies requises)',
const node = {
cond_combinator: 'et',
cond_clauses: JSON.stringify([
{ source: 'variable', variable: 'pv', operator: 'egal', value: '3' },
{ source: 'variable', variable: 'or', operator: 'egal', value: '0' },
{
source: 'variable', variable: 'pv', operator: 'egal', value: '3',
},
{
source: 'variable', variable: 'or', operator: 'egal', value: '0',
},
]),
};
assert.equal(evaluateConditionNode(node), true);
@@ -131,8 +141,12 @@ test('evaluateConditionNode — clauses combinées OU (une seule vraie suffit)',
const node = {
cond_combinator: 'ou',
cond_clauses: JSON.stringify([
{ source: 'variable', variable: 'pv', operator: 'egal', value: '999' },
{ source: 'variable', variable: 'or', operator: 'egal', value: '0' },
{
source: 'variable', variable: 'pv', operator: 'egal', value: '999',
},
{
source: 'variable', variable: 'or', operator: 'egal', value: '0',
},
]),
};
assert.equal(evaluateConditionNode(node), true);
@@ -144,45 +158,85 @@ test('evaluateConditionNode — clauses combinées OU (une seule vraie suffit)',
// collision (= sa boîte visuelle par défaut), configurable (taille,
// forme, activée/désactivée).
test('forgeCollisionRectFromBox — sans réglage, le rectangle visuel EXACT (comportement inchangé)', () => {
const box = { left: 10, top: 20, width: 40, height: 60 };
assert.deepEqual(forgeCollisionRectFromBox(box, undefined), { left: 10, top: 20, right: 50, bottom: 80, width: 40, height: 60, shape: 'rectangle' });
const box = {
left: 10, top: 20, width: 40, height: 60,
};
assert.deepEqual(forgeCollisionRectFromBox(box, undefined), {
left: 10, top: 20, right: 50, bottom: 80, width: 40, height: 60, shape: 'rectangle',
});
});
test('forgeCollisionRectFromBox — une boîte plus petite est centrée par défaut (offset 0,0)', () => {
const box = { left: 0, top: 0, width: 100, height: 100 };
const rect = forgeCollisionRectFromBox(box, { width: 20, height: 20, offset_x: 0, offset_y: 0 });
assert.deepEqual(rect, { left: 40, top: 40, right: 60, bottom: 60, width: 20, height: 20, shape: 'rectangle' });
const box = {
left: 0, top: 0, width: 100, height: 100,
};
const rect = forgeCollisionRectFromBox(box, {
width: 20, height: 20, offset_x: 0, offset_y: 0,
});
assert.deepEqual(rect, {
left: 40, top: 40, right: 60, bottom: 60, width: 20, height: 20, shape: 'rectangle',
});
});
test('forgeCollisionRectFromBox — un décalage déplace la boîte de collision sans toucher au rectangle visuel', () => {
const box = { left: 0, top: 0, width: 100, height: 100 };
const rect = forgeCollisionRectFromBox(box, { width: 20, height: 20, offset_x: 15, offset_y: -5 });
assert.deepEqual(rect, { left: 55, top: 35, right: 75, bottom: 55, width: 20, height: 20, shape: 'rectangle' });
const box = {
left: 0, top: 0, width: 100, height: 100,
};
const rect = forgeCollisionRectFromBox(box, {
width: 20, height: 20, offset_x: 15, offset_y: -5,
});
assert.deepEqual(rect, {
left: 55, top: 35, right: 75, bottom: 55, width: 20, height: 20, shape: 'rectangle',
});
});
test('forgeRectanglesOverlap — chevauchement/non-chevauchement', () => {
assert.equal(forgeRectanglesOverlap({ left: 0, top: 0, right: 10, bottom: 10 }, { left: 5, top: 5, right: 15, bottom: 15 }), true);
assert.equal(forgeRectanglesOverlap({ left: 0, top: 0, right: 10, bottom: 10 }, { left: 20, top: 20, right: 30, bottom: 30 }), false);
assert.equal(forgeRectanglesOverlap({
left: 0, top: 0, right: 10, bottom: 10,
}, {
left: 5, top: 5, right: 15, bottom: 15,
}), true);
assert.equal(forgeRectanglesOverlap({
left: 0, top: 0, right: 10, bottom: 10,
}, {
left: 20, top: 20, right: 30, bottom: 30,
}), false);
});
test('forgeCirclesOverlap — deux cercles qui se touchent/s\'évitent', () => {
const a = { left: 0, top: 0, right: 20, bottom: 20, width: 20, height: 20 }; // centre (10,10), rayon 10
const touching = { left: 15, top: 0, right: 35, bottom: 20, width: 20, height: 20 }; // centre (25,10), rayon 10, distance 15 < 20
const apart = { left: 100, top: 100, right: 120, bottom: 120, width: 20, height: 20 };
const a = {
left: 0, top: 0, right: 20, bottom: 20, width: 20, height: 20,
}; // centre (10,10), rayon 10
const touching = {
left: 15, top: 0, right: 35, bottom: 20, width: 20, height: 20,
}; // centre (25,10), rayon 10, distance 15 < 20
const apart = {
left: 100, top: 100, right: 120, bottom: 120, width: 20, height: 20,
};
assert.equal(forgeCirclesOverlap(a, touching), true);
assert.equal(forgeCirclesOverlap(a, apart), false);
});
test('forgeShapesOverlap — deux cercles utilisent le test cercle-cercle', () => {
const a = { left: 0, top: 0, right: 20, bottom: 20, width: 20, height: 20, shape: 'cercle' };
const b = { left: 15, top: 0, right: 35, bottom: 20, width: 20, height: 20, shape: 'cercle' };
const apart = { left: 100, top: 100, right: 120, bottom: 120, width: 20, height: 20, shape: 'cercle' };
const a = {
left: 0, top: 0, right: 20, bottom: 20, width: 20, height: 20, shape: 'cercle',
};
const b = {
left: 15, top: 0, right: 35, bottom: 20, width: 20, height: 20, shape: 'cercle',
};
const apart = {
left: 100, top: 100, right: 120, bottom: 120, width: 20, height: 20, shape: 'cercle',
};
assert.equal(forgeShapesOverlap(a, b), true);
assert.equal(forgeShapesOverlap(a, apart), false);
});
test('forgeShapesOverlap — une paire rectangle/cercle retombe sur le test rectangle-rectangle', () => {
const rect = { left: 0, top: 0, right: 20, bottom: 20, shape: 'rectangle' };
const circle = { left: 15, top: 15, right: 35, bottom: 35, shape: 'cercle' };
const rect = {
left: 0, top: 0, right: 20, bottom: 20, shape: 'rectangle',
};
const circle = {
left: 15, top: 15, right: 35, bottom: 35, shape: 'cercle',
};
assert.equal(forgeShapesOverlap(rect, circle), true); // les rectangles englobants se chevauchent
});
@@ -28,18 +28,25 @@ function fakeWidget(objectId) {
};
const classes = new Set();
return {
getAttribute: attr => (attr === 'data-object-id' ? String(objectId) : null),
getAttribute: (attr) => (attr === 'data-object-id' ? String(objectId) : null),
classList: {
add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c),
toggle: (c, force) => { const on = force === undefined ? !classes.has(c) : !!force; if (on) classes.add(c); else classes.delete(c); return on; },
add: (c) => classes.add(c),
remove: (c) => classes.delete(c),
contains: (c) => classes.has(c),
toggle: (c, force) => {
const on = force === undefined ? !classes.has(c) : !!force;
if (on) classes.add(c); else classes.delete(c);
return on;
},
},
querySelector: sel => {
querySelector: (sel) => {
if (sel === '[data-dialogue-role="header"]') return roles.header;
if (sel === '[data-dialogue-role="body"]') return roles.body;
if (sel === '[data-dialogue-role="footer"]') return roles.footer;
return null;
},
_roles: roles, _classes: classes,
_roles: roles,
_classes: classes,
};
}
@@ -50,16 +57,16 @@ function fakeWidget(objectId) {
// couvrir le VRAI viewport qu'en s'échappant de ce conteneur, comme
// static/js/play/video-overlay.js le fait déjà pour son propre overlay.
test('forgeEscapeCameraForFullscreen déplace le widget en enfant de <body> et retient l\'écran d\'origine', () => {
const screenEl = { getAttribute: attr => (attr === 'data-screen-id' ? '7' : null) };
const screenEl = { getAttribute: (attr) => (attr === 'data-screen-id' ? '7' : null) };
const attrs = {};
const classes = new Set();
const bodyChildren = [];
const body = { appendChild: el => { bodyChildren.push(el); el.parentNode = body; } };
const body = { appendChild: (el) => { bodyChildren.push(el); el.parentNode = body; } };
const widget = {
parentNode: null,
closest: sel => (sel === '.playScreen' ? screenEl : null),
closest: (sel) => (sel === '.playScreen' ? screenEl : null),
setAttribute: (k, v) => { attrs[k] = v; },
classList: { add: c => classes.add(c) },
classList: { add: (c) => classes.add(c) },
};
const previousDoc = global.document;
global.document = { body };
@@ -108,7 +115,7 @@ test('forgeShowDialogueBox — dialogue vide, sans mark_completed : aucun effet'
try {
forgeShowDialogueBox('d_6', 'PNJ', []);
assert.equal(widget._classes.has('is-active'), false);
assert.equal(window.forgeCompletedDialogueIds['d_6'], undefined);
assert.equal(window.forgeCompletedDialogueIds.d_6, undefined);
} finally { global.document = previousDoc; }
});
@@ -119,7 +126,7 @@ test('forgeShowDialogueBox — dialogue vide AVEC mark_completed : marque le dia
try {
forgeShowDialogueBox('d_6', 'PNJ', [], true);
assert.equal(widget._classes.has('is-active'), false);
assert.equal(window.forgeCompletedDialogueIds['d_6'], true);
assert.equal(window.forgeCompletedDialogueIds.d_6, true);
} finally { global.document = previousDoc; }
});
@@ -134,7 +141,7 @@ test('forgeShowDialogueBox — aucun widget posé sur l\'écran : aucun effet',
test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque', () => {
const widget = fakeWidget(42);
const previousDoc = global.document;
global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
global.document = { querySelector: (sel) => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
try {
forgeShowDialogueBox('d_8', 'PNJ', [{ speaker: 'PNJ', text: 'Merci !' }]);
assert.equal(widget._roles.header.textContent, 'PNJ');
@@ -182,7 +189,7 @@ test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — la voix off de la r
const paused = [];
global.document = { querySelector: () => widget };
global.Audio = function (url) {
return { url: url, play: () => Promise.resolve(), pause: function () { paused.push(this.url); } };
return { url, play: () => Promise.resolve(), pause() { paused.push(this.url); } };
};
try {
forgeShowDialogueBox('d_9', 'Marc', [
@@ -202,7 +209,7 @@ test('fin de dialogue SANS mark_completed : le dialogue n\'est jamais marqué te
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }]);
forgeDialogueBoxAdvance(42);
forgeDialogueBoxAdvance(42); // dernière réplique consommée
assert.equal(window.forgeCompletedDialogueIds['d_5'], undefined);
assert.equal(window.forgeCompletedDialogueIds.d_5, undefined);
assert.equal(widget._classes.has('is-active'), false);
} finally { global.document = previousDoc; }
});
@@ -217,7 +224,7 @@ test('fin de dialogue AVEC mark_completed : marque le dialogue terminé et propa
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], true);
forgeDialogueBoxAdvance(42);
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> dialogue terminé
assert.equal(window.forgeCompletedDialogueIds['d_5'], true);
assert.equal(window.forgeCompletedDialogueIds.d_5, true);
assert.equal(widget._classes.has('is-active'), false);
assert.equal(gameData.scoring.status, 'termine'); // seul dialogue connu, désormais complété
} finally { global.document = previousDoc; }
@@ -231,7 +238,9 @@ test('forgeShowDialogueBox — dialogue vide AVEC "then" : exécute immédiateme
global.forgeRunCollisionRuleAction = (...args) => calls.push(args);
global.document = { querySelector: () => widget };
try {
const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' };
const thenAction = {
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
};
forgeShowDialogueBox('d_6', 'Crokine', [], false, thenAction);
assert.equal(calls.length, 1);
assert.equal(calls[0][0], thenAction);
@@ -247,7 +256,9 @@ test('fin de dialogue AVEC "then" : exécute l\'action chaînée APRÈS la derni
global.forgeRunCollisionRuleAction = (...args) => calls.push(args);
global.document = { querySelector: () => widget };
try {
const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' };
const thenAction = {
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
};
forgeShowDialogueBox('d_9', 'Crokine', [{ speaker: 'PNJ', text: 'Salut !' }], false, thenAction);
assert.equal(calls.length, 0); // pas encore, le dialogue n'est pas terminé
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> "then" exécuté maintenant
@@ -268,14 +279,21 @@ test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet'
function fakeQuizChoiceBtn() {
const classes = new Set();
return { disabled: false, classList: { add: c => classes.add(c), contains: c => classes.has(c) }, _classes: classes };
return {
disabled: false,
classList: { add: (c) => classes.add(c), contains: (c) => classes.has(c) },
_classes: classes,
};
}
function fakeQuizWidget(objectId, choiceCount, attrs) {
attrs = attrs || {};
const roles = {
header: { textContent: '' }, question: { textContent: '' }, choices: { innerHTML: '' },
score: { textContent: '' }, timer: { textContent: '', classList: { add() {}, remove() {}, toggle() {} } },
header: { textContent: '' },
question: { textContent: '' },
choices: { innerHTML: '' },
score: { textContent: '' },
timer: { textContent: '', classList: { add() {}, remove() {}, toggle() {} } },
};
// Posé désactivé par forgeShowQuizBox dès la question (jamais recréé
// par forgeQuizBoxAnswer, qui se contente de .disabled=false) — voir
@@ -294,8 +312,8 @@ function fakeQuizWidget(objectId, choiceCount, attrs) {
// Simule ".closest('.playScreen')" (voir forgeEscapeCameraForFullscreen) :
// un widget "posé sur l'écran courant" est toujours, au départ,
// l'enfant d'un ".playScreen" fictif portant le même id d'écran.
closest: sel => (sel === '.playScreen' ? { getAttribute: a => (a === 'data-screen-id' ? String(window.currentScreenId) : null) } : null),
getAttribute: attr => {
closest: (sel) => (sel === '.playScreen' ? { getAttribute: (a) => (a === 'data-screen-id' ? String(window.currentScreenId) : null) } : null),
getAttribute: (attr) => {
if (attr === 'data-object-id') return String(objectId);
if (attr in attrs) return attrs[attr];
return null;
@@ -305,11 +323,16 @@ function fakeQuizWidget(objectId, choiceCount, attrs) {
// Variadique (comme le vrai DOMTokenList.remove/add) : le moteur
// appelle parfois .remove('is-active', 'is-fullscreen') d'un coup
// (voir forgeQuizResultQuit/forgeHideQuizBoxOnCurrentScreen).
add: (...cs) => cs.forEach(c => classes.add(c)), remove: (...cs) => cs.forEach(c => classes.delete(c)),
contains: c => classes.has(c),
toggle: (c, force) => { const on = force === undefined ? !classes.has(c) : !!force; if (on) classes.add(c); else classes.delete(c); return on; },
add: (...cs) => cs.forEach((c) => classes.add(c)),
remove: (...cs) => cs.forEach((c) => classes.delete(c)),
contains: (c) => classes.has(c),
toggle: (c, force) => {
const on = force === undefined ? !classes.has(c) : !!force;
if (on) classes.add(c); else classes.delete(c);
return on;
},
},
querySelector: sel => {
querySelector: (sel) => {
if (sel === '[data-quiz-role="header"]') return roles.header;
if (sel === '[data-quiz-role="question"]') return roles.question;
if (sel === '[data-quiz-role="choices"]') return roles.choices;
@@ -318,9 +341,12 @@ function fakeQuizWidget(objectId, choiceCount, attrs) {
if (sel === '.quizBoxContinueBtn') return continueBtn;
return null;
},
querySelectorAll: sel => (sel === '.quizBoxChoiceBtn' ? buttons : []),
querySelectorAll: (sel) => (sel === '.quizBoxChoiceBtn' ? buttons : []),
offsetWidth: 100,
_roles: roles, _classes: classes, _buttons: buttons, _continueBtn: continueBtn,
_roles: roles,
_classes: classes,
_buttons: buttons,
_continueBtn: continueBtn,
};
return widget;
}
@@ -351,7 +377,9 @@ function docWithDialogueAndQuiz(dialogueWidget, quizWidget) {
const QUIZ_LINES = [
{ type: 'dialogue', speaker: 'PNJ', text: 'Une question pour toi.' },
{ type: 'question', text: '2+2 ?', choices: ['3', '4'], correct_index: 1, reward_type: 'score', reward_amount: 5 },
{
type: 'question', text: '2+2 ?', choices: ['3', '4'], correct_index: 1, reward_type: 'score', reward_amount: 5,
},
{ type: 'dialogue', speaker: 'PNJ', text: 'Bien joué !' },
];
@@ -372,7 +400,7 @@ function docWithQuizOnlyNoDialogueBox(quizWidget) {
if (sel.indexOf('data-object-id="99"') !== -1) return quizWidget || null;
return null; // jamais de ".dialogueBoxWidget" sur cet écran
},
querySelectorAll: sel => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizWidget._roles.score] : []),
querySelectorAll: (sel) => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizWidget._roles.score] : []),
body: { appendChild: () => {} },
};
}
@@ -383,7 +411,9 @@ test('un quiz SANS aucune "💬 Boîte de dialogue" posée (que des questions) a
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
try {
forgeShowDialogueBox('d_30', 'Quiz', [
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
{
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
},
]);
assert.equal(quizWidget._classes.has('is-active'), true);
assert.equal(quizWidget._roles.question.textContent, 'Qui es-tu ?');
@@ -396,12 +426,14 @@ test('un quiz SANS boîte de dialogue : répondre avance/termine correctement (m
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
try {
forgeShowDialogueBox('d_31', 'Quiz', [
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
{
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
},
], true); // markCompleted
forgeQuizBoxAnswer(99, 0); // bonne réponse
assert.equal(window.forgeQuizScore, 1);
forgeQuizBoxContinue(99); // -> plus aucune ligne, dialogue épuisé
assert.equal(window.forgeCompletedDialogueIds['d_31'], true);
assert.equal(window.forgeCompletedDialogueIds.d_31, true);
assert.equal(quizWidget._classes.has('is-active'), false);
} finally { global.document = previousDoc; }
});
@@ -430,7 +462,9 @@ test('forgeQuizBoxContinue à la DERNIÈRE question d\'un modèle avec écran de
withFakeTemplateEffects({ classique: { onQuizComplete: (w, result) => { completeCalledWith = result; } } }, () => {
try {
forgeShowDialogueBox('d_50', 'Quiz', [
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
{
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
},
]);
forgeQuizBoxAnswer(99, 0); // bonne réponse — seule et dernière question
forgeQuizBoxContinue(99);
@@ -449,8 +483,12 @@ test('forgeQuizBoxContinue AVANT la dernière question : jamais d\'écran de ré
withFakeTemplateEffects({ classique: { onQuizComplete: () => { completeCalled = true; } } }, () => {
try {
forgeShowDialogueBox('d_51', 'Quiz', [
{ type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
{ type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
{
type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
},
{
type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
},
]);
forgeQuizBoxAnswer(99, 0);
forgeQuizBoxContinue(99); // avance vers la question 2, pas la fin
@@ -470,7 +508,9 @@ test('forgeQuizResultQuit (bouton de l\'écran de résultat) masque le widget PU
withFakeTemplateEffects({ classique: { onQuizComplete: () => {} } }, () => {
try {
forgeShowDialogueBox('d_52', 'Quiz', [
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
{
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
},
]);
forgeQuizBoxAnswer(99, 0);
forgeQuizBoxContinue(99); // -> écran de résultat, widget toujours actif
@@ -495,7 +535,9 @@ test('forgeQuizResultQuit ne plante jamais si window.close n\'existe pas (naviga
withFakeTemplateEffects({ classique: { onQuizComplete: () => {} } }, () => {
try {
forgeShowDialogueBox('d_54', 'Quiz', [
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
{
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
},
]);
forgeQuizBoxAnswer(99, 0);
forgeQuizBoxContinue(99);
@@ -514,15 +556,21 @@ test('forgeQuizResultQuit ne fait rien sans widget correspondant', () => {
test('forgeQuizBoxTimeout (minuteur du quiz ENTIER expiré) affiche l\'écran de résultat même s\'il restait des questions', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'quiz', 'data-timer-seconds': '5', 'data-fullscreen': '1', 'data-quiz-template': 'classique' });
const quizWidget = fakeQuizWidget(99, 2, {
'data-timer-mode': 'quiz', 'data-timer-seconds': '5', 'data-fullscreen': '1', 'data-quiz-template': 'classique',
});
const previousDoc = global.document;
let completeCalledWith = null;
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
withFakeTemplateEffects({ classique: { onQuizComplete: (w, result) => { completeCalledWith = result; } } }, () => {
try {
forgeShowDialogueBox('d_53', 'Quiz', [
{ type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
{ type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
{
type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
},
{
type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
},
]);
forgeQuizBoxTimeout(99, true); // encore la question 2 à venir
t.mock.timers.tick(1500);
@@ -552,7 +600,7 @@ test('"Continuer →" est TOUJOURS présent dès la question (désactivé), jama
test('une ligne "question" est affichée dans la boîte à QUIZ (header = nom de l\'objet déclencheur, corps = question+choix), pas la boîte de dialogue', () => {
const dialogueWidget = fakeWidget(42);
dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null);
dialogueWidget.getAttribute = (attr) => (attr === 'data-object-id' ? '42' : null);
const quizWidget = fakeQuizWidget(99, 2);
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
@@ -627,7 +675,7 @@ test('bonne réponse au quiz : crédite le score, la surligne en vert, "Continue
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !');
forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue épuisé, mark_completed -> terminé
assert.equal(window.forgeCompletedDialogueIds['d_10'], true);
assert.equal(window.forgeCompletedDialogueIds.d_10, true);
assert.equal(dialogueWidget._classes.has('is-active'), false);
} finally { global.document = previousDoc; }
});
@@ -733,9 +781,9 @@ test('minuteur "pour tout le quiz" expiré : révèle la bonne réponse PUIS ter
forgeDialogueBoxAdvance(42); // -> question, démarre le minuteur "quiz" (5s)
t.mock.timers.tick(5000); // expire : révèle la bonne réponse
assert.equal(quizWidget._buttons[1]._classes.has('is-correct'), true);
assert.equal(window.forgeCompletedDialogueIds['d_20'], undefined); // pas encore, le délai de lecture tourne
assert.equal(window.forgeCompletedDialogueIds.d_20, undefined); // pas encore, le délai de lecture tourne
t.mock.timers.tick(1500); // délai de lecture écoulé -> fin forcée du quiz
assert.equal(window.forgeCompletedDialogueIds['d_20'], true);
assert.equal(window.forgeCompletedDialogueIds.d_20, true);
assert.equal(quizWidget._classes.has('is-active'), false);
assert.equal(quizWidget._classes.has('is-fullscreen'), false);
} finally { global.document = previousDoc; }
@@ -760,7 +808,7 @@ test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" a
const previousDoc = global.document;
window.forgeQuizScore = 42;
global.document = {
querySelectorAll: sel => (sel === '.scoreWidget [data-score-role="value"]' ? values : []),
querySelectorAll: (sel) => (sel === '.scoreWidget [data-score-role="value"]' ? values : []),
};
try {
forgeUpdateAllScoreWidgets();
@@ -777,7 +825,7 @@ test('forgeUpdateAllScoreWidgets — met aussi à jour le score affiché DANS un
const previousDoc = global.document;
window.forgeQuizScore = 7;
global.document = {
querySelectorAll: sel => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizScoreEl] : []),
querySelectorAll: (sel) => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizScoreEl] : []),
};
try {
forgeUpdateAllScoreWidgets();
@@ -849,7 +897,7 @@ test('forgeCompleteDialogue marque le dialogue completed et notifie xAPI', () =>
global.gameData.scoring = { score: 0, status: 'en_cours' };
try {
forgeCompleteDialogue('d_20', 'Crokine');
assert.equal(window.forgeCompletedDialogueIds['d_20'], true);
assert.equal(window.forgeCompletedDialogueIds.d_20, true);
assert.deepEqual(calls, [{ id: 'd_20', name: 'Crokine' }]);
assert.equal(gameData.scoring.status, 'termine');
} finally { delete global.forgeXapiNotifyDialogueCompleted; }
@@ -900,20 +948,24 @@ test('forgeSyncAllDialoguesCompletionToScorm propage reussi/echoue (pas juste "t
test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et le score gagné', () => {
const dialogueWidget = fakeWidget(42);
const quizWidget = {
getAttribute: attr => (attr === 'data-object-id' ? '99' : null),
getAttribute: (attr) => (attr === 'data-object-id' ? '99' : null),
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
querySelectorAll: () => [],
};
const previousDoc = global.document;
global.document = {
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
querySelector: (sel) => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
querySelectorAll: () => [],
};
const calls = [];
global.forgeXapiNotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, score) => calls.push({ dialogueId, objectName, index, text, correct, score });
global.forgeXapiNotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, score) => calls.push({
dialogueId, objectName, index, text, correct, score,
});
try {
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }]);
forgeShowDialogueBox('d_5', 'Le Sorcier', [{
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5,
}]);
forgeQuizBoxAnswer(99, 0); // bonne réponse (correct_index=0)
assert.equal(calls.length, 1);
assert.equal(calls[0].dialogueId, 'd_5');
@@ -927,21 +979,32 @@ test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et
test('répondre à une question notifie AUSSI le SCORM 2004 (cmi.interactions.n.*, indépendant de xAPI)', () => {
const dialogueWidget = fakeWidget(42);
const quizWidget = {
getAttribute: attr => (attr === 'data-object-id' ? '99' : null),
getAttribute: (attr) => (attr === 'data-object-id' ? '99' : null),
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
querySelectorAll: () => [],
};
const previousDoc = global.document;
global.document = {
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
querySelector: (sel) => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
querySelectorAll: () => [],
};
const calls = [];
global.forgeScorm2004NotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, choiceIndex, correctIndex) =>
calls.push({ dialogueId, objectName, index, text, correct, choiceIndex, correctIndex });
global.forgeScorm2004NotifyQuestionAnswered = (
dialogueId,
objectName,
index,
text,
correct,
choiceIndex,
correctIndex,
) => calls.push({
dialogueId, objectName, index, text, correct, choiceIndex, correctIndex,
});
try {
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }]);
forgeShowDialogueBox('d_5', 'Le Sorcier', [{
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5,
}]);
forgeQuizBoxAnswer(99, 1); // mauvaise réponse (correct_index=0, répond 1)
assert.equal(calls.length, 1);
assert.equal(calls[0].dialogueId, 'd_5');
@@ -12,6 +12,7 @@ global.screensData = [];
global.heldKeys = new Set();
const actionsMod = require('../actions.js');
Object.assign(global, actionsMod);
const {
@@ -19,14 +20,19 @@ const {
forgeRunPersonnageControllerTick, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera, forgeWouldCollide,
} = require('../personnage-controller.js');
const { stopAllSpriteAnimations } = actionsMod;
function fakeSceneEl() {
const classes = new Set();
return {
style: { left: '100px', top: '100px', width: '20px', height: '20px' },
style: {
left: '100px', top: '100px', width: '20px', height: '20px',
},
classList: {
add: c => classes.add(c), remove: c => classes.delete(c),
toggle: c => (classes.has(c) ? classes.delete(c) : classes.add(c)),
contains: c => classes.has(c),
add: (c) => classes.add(c),
remove: (c) => classes.delete(c),
toggle: (c) => (classes.has(c) ? classes.delete(c) : classes.add(c)),
contains: (c) => classes.has(c),
},
};
}
@@ -37,13 +43,12 @@ function withPersonnageObject(obj, elByObjectId, fn) {
// (cherche ".sceneWorld", jamais présente dans elByObjectId ici) — un
// sélecteur qui ne matche aucun id numérique renvoie undefined plutôt
// que de lever, comme un vrai document.querySelector sans résultat.
global.document = { querySelector: sel => { const m = sel.match(/"(\d+)"/); return m ? elByObjectId[m[1]] : undefined; } };
global.document = { querySelector: (sel) => { const m = sel.match(/"(\d+)"/); return m ? elByObjectId[m[1]] : undefined; } };
global.screensData = [{ id: 1, scene_width: 500, scene_height: 500 }];
global.window.currentScreenId = 1;
global.gameData = { personnage_animations: { [obj.id]: { walk: ['/w0.png'], idle: ['/i0.png'] } } };
try { fn(); } finally { global.document = previousDoc; stopAllSpriteAnimations(); }
}
const { stopAllSpriteAnimations } = actionsMod;
test('forgePersonnageMovementAnim/forgePersonnageIdleAnim — repli silencieux si la pose est absente', () => {
global.gameData = { personnage_animations: { 5: { idle: ['/i0.png'] } } };
@@ -53,7 +58,14 @@ test('forgePersonnageMovementAnim/forgePersonnageIdleAnim — repli silencieux s
});
test('tick — touche "droite" enfoncée : déplace vers la droite et retire le miroir', () => {
const obj = { id: 10, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const obj = {
id: 10,
kind: 'personnage',
personnage_role: 'joueur',
personnage_commandes: {
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4,
},
};
const el = fakeSceneEl();
withPersonnageObject(obj, { 10: el }, () => {
global.heldKeys = new Set(['d']);
@@ -64,7 +76,14 @@ test('tick — touche "droite" enfoncée : déplace vers la droite et retire le
});
test('tick — touche "gauche" enfoncée : déplace vers la gauche et applique le miroir', () => {
const obj = { id: 11, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const obj = {
id: 11,
kind: 'personnage',
personnage_role: 'joueur',
personnage_commandes: {
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4,
},
};
const el = fakeSceneEl();
withPersonnageObject(obj, { 11: el }, () => {
global.heldKeys = new Set(['q']);
@@ -75,7 +94,14 @@ test('tick — touche "gauche" enfoncée : déplace vers la gauche et applique l
});
test('tick — axe "horizontal" : ignore haut/bas même si la touche est enfoncée', () => {
const obj = { id: 12, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'horizontal', vitesse: 4 } };
const obj = {
id: 12,
kind: 'personnage',
personnage_role: 'joueur',
personnage_commandes: {
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'horizontal', vitesse: 4,
},
};
const el = fakeSceneEl();
withPersonnageObject(obj, { 12: el }, () => {
global.heldKeys = new Set(['z']); // "haut", bloqué par l'axe
@@ -85,7 +111,14 @@ test('tick — axe "horizontal" : ignore haut/bas même si la touche est enfonc
});
test('tick — axe "vertical" : ignore gauche/droite même si la touche est enfoncée', () => {
const obj = { id: 13, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'vertical', vitesse: 4 } };
const obj = {
id: 13,
kind: 'personnage',
personnage_role: 'joueur',
personnage_commandes: {
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'vertical', vitesse: 4,
},
};
const el = fakeSceneEl();
withPersonnageObject(obj, { 13: el }, () => {
global.heldKeys = new Set(['d']); // "droite", bloqué par l'axe
@@ -95,7 +128,14 @@ test('tick — axe "vertical" : ignore gauche/droite même si la touche est enfo
});
test('tick — bornage aux limites de la scène (ne sort jamais du cadre)', () => {
const obj = { id: 14, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 50 } };
const obj = {
id: 14,
kind: 'personnage',
personnage_role: 'joueur',
personnage_commandes: {
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 50,
},
};
const el = fakeSceneEl();
el.style.left = '490px'; // 500 (scène) - 20 (largeur) = 480 max
withPersonnageObject(obj, { 14: el }, () => {
@@ -106,7 +146,14 @@ test('tick — bornage aux limites de la scène (ne sort jamais du cadre)', () =
});
test('tick — aucune touche enfoncée : ne bouge pas (l\'appel reste sans effet)', () => {
const obj = { id: 15, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const obj = {
id: 15,
kind: 'personnage',
personnage_role: 'joueur',
personnage_commandes: {
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4,
},
};
const el = fakeSceneEl();
withPersonnageObject(obj, { 15: el }, () => {
global.heldKeys = new Set();
@@ -131,7 +178,14 @@ test('tick — ignore tout objet qui n\'est pas kind="personnage" ou sans comman
// clavier, "ennemie"/"pnj" doivent rester immobiles tant qu'aucune
// logique de flow ne les pilote.
test('tick — un personnage "ennemie"/"pnj" n\'est jamais déplacé au clavier', () => {
const ennemi = { id: 17, kind: 'personnage', personnage_role: 'ennemie', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const ennemi = {
id: 17,
kind: 'personnage',
personnage_role: 'ennemie',
personnage_commandes: {
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4,
},
};
const el = fakeSceneEl();
withPersonnageObject(ennemi, { 17: el }, () => {
global.heldKeys = new Set(['d']);
@@ -165,7 +219,14 @@ function fakeWorldEl() {
}
test('forgeUpdateSceneWorldSize — le monde reste la scène tant qu\'aucun fond ne dépasse (comportement inchangé)', () => {
const screenData = { id: 1, scene_width: 500, scene_height: 500, elements: [{ kind: 'decor', x: 10, y: 10, width: 50, height: 50 }] };
const screenData = {
id: 1,
scene_width: 500,
scene_height: 500,
elements: [{
kind: 'decor', x: 10, y: 10, width: 50, height: 50,
}],
};
const world = forgeUpdateSceneWorldSize(screenData);
assert.deepEqual(world, { width: 500, height: 500 });
assert.equal(screenData.world_width, 500);
@@ -173,7 +234,14 @@ test('forgeUpdateSceneWorldSize — le monde reste la scène tant qu\'aucun fond
});
test('forgeUpdateSceneWorldSize — un fond plus grand agrandit le monde jusqu\'à ses propres bords', () => {
const screenData = { id: 1, scene_width: 500, scene_height: 500, elements: [{ kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 }] };
const screenData = {
id: 1,
scene_width: 500,
scene_height: 500,
elements: [{
kind: 'fond', x: 0, y: 0, width: 3840, height: 2160,
}],
};
const world = forgeUpdateSceneWorldSize(screenData);
assert.deepEqual(world, { width: 3840, height: 2160 });
});
@@ -182,7 +250,9 @@ test('forgeUpdateSceneCamera — sans fond XXL, transform vidé (aucun effet, co
const worldEl = fakeWorldEl();
const previousDoc = global.document;
global.document = { querySelector: () => worldEl };
const screenData = { id: 1, scene_width: 500, scene_height: 500, elements: [] };
const screenData = {
id: 1, scene_width: 500, scene_height: 500, elements: [],
};
try {
forgeUpdateSceneCamera(screenData);
assert.equal(worldEl.style.width, '500px');
@@ -193,15 +263,23 @@ test('forgeUpdateSceneCamera — sans fond XXL, transform vidé (aucun effet, co
test('forgeUpdateSceneCamera — centre le monde sur le personnage, borné aux bords du monde', () => {
const worldEl = fakeWorldEl();
const playerEl = { style: { left: '1900px', top: '1000px', width: '40px', height: '60px' } };
const playerEl = {
style: {
left: '1900px', top: '1000px', width: '40px', height: '60px',
},
};
const previousDoc = global.document;
global.document = {
querySelector: sel => (sel.indexOf('sceneWorld') !== -1 ? worldEl : playerEl),
querySelector: (sel) => (sel.indexOf('sceneWorld') !== -1 ? worldEl : playerEl),
};
const screenData = {
id: 1, scene_width: 500, scene_height: 500,
id: 1,
scene_width: 500,
scene_height: 500,
elements: [
{ kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 },
{
kind: 'fond', x: 0, y: 0, width: 3840, height: 2160,
},
{ id: 42, kind: 'personnage', personnage_role: 'joueur' },
],
};
@@ -217,15 +295,23 @@ test('forgeUpdateSceneCamera — centre le monde sur le personnage, borné aux b
test('forgeUpdateSceneCamera — ne montre jamais au-delà des bords du monde (bornage)', () => {
const worldEl = fakeWorldEl();
const playerEl = { style: { left: '3820px', top: '0px', width: '20px', height: '20px' } }; // tout au bord droit
const playerEl = {
style: {
left: '3820px', top: '0px', width: '20px', height: '20px',
},
}; // tout au bord droit
const previousDoc = global.document;
global.document = {
querySelector: sel => (sel.indexOf('sceneWorld') !== -1 ? worldEl : playerEl),
querySelector: (sel) => (sel.indexOf('sceneWorld') !== -1 ? worldEl : playerEl),
};
const screenData = {
id: 1, scene_width: 500, scene_height: 500,
id: 1,
scene_width: 500,
scene_height: 500,
elements: [
{ kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 },
{
kind: 'fond', x: 0, y: 0, width: 3840, height: 2160,
},
{ id: 42, kind: 'personnage', personnage_role: 'joueur' },
],
};
@@ -244,7 +330,7 @@ test('forgeUpdateSceneCamera — ne montre jamais au-delà des bords du monde (b
// chargement) — un test document mock dédié, avec plusieurs éléments.
function withScene(objectsById, screenData, fn) {
const previousDoc = global.document;
global.document = { querySelector: sel => { const m = sel.match(/"(\d+)"/); return m ? objectsById[m[1]] : undefined; } };
global.document = { querySelector: (sel) => { const m = sel.match(/"(\d+)"/); return m ? objectsById[m[1]] : undefined; } };
global.screensData = [{ id: 1, scene_width: 500, scene_height: 500 }];
global.window.currentScreenId = 1;
try { fn(); } finally { global.document = previousDoc; }
@@ -252,7 +338,11 @@ function withScene(objectsById, screenData, fn) {
test('forgeWouldCollide — un décor solide bloque le personnage qui l\'approche', () => {
const player = { id: 20, kind: 'personnage' };
const wallEl = { style: { left: '140px', top: '100px', width: '20px', height: '20px' } };
const wallEl = {
style: {
left: '140px', top: '100px', width: '20px', height: '20px',
},
};
const screenData = { elements: [player, { id: 21, kind: 'decor' }] };
withScene({ 21: wallEl }, screenData, () => {
// Le joueur (20x20) tente d'aller en (130,100) -> chevauche le mur (140..160, 100..120).
@@ -264,8 +354,16 @@ test('forgeWouldCollide — un décor solide bloque le personnage qui l\'approch
test('forgeWouldCollide — ignore un "fond" (jamais un obstacle) et un décor à collision désactivée', () => {
const player = { id: 22, kind: 'personnage' };
const bgEl = { style: { left: '0px', top: '0px', width: '3840px', height: '2160px' } };
const disabledEl = { style: { left: '130px', top: '100px', width: '20px', height: '20px' } };
const bgEl = {
style: {
left: '0px', top: '0px', width: '3840px', height: '2160px',
},
};
const disabledEl = {
style: {
left: '130px', top: '100px', width: '20px', height: '20px',
},
};
const screenData = {
elements: [
player,
@@ -279,10 +377,21 @@ test('forgeWouldCollide — ignore un "fond" (jamais un obstacle) et un décor
});
test('tick — un décor solide empêche d\'avancer (bloqué à son bord, jamais dans le mur)', () => {
const player = { id: 25, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const player = {
id: 25,
kind: 'personnage',
personnage_role: 'joueur',
personnage_commandes: {
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4,
},
};
const wall = { id: 26, kind: 'decor' };
const playerEl = fakeSceneEl(); // left/top 100px, 20x20
const wallEl = { style: { left: '122px', top: '100px', width: '20px', height: '20px' } };
const wallEl = {
style: {
left: '122px', top: '100px', width: '20px', height: '20px',
},
};
withPersonnageObject(player, { 25: playerEl, 26: wallEl }, () => {
global.heldKeys = new Set(['d']); // vers la droite, vers le mur juste à côté
forgeRunPersonnageControllerTick({ elements: [player, wall] });
@@ -291,14 +400,25 @@ test('tick — un décor solide empêche d\'avancer (bloqué à son bord, jamais
});
test('tick — glisse le long d\'un mur en diagonale (un seul axe bloqué)', () => {
const player = { id: 27, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const player = {
id: 27,
kind: 'personnage',
personnage_role: 'joueur',
personnage_commandes: {
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4,
},
};
const wall = { id: 28, kind: 'decor' };
const playerEl = fakeSceneEl(); // left/top 100px, 20x20
const wallEl = { style: { left: '122px', top: '100px', width: '20px', height: '20px' } }; // bloque seulement à droite
const wallEl = {
style: {
left: '122px', top: '100px', width: '20px', height: '20px',
},
}; // bloque seulement à droite
withPersonnageObject(player, { 27: playerEl, 28: wallEl }, () => {
global.heldKeys = new Set(['d', 's']); // droite (bloquée) + bas (libre)
forgeRunPersonnageControllerTick({ elements: [player, wall] });
assert.equal(playerEl.style.left, '100px'); // bloqué
assert.equal(playerEl.style.top, '104px'); // continue de descendre
assert.equal(playerEl.style.top, '104px'); // continue de descendre
});
});
@@ -40,8 +40,11 @@ function fakeMangaWidget() {
if (sel === '[data-manga-role="result"]') return resultZone;
return null;
},
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
_header: header, _progress: progress, _resultZone: resultZone, _classes: classes,
classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), contains: (c) => classes.has(c) },
_header: header,
_progress: progress,
_resultZone: resultZone,
_classes: classes,
};
}
@@ -123,7 +126,9 @@ test('forgeQuizMangaOnQuizComplete affiche le badge "不合格" (échoué) sous
test('forgeQuizMangaOnQuizComplete ne plante jamais sans widget/sans le conteneur de résultat', () => {
assert.doesNotThrow(() => forgeQuizMangaOnQuizComplete(null, { correctCount: 1, totalCount: 1 }));
assert.doesNotThrow(() => forgeQuizMangaOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 }));
assert.doesNotThrow(
() => forgeQuizMangaOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 }),
);
});
// "classique" réutilise le même calcul générique (forgeQuizProgressOnShow)
@@ -139,7 +144,8 @@ function fakeClassiqueWidget() {
if (sel === '[data-classique-role="progress"]') return progress;
return null;
},
_header: header, _progress: progress,
_header: header,
_progress: progress,
};
}
@@ -165,7 +171,13 @@ test('forgeQuizClassiqueOnShow ne plante jamais sans widget/état', () => {
function fakeClassiqueWidgetForResult() {
const contentClasses = new Set();
const content = { classList: { add: c => contentClasses.add(c), remove: c => contentClasses.delete(c), contains: c => contentClasses.has(c) } };
const content = {
classList: {
add: (c) => contentClasses.add(c),
remove: (c) => contentClasses.delete(c),
contains: (c) => contentClasses.has(c),
},
};
const resultZone = { innerHTML: '' };
const headerTitle = { textContent: 'Formation Sécurité Incendie' };
return {
@@ -176,7 +188,10 @@ function fakeClassiqueWidgetForResult() {
if (sel === '.classiqueHeaderTitle') return headerTitle;
return null;
},
_content: content, _contentClasses: contentClasses, _resultZone: resultZone, _headerTitle: headerTitle,
_content: content,
_contentClasses: contentClasses,
_resultZone: resultZone,
_headerTitle: headerTitle,
};
}
@@ -211,7 +226,9 @@ test('forgeQuizClassiqueOnQuizComplete affiche le badge "échoué" sous le seuil
test('forgeQuizClassiqueOnQuizComplete ne plante jamais sans widget/sans les éléments attendus', () => {
assert.doesNotThrow(() => forgeQuizClassiqueOnQuizComplete(null, { correctCount: 1, totalCount: 1 }));
assert.doesNotThrow(() => forgeQuizClassiqueOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 }));
assert.doesNotThrow(
() => forgeQuizClassiqueOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 }),
);
});
test('forgeQuizClassiqueOnShow retire "is-showing-result" (défensif — un futur modèle "Recommencer" réutiliserait ce garde-fou)', () => {
@@ -8,6 +8,7 @@ global.window = global.window || {};
global.gameData = global.gameData || {};
const actionsMod = require('../actions.js');
Object.assign(global, actionsMod);
require('../personnage-controller.js');
global.forgeApplyVariableActionOffline = global.forgeApplyVariableActionOffline || require('../offline/apply-actions.js').forgeApplyVariableActionOffline;
@@ -38,7 +39,9 @@ test('forgeRunScreenTriggers — ignore tout déclencheur autre que "affichage"
id: 1,
screen_triggers: [{
trigger: 'collision',
action: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
action: {
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
},
}],
});
assert.equal(gameData.variables.score.value, '0');
+20 -5
View File
@@ -14,11 +14,17 @@ const { isGameKey } = require('../triggers.js');
test('isGameKey — vrai pour une touche écoutée par un déclencheur clavier/touche_maintenue/touche_relachee', () => {
global.gameData = {
flows: {
'1': {
1: {
nodes: [
{ id: 1, node_type: 'trigger', trigger_event: 'clavier', trigger_key: ' ' },
{ id: 2, node_type: 'trigger', trigger_event: 'touche_maintenue', trigger_key: 'ArrowRight' },
{ id: 3, node_type: 'trigger', trigger_event: 'touche_relachee', trigger_key: 'ArrowLeft' },
{
id: 1, node_type: 'trigger', trigger_event: 'clavier', trigger_key: ' ',
},
{
id: 2, node_type: 'trigger', trigger_event: 'touche_maintenue', trigger_key: 'ArrowRight',
},
{
id: 3, node_type: 'trigger', trigger_event: 'touche_relachee', trigger_key: 'ArrowLeft',
},
],
edges: [],
},
@@ -31,6 +37,15 @@ test('isGameKey — vrai pour une touche écoutée par un déclencheur clavier/t
});
test('isGameKey — ignore les touches d\'un autre type de déclencheur (ex. "Au clic")', () => {
global.gameData = { flows: { '1': { nodes: [{ id: 1, node_type: 'trigger', trigger_event: 'clic', trigger_element_id: 5 }], edges: [] } } };
global.gameData = {
flows: {
1: {
nodes: [{
id: 1, node_type: 'trigger', trigger_event: 'clic', trigger_element_id: 5,
}],
edges: [],
},
},
};
assert.equal(isGameKey('Enter'), false);
});